From 4767def4b5aa68ac682676d42508643efb918ea7 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 4 Oct 2024 17:25:25 +0300 Subject: [PATCH] all: delete old code --- .github/workflows/go.yml | 45 +- .gitlab-ci.yml | 52 +- README.md | 8 +- build.sh | 2 - chat-preview.png | Bin 166865 -> 0 bytes config/config.go | 401 -------- config/doc.go | 2 - config/keybindings.yaml | 42 - deb/DEBIAN/control | 7 - debug/debug.go | 184 ---- debug/doc.go | 2 - go.mod | 52 +- go.sum | 129 --- gomuks.go | 192 ---- interface/doc.go | 2 - interface/gomuks.go | 32 - interface/matrix.go | 92 -- interface/ui.go | 89 -- lib/ansimage/LICENSE | 373 -------- lib/ansimage/ansimage.go | 297 ------ lib/ansimage/doc.go | 11 - lib/filepicker/filepicker.go | 49 - lib/notification/doc.go | 2 - lib/notification/notify_darwin.go | 65 -- lib/notification/notify_windows.go | 39 - lib/notification/notify_xdg.go | 84 -- lib/open/doc.go | 4 - lib/open/open.go | 39 - lib/open/open_darwin.go | 5 - lib/open/open_windows.go | 27 - lib/open/open_xdg.go | 7 - lib/util/doc.go | 2 - lib/util/lcp.go | 38 - main.go | 203 ---- matrix/crypto.go | 100 -- matrix/doc.go | 2 - matrix/history.go | 316 ------- matrix/matrix.go | 1418 ---------------------------- matrix/mediainfo.go | 106 --- matrix/muksevt/content.go | 44 - matrix/muksevt/event.go | 53 -- matrix/nocrypto.go | 15 - matrix/rooms/doc.go | 2 - matrix/rooms/room.go | 715 -------------- matrix/rooms/roomcache.go | 376 -------- matrix/sync.go | 267 ------ matrix/uia-fallback.go | 115 --- ui/autocomplete.go | 88 -- ui/command-processor.go | 290 ------ ui/commands.go | 1046 -------------------- ui/crypto-commands.go | 698 -------------- ui/doc.go | 2 - ui/fuzzy-search-modal.go | 165 ---- ui/help-modal.go | 117 --- ui/member-list.go | 128 --- ui/message-view.go | 682 ------------- ui/messages/base.go | 396 -------- ui/messages/doc.go | 2 - ui/messages/expandedtextmessage.go | 102 -- ui/messages/filemessage.go | 190 ---- ui/messages/html/base.go | 101 -- ui/messages/html/blockquote.go | 88 -- ui/messages/html/break.go | 54 -- ui/messages/html/codeblock.go | 59 -- ui/messages/html/colormap.go | 156 --- ui/messages/html/container.go | 148 --- ui/messages/html/entity.go | 63 -- ui/messages/html/horizontalline.go | 61 -- ui/messages/html/list.go | 123 --- ui/messages/html/parser.go | 552 ----------- ui/messages/html/spoiler.go | 120 --- ui/messages/html/text.go | 156 --- ui/messages/htmlmessage.go | 99 -- ui/messages/parser.go | 324 ------- ui/messages/redactedmessage.go | 67 -- ui/messages/textbase.go | 96 -- ui/messages/tstring/cell.go | 53 -- ui/messages/tstring/doc.go | 4 - ui/messages/tstring/string.go | 270 ------ ui/no-crypto-commands.go | 46 - ui/password-modal.go | 143 --- ui/rainbow.go | 135 --- ui/room-list.go | 592 ------------ ui/room-view.go | 937 ------------------ ui/syncing-modal.go | 71 -- ui/tag-room-list.go | 331 ------- ui/ui.go | 128 --- ui/verification-modal.go | 253 ----- ui/view-login.go | 196 ---- ui/view-main.go | 463 --------- ui/widget/border.go | 63 -- ui/widget/color.go | 224 ----- ui/widget/doc.go | 2 - ui/widget/util.go | 73 -- 94 files changed, 37 insertions(+), 16227 deletions(-) delete mode 100755 build.sh delete mode 100644 chat-preview.png delete mode 100644 config/config.go delete mode 100644 config/doc.go delete mode 100644 config/keybindings.yaml delete mode 100644 deb/DEBIAN/control delete mode 100644 debug/debug.go delete mode 100644 debug/doc.go delete mode 100644 gomuks.go delete mode 100644 interface/doc.go delete mode 100644 interface/gomuks.go delete mode 100644 interface/matrix.go delete mode 100644 interface/ui.go delete mode 100644 lib/ansimage/LICENSE delete mode 100644 lib/ansimage/ansimage.go delete mode 100644 lib/ansimage/doc.go delete mode 100644 lib/filepicker/filepicker.go delete mode 100644 lib/notification/doc.go delete mode 100644 lib/notification/notify_darwin.go delete mode 100644 lib/notification/notify_windows.go delete mode 100644 lib/notification/notify_xdg.go delete mode 100644 lib/open/doc.go delete mode 100644 lib/open/open.go delete mode 100644 lib/open/open_darwin.go delete mode 100644 lib/open/open_windows.go delete mode 100644 lib/open/open_xdg.go delete mode 100644 lib/util/doc.go delete mode 100644 lib/util/lcp.go delete mode 100644 main.go delete mode 100644 matrix/crypto.go delete mode 100644 matrix/doc.go delete mode 100644 matrix/history.go delete mode 100644 matrix/matrix.go delete mode 100644 matrix/mediainfo.go delete mode 100644 matrix/muksevt/content.go delete mode 100644 matrix/muksevt/event.go delete mode 100644 matrix/nocrypto.go delete mode 100644 matrix/rooms/doc.go delete mode 100644 matrix/rooms/room.go delete mode 100644 matrix/rooms/roomcache.go delete mode 100644 matrix/sync.go delete mode 100644 matrix/uia-fallback.go delete mode 100644 ui/autocomplete.go delete mode 100644 ui/command-processor.go delete mode 100644 ui/commands.go delete mode 100644 ui/crypto-commands.go delete mode 100644 ui/doc.go delete mode 100644 ui/fuzzy-search-modal.go delete mode 100644 ui/help-modal.go delete mode 100644 ui/member-list.go delete mode 100644 ui/message-view.go delete mode 100644 ui/messages/base.go delete mode 100644 ui/messages/doc.go delete mode 100644 ui/messages/expandedtextmessage.go delete mode 100644 ui/messages/filemessage.go delete mode 100644 ui/messages/html/base.go delete mode 100644 ui/messages/html/blockquote.go delete mode 100644 ui/messages/html/break.go delete mode 100644 ui/messages/html/codeblock.go delete mode 100644 ui/messages/html/colormap.go delete mode 100644 ui/messages/html/container.go delete mode 100644 ui/messages/html/entity.go delete mode 100644 ui/messages/html/horizontalline.go delete mode 100644 ui/messages/html/list.go delete mode 100644 ui/messages/html/parser.go delete mode 100644 ui/messages/html/spoiler.go delete mode 100644 ui/messages/html/text.go delete mode 100644 ui/messages/htmlmessage.go delete mode 100644 ui/messages/parser.go delete mode 100644 ui/messages/redactedmessage.go delete mode 100644 ui/messages/textbase.go delete mode 100644 ui/messages/tstring/cell.go delete mode 100644 ui/messages/tstring/doc.go delete mode 100644 ui/messages/tstring/string.go delete mode 100644 ui/no-crypto-commands.go delete mode 100644 ui/password-modal.go delete mode 100644 ui/rainbow.go delete mode 100644 ui/room-list.go delete mode 100644 ui/room-view.go delete mode 100644 ui/syncing-modal.go delete mode 100644 ui/tag-room-list.go delete mode 100644 ui/ui.go delete mode 100644 ui/verification-modal.go delete mode 100644 ui/view-login.go delete mode 100644 ui/view-main.go delete mode 100644 ui/widget/border.go delete mode 100644 ui/widget/color.go delete mode 100644 ui/widget/doc.go delete mode 100644 ui/widget/util.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 186383d..ee71f45 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -5,45 +5,34 @@ on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ["1.22", "1.23"] + name: Lint and test ${{ matrix.go-version == '1.23' && '(latest)' || '(old)' }} + steps: - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: "1.22" - - - 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.21", "1.22"] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Go ${{ matrix.go-version }} uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} + cache: true - - name: Install libolm - run: sudo apt-get install libolm-dev libolm3 + - name: Install dependencies + run: | + sudo apt-get install libolm-dev libolm3 + go install golang.org/x/tools/cmd/goimports@latest + go install honnef.co/go/tools/cmd/staticcheck@latest + pip install pre-commit + export PATH="$HOME/go/bin:$PATH" - name: Build run: go build -v ./... + - name: Lint + run: pre-commit run -a + - name: Test run: go test -v ./... diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7ec01c1..11d0782 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,5 @@ stages: - build -- package default: before_script: @@ -14,7 +13,7 @@ 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'`'" + - 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 -Iseconds`'" script: - go build -ldflags "$GO_LDFLAGS" -o gomuks artifacts: @@ -24,10 +23,16 @@ cache: linux/amd64: <<: *build-linux image: dock.mau.dev/tulir/gomuks-build-docker:linux-amd64 + tags: + - linux + - amd64 linux/arm: <<: *build-linux image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm + tags: + - linux + - amd64 linux/arm64: <<: *build-linux @@ -36,15 +41,6 @@ linux/arm64: - 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/arm64: stage: build tags: @@ -54,31 +50,15 @@ macos/arm64: - 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'`'" + - export GO_LDFLAGS="-X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '-Iseconds'`'" 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/ + - go build -ldflags "$GO_LDFLAGS" -o gomuks + - install_name_tool -change /opt/homebrew/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks + - install_name_tool -add_rpath @executable_path gomuks + - install_name_tool -add_rpath /opt/homebrew/opt/libolm/lib gomuks + - install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks + - cp /opt/homebrew/opt/libolm/lib/libolm.3.dylib . artifacts: paths: - - gomuks-macos-arm64 - -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 + - libolm.3.dylib diff --git a/README.md b/README.md index cbec5d3..a6ba8ba 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,9 @@ [![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/). +A Matrix client written in Go using [mautrix](https://github.com/tulir/mautrix-go). ## Discussion Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net) diff --git a/build.sh b/build.sh deleted file mode 100755 index 2409c5b..0000000 --- a/build.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/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'`'" "$@" diff --git a/chat-preview.png b/chat-preview.png deleted file mode 100644 index 4f7c8751ecd9bc155b303012b96d059dff7685b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 166865 zcmcfp1AAo8_C5}0CicWmW+t|6+qP{R6Wewswr$(CZFZ6l{`s8qJLi0#H}Ldzb@#ps zyVtH-wd%gtTGe54GNSM>*f1aIc8Rz#Ij{m7u;} zUQkA%ARs?M#D(~k+%hjVUENR@Uwdw^)2L6Qlr&DH%W_fUC{{qwC|Q&)9!;-37A-rU zwm3aHL)A(Q@>t2g&k4f*K%VogTis0CCeW-~MP<=9fAz^W%^}LDcbuA-BH_I5I_54@ zT-Jf$K@G=th58lvL*QQ@p+C#bl|NF&2ws^SF0;GS-_SKCI-7H>=@IS&r*(YMT4#*>YpP%-)N~ih0Pir8d?Ieo|KZpcwS7fQfDk# z6Y}#P84Z(Q2=lZ((>XuBjdm0%1#h}xal3q^ARZMM@-AGKilw`rN}Lse_hNpD`j+2t zz49l5rWGz?aIce=gCpJNUFNT8wT{&LQTN40?LJww94_K(P>8*;7gJuD$zke}3T$O1 zx`;@~y|53xC$u-;fc-SRovl8q47fbS zWYWoywpb|oGhwAhK0ZZMr&hX7C-BD}S$I^`J%rg@leOWjzx#pFacpYh$jmO`$x10B zhD=K$VA~_%YrOKgH?U1)KI7>3rv^d3E_Tfjj|nPu-7uGl2NVFDa4B+y_TFAp1Of@) znIS^*NTh#$!;Tpl39h&P*Ysl#k{O>@$d1A*`iTg9!m@WBy7+wP!@EQ1DaESV2Nrpp zL#o|(BdR+Zd7ubD$;T)LBRoQweE=SfuHS)-49`d9qf#wzFdQyt6Fa~9Q6K%k?DL&svq&$d|Ex7-HYYsrds> zN%0|fW=XR-F1(??RIyK5IG*DKE;wf+D7jVzP7@lO9jL}>2}8*MKl9hz^DHfrt(1{G z4#HS{UG4tdD*1}a+1RH9vn|t^N05VfmWl)ymIcPO(qIrhYk`N`JOw$bl_3OAvY63v zREGjY3Xg5Siqfq0!I?)}65E8q4J|>47?J5M#T7WEo39 zp}U^lQH)h%Ky7CHe&u64#jCrCtjCoKL88t@@w*?is46KDdVH%V29WzYLCW3<(;h<5 zqmh_hBj4+jBzZDz@(d0qIQ;K>Xf537R{BAU-NY1f)1+6<^-|i&#)@HHA>H#3&S}Ea zx9gAeuq*EDuz^7#y~6PL3FUjEeO6= zlBJY=8|yv@Xun`l7*h{MV7&uG`cSN>-V3Ty}6%01xAI7C1F|%gfYhT-ABjqP-_TS z@pJ?CMxMAB1vG77Nr_3?d zM?KF;=d2(OJyq_2t`X!_N}Qe|Ty{pM(b$7u7+{Wo_u%F36R>>qQgI?;440Vs22Mp?zVmRB zek@yEGeZJqj&P5>kqw9anM0iak6~t;5NKyG(?foV*xn5pTxI))h9&EpPYOa&ffMjL zoKU|bZBk)ZiQ2%)jVL)s4aZZ+INr4>Rp_ax+3TxQl(bzkeRlUX+sUdbu1Ry)dT$l!5>Hd!kMY^nYoRN6nTrH^BS4_F=$peLc*nG?M(-E*I zZvX%TPk-hUmR-MOno=7LhA^%k-TzAH0w5+|oQ$2K*cQL&QmQ`=TQnIZ1Hri_8gM)b zP+ryK{Tyuc*6I~kPM~s+pHbr{RQ9IYb0DWH@nhzk=JExwsUXx8W21%jdB8>3cJ-^? z!%sOf<%?a4l?8~iB=FQ&Fp;Y@1;q;UW;4IAbMc~& zd600G6h$v5=^lzk@Mb?v$kE&zrNQ>o;7-Ufxh`H1I~cgduooa4`T`4o zn+jnu=JHKtIh?O10>`WMcKu%tOpaq?|JmC5PgiOgLC%*dxyJ}AFWuBPPwh-5aHS%W zC$SvP$f>j%z_gdu>zv$YfFre>X4Z`!zS)_&BX@T&Ljx^EY?D+JgXyXR1J9VY8Cs(A z4tj_ASpQOsk&)?Nvbt5@dw8lJ*&UVPfR@8+(4#N~;UT}Qr!r`RP~Uh#=tOlp)`T%|pY(HF0M3|>z8 zV7b<#*h0cd8tT_aFAOYf>g%L$t6g8A(-+kGGT+OqSDCjMH;czP@+wQu9KS;%Dz21x z`{~F=Xo;%SsVX&_jZR{FxA>%M-j(H*3B~HW`i5F-9nI}EE;eeTr_06UU>Ja{Ru0orTW?!kO?ZRlxAR_feC|V3}(4#m2X8zse*WT zr$|btqubNQJ-9+WOB8fmYjeqC)q7ebp4kdHqWFHw*nlC7<3Ki_9|nE9YM-AOE)jL| z#C=-Bl5^>J`Vh$+no!dF)q0g4~0J`jSkxcx8vm1LUq>^QzA<8Apk z1@sIUcm0uMQ(Vq)7>H2(^W`OEqr{Zmb7+zDFwd4B$Q&`;X|wMvwT@M&DCh#ALu3>b z21}7SXK$OTIlg1Ys@l>H3JfUKX#*HJ-U8~Uvxo~LXEMXV$i-6_FN0bH1fDbGlMpw? zKEI6NrPyslEw)SBXQ}es6Yww2<<2Mn%}m%jl=kdMHa9{#bGmvrI(uco(Z3i*v?&r& zoR-hxA`vlhTv}^R%(m~f8DbdcRaeT%i5W>B8;dIZt1I9LW<|)5%_GME|fUTtp71GbKak;w>B+>O|LXcx|3&>~lXEiMnK05(|HrQIk^e6|Y4$-zCxC{{PVtE4QxW!7N7bzXWfOU=prIfAcP%8{vPF`Bpq-wSBSKFX|Mdk4aW& zvE{xwixu%7!TV1Lms7gsrLm)CZeqLV5&psK|9Yl9f`eEVxR zKRt1MaL2n{S3Y_EMO^+-30}3$pnxo3Dqz*M;=UjslZAnGt)pAz*DMjq5DLm(0UK+^ z#@{g#NO-AT=ms%_{NJ1j9Sp<#3_?Q}z$p6;n zH|<5d0{ZZkMhkMu`!`zRzEj6VlnoaToWImpuq^#Ue;T*%r*O9GTjBOZc_%0nJoKE4 zZWp;f4TipfR**W(X8`D_)_R9zulCY(?|zn{m}wt4}24 z+_ZAA^$p02VrDz5HQ%x~eQ-NkY`CM>PwIMeL3>py_=_5g;g+QeYc*Re;d{T#Q+jC! z@FT{G%1N8LG*w5-YaKrFGAZ!fW3fj)eyXmN;O;h)X$%{sf?i^!{R9R7-GkD76yqk- zn83Gi9cNnv7lR6qcJEYFhP1YdLe2xhF{d>o<^$%ZSibXKG^fvgo=V(6;E>)%>{Khl zjAZxTo$vOz*EIk7cl{UFT=T9M5v z&1&NfOV$er$9R7cbwbIf0h2$KH3%vgGC!0)-If$%x44{BKj`L_d4^z%CW` z83{>QwR4y!Xl_cacS(R~H@K`DcLep6+JCa~$=l((WegV@)1y+8Zd<%PvMI?TCy{II z&t>xOl|5iz9v2iqxzbFV@TP64em8FAqumw7)t+>~gW)(Hq)lfS+kJ%C#K|m`?BaBd z9rj)7(qo2uD6+`b^f28EE@{Nvm1{ydAl;B=rSFaXekIgsK>&=={t8&tl~fl%)*NjU!)}PA>-$VT&zcxN;K4hoS~TeXFayurMUo9VCi-yHT3u-cH|mU1 zF8L{;W#EIu5I-#cC8Anx1qfOc%{|K5$q-8qIX8qHFP4%w?HKXSJj62>nV7Pb+i)!^ zg}(@|C^uXYdj5#sErr|n`k5s)M?HCRzsT?^^PC2FC21K(5zE+#R{yufw|^_+6f-=V zAfE2(Z@=yu|L2|Iiak)aqRfmE%v$@OXzs?#BZAmwVe+(0~h(QNVcst zB%{odISB=iIwEx<letFv!w|0iw(Hn&KibX(>=g)y6INTEg|jsL zP8h=5l{Hkxg{K6C*Vb$_-Qat_(A+-rh>LJ=;+LS^-7&3c+ck(Gd6o@fgj%te!MeS3 zKL|PVE#HV*aoa6xS=TqFwOuPlZW6-&DA&Zy10R;hGl#$@ZSnx%YmlLcNfQB-E_;!dhE;}BeHXi0bWe%K=? zxpQ*D$Fd?U;e+)QUCH{y4foHw1Oh zkgz75x=J>H8Q}Cg-%oj*g|XJu_IF!0AC4o3wP%9}Bd(QBZMYX`MdjLz7HFn=!L{RJ zTj^SJh7{>Qx`LRsd5|_5Pd>ff#akR=kquGv%ctA9Usl7#jxeQEhSD90lw9zyn{s+4n+?SytvWihN$y?9B~LQ-8?Zxh@43 zHA_%>6JT|^&X2~1rcXL%%l<~-Kw8@$Ylam3xyRased?>wbBqDxS`) z$-Gunyw;=qqrBc<%G<5m(}FVfQylaG7u{<+l`0*C9EAiv6Cp^Q(K7(6sZ;m%VhG&G z{k2?U7&0ew!gM=(q@|bDetv{j&3VXn;~@w3h3Qx<)0~GBei-%5d*mKf_FRL+Y z@Qz4(-TGbRvk{p<%00?mOpM|qV9OEoCg6gC{JSCXsR4R*yl=A+&`NJTaNFCY&oR#m zZ>SLS!z!bQ(L3OB2eB;S98LcCvArvXTAf%;;OPD}^3$P)K}4C@_ii&2=j94u>kimv zY^n5B?MiK(Jtm&x)0H;K6O`~A$(p9&?xDwtTCcYF)gWmR6z5wq5B@%6RgjQe?x?Kb z*>{8b4WZE6fija_Q`;JH2;s)$+t|!$}AYPuF-R zOe^~bJU?`CoK-$32|qxF1RHW)rQ%6PiG3b}p0*NMaFFhx4cKxucHVORsy{#xFxIfG zo_Gb8B=#9GbzI;EFDC@xNU^Nu4#}SFN(s+%Vn64w>9`f4!AsqvsKJ`evR~dFiBg3p zUDULn|LMC-1LGt&8_>EN2H66LJ9fW)`ZYj7=a9m193udS#@}s;zn@)ByBkkwyO)=; zb+{_o{sZF2T(mu)dqT6Cd+<}+5gH?tH5y6VP#%-*skpv7E`o-IxZfv~^^4om;z2E^R|wXWE4;)Zfx$w&mvPGMv$8$?fIftLX z&_QpS-An`y1Wo7lJ?8OMvva|1xgh=Rp}R%*#a6mkACvAUWPR4n0ryT%`_~p7y_HdI zAoX<@X9gS|-Qe6yBEOTJu2)d=>2!%ot#P&uJ*{sDKy(Q=9@$7Ax6!iw{ne~`& z{R$L*b=HXVM<^&c+HKJrloF*4LW+&ol=!2s*)PP#erV<4#sfj)&&$pT;TK3msrBj| zHxxD+@#>7Wmp{G1JGRp}&Fp6(>e9&tepg(mv4p`MshD2idQJRk{5B&gapKbJ;gBF$ z6NqW0;|0HuaaB|+P7WWVZu(hw-um;3k*AwB!jpm$3Z8r?C1+}XC<$_7m@by37wuMIcEVzAmI{P< zxpV;=Wv|mm0Z(W@7-wrMLn}7MKQw5o_=Y#+a8NpjDoxavV@{vu+EIrZNa`G9-@U;t zCyFpN289(ebNKDOA&a(HLJTMV4Ff{=$3$nk6ai;a>Ewz)Ze*FJ)7M#daYZPK*R*im_Xtk+o1>I`j1W;a2I_G>(Ea!$f z)cH0<2S>+cN$<{jv`(Cjh1*!<@Jt8SZwklUO#$FbQXm67DLsnx9@hrzw=^pcW1e|% z@w~s#OlliyU0t4W$ZUqqpAMYb?bxx|bKtRM%6#n$u5DK~*F4x;waXmAkcMu6LC<;b zEYu14oSn=9#2OqWb#M)zSY*xgs|Pi4?|{nATTU z+{G>ha>hpw)5HFo*!YhyZi>5dXk!F`jMYz6$y2t-bLPqc`|4fj4t}0N`MY89MA+ak zW9&S^W4=Iecj$KVN1X9Rx#Dc`yazBY0$pm}Zy+q3@HS^=vpCvh^(4sQ3$e`1+@cH1 zTK}B+p~FL#Zy=T+72NT#X1{|$&)^1dO}C$GDUm7TJwAY)euq{IA1b9~4KpkVY9XsAzVSGA+%RJj%V%5m!;QNazF+T$r zB;X33fJ&yF*AB|umZ!MQR1y4o^Qo*CgEEZ$c|7L(`V|TJmy(70vec8p4$*P-A}rIw z3A%$aG1r@KpqYKGvMho_MNh$U_Nb~fOI+gVW|j|C>ExNv!6(@ z2!4QRz}6Eps3ecG)7gYUDLX!@*_B?5D|TX09u`Mim7L5bS_wZSo|7OzO3q?!&^?wR z{wsjwmXA3C)fP8;Y-O|e`(~IKO~5Kbg-G-Sm0;v&d5OdaLFj%6x`2`t7&-vbpsg~o zj{*40T7mg|GrmJp6z#Ue2aa%UULAnw~ykX5@!LOJuL~xbY)`#cNSz7YmD}G^KiTcVSBiiVFfTC+IWsssOndxIw;`fKMQZ)gl z=%5uA?bD@NYVjrHCu@z#oKs?F-MTWlbk}R?^6<)vHTjiixTeshhqJ}>Q0#B2oRBQl z2BP{=67qoq&BuZG+Tu}VM27nk9~;V%_41u*p)!~J(=??-Y7(O>9l^Y=Xp39H=;Gs7 zou{)T@pBLG73-<|r5&uiGC{FDath=|w3MT_TbzD2Mzvv1WnLPZ>>=}5V=P^Uu!Fua zAVI{*ESqif%*^p~+?uNm#;oAVjTxmYK2lyg=rXqQ!Y+9tUiDgCo;UP96R`=pxCBZY zr5iT~K~7BM74o%g=v@7k1Rp{BKbo-S^fC8`0ml8=+iE$QPk`qPJFVLIjZRuT1JvMo z5pf@Ub>LI1=CA30C=*P*34=TAOdAH5gY>+7E+rp8+~XEPXG5Jj{it3bic@bN`mZ0W zT7a$JfSokZUb^sZA6jF9-L$N2rrH70zs$CgUyyJzFXE2m|RQPM&*wJJTh1jzeG19Yp;yke`+m^bK|LPQ(JfzuSG$X(o zAHP}~mHTA+oE~0$UMqIcdD=#?w*Mk0b=)c_XD@dBe%Q@Bp2G=)^KPOhi8+GSoF%fD zK568MUvYdt9Ibp&f{z*&V0$!c*)HGW0`D1#3h@|CavgNNie?r<;g3S&6U!M)(Nwi6 zz@nXIx0@9C`NGS^Z4A2w@GKI7*nEascAY$oVNMf*?~aXd@$O^1rY0=5NBU(i6K+&P@P)-Q7i@KrAlgTGI>Hmz%XN;A}>Cwp7j33 z)$-u8?kk@tZq^N z0PRH9(P-n;uSs3R;kHNKcb;$ZBR|?)#u=UZ32|wJ!)4cOjPyc%AmWAb$zhe ztXhCEZ3C$87*u{XfepG@YRAT;$~UZeJrrotl6<|CRIDSzH=Xn&cb$B_JJc2bHH!B> z({C?C9`Sw*Errf*cASdFxpU?(PF$Dew{LfK=PqmFxy2#T|h#&CkM z#)P)o_kdwHPj{brl03}A3#~We=}9TV%}&Ja>ejfF>(0#iBi^Y<&??|>*{(=&Qseta z(CK^2Xqo5H-435=XRW4J+ATF53fjVb1vD(9*`Ru}q2&YS6_sHTcaZf4y`ew(TZ{tw zeSuqi5&g88JgQrx>__Zek3FszC13(;^dE7-~##_BXGgLhObKLDC&X7Ft z?VWLH7Cebq5k~`8Ry)tB%uXSUX2YWO!3Hi$3d-k*fqe$d`z3X(D=8){0sZ!1B(31S z+vkPq(Ddvz;FwnBpO_Gz&$te{V}auA_V`bvQpI&041O-yRrM3c z&Q`kOau&UfwAG1Zf@{E4ax~>}9q5%aLmcDIOOe18TjGpa@jI@So)ddN>vBSXQ}XP; z(9YW#{BE1av}ymOxnlkp%-_B`m?3HSO4;B|_)-l+%r;iOd?xU=;3D=}XLxOZRKOi7 z%o7-%7C(0VYUHW{qnP2G*ZHs8GJ^vKYIAYDK2%MF`mtHL-1KH@-pF?Yef3Z12kfu z65E}HA|4{D!1L;w>utFAI}`k)v0Pehl+$)j{Zm49bFbB1phcw9NDk+2Cl=4o#72S%2XHeG ztM*jJd)c8)rbM3Ia&-j=`AW|jdiwY#dTf+x3C2AuYb|MJ?Hxtfvwp zj~3F5l4vidmZ`wUl(*-U<3`KEmmSI${k~Es38>!e?u0`{-kp1QPkVUz{KFPew~}#W zeBT$>J9xf9hKnj+My5;m?6mAZ(Z6j5Hn|+lF_ea!vOOIU18iFV)N-TH9Zw&ot@D}$ z^gp(2VQ5Vc)W6RB*4nM0(!18Ze|Z`=uekMjs|r2ih(52v63khWFNt^hNPYU1GqEY| zp4)D7I%X`pEpk^e>}JjX2Y8_%1lJK{I!(LxE=p2AieKT4c;jy72;sWr!sD$mY@jSN z2B0O;?(B6XlR2$wESSpHh9kbj;6nQn&dRGUWRk0T_KioE z;?xP>?M`4V10?ez=LJ{xy=C~A-7e~Q>RI=N=$MEkdo?5ZR$s~L{m-&a`k2YIMfAvuQ;rWOtrvb%cv(&O~R{s94%rne9*w&*S2)ur0TZ* znr+eqFljJkz@qwjVZNXJ*O(!V&OE)nD%!Fwnew^7-E!M3-E)!TdNOcSiFlD^QDbHzdGy08M?zw-eKj3PtY=Un=f3*5qkd{X_#UWN zSs(Eu-UDv8;(p3-B9x~A4#4dzcsj$LRM(Kj<{7mco^ic*rCGgKk$u~k5)E5GE1Qxx ziRCs$XRthFVqfIh-2kT12$e_135oM#$rBe8@ogaSaaB#T;Wt0$$yB&ueBXpua-5W} z%z%O@YyiQFKdgInMye=WoO7mj_&2Q{O*kWL0>)5ItZ7lJ{@_W^Jd7#A4H;dA)Sdvi zBle})bU+&41=44^mBs3xrX}1blZ6id+RJsxrbg;ZO*21{dX!)Wj|VXiqI4*0M}~Zn z7?X#RkAh&T8v!H+|3o}(EvgWrNBKCNFA+b|5_k&6@}C}L~xV;NmO&Eq%5HdowJ2Jil8 zGrEFVy}sk8JPd&ynrkGq{_ef2-Q=OF-DTD^qqE~LyosSH!LGBu0E^q-3Uj8d7>`dwC#o9wy(P>=1!$eiS4lGHaHhWL?n#eaf z$9~zgbPgCdM*#r)?e}RqM#}gSImJqEmJpG7iw9;c-1^LBg0{GFTko8P1xGgD*yBf= zqW%|GN5vSYt@d*Wo3>LA8*g`Z4M!ABpX|Z54Ul{+5A^O1Csx-j0JJ4Vg!StJM>P+4rwSG=?j40Zocr|G;kJdqFxT!x6(ToMV4YU43slR{CIT{oUW$#trPF zd)=FFWUC=;qbAzP#@xcm_KUsnDf0Y*zMec_Od93R8nt400T-B1qcX9j8?dT<1{31y z#Z!#pizyYE8NPsTa`Puk#{Tn5andiGHMKw)Qbda6I+Yuc*5T~HB$YneNxR6p{INtp zBG04yUaYK)LE&QE-@d8g?BK;1lPLSk7?4aXN9aG8Ii`O6^vm@MZfSuLuT)?8jrrWt zNBaR*ck&JP)1xrBGqvI!=>c~updi$YBeE;WG$%`lK$ch^OxvC?Ck+Tv&9+*DeS$t5vTeT-r%Or*--M?A5E<02oHK;?3e z1WV=s`ODyB>NM~MseUCSO5)Bvrd929knT6a`p2SlMEI|C28p*i{m46ou}~rk3DC@f z&o`D&_g!Wat7gT9J4zj|U2q+@TgBufrlWm3;Rio@xEP&bR)R@m%|Gy>!uaM9U8Yka z049Dk@$GpCe=@tbqPquM*$Aj7Hf^_ntU|g-!Cxw)_T!8`9 zye=PJKwAo~51h#j^~UJeM9bO%9WF9QN#~d1lx>QwhH*_QsEVa?@CU%}U%t;+C!(Rr zD=A3{gEmfQwin-juu`t7o(a)zObmH{!L=bYra9!(*>ZOFa`d{w0gz18D(&8 zeC+3M13miXxg8t9D4^|?nRj;%6cm?>Y23YD-bq$QFKyp1jExi7pK2^lZ79md$Qp%_ zu}YziT6d9qK5ckC#E}Mtknk=rV!o2%1yjq5&vy{G$P)AZ995y^YdOYr^g>OdLCL3c zlI^vUyAFkoDklsvx0jk|V;3@Y%;%nIY>wK!(=T_5-v|M13C$c73q~ppssz0s={=6UkgZw~)KP!0IPcd0RM!r%ROw=T_9qJ6S@O zP(q1Vo300?DSZfrufmk_ z1YQdK-u-w`cf<+gjbEpJ*-h_4uc+cBjq4hE%TBk7#R9DVs%O_*ro5*8ufvrtdP0UNX_K&H-=d8K zoSglncpZ8zC+|p-QuF~k`hz2(-{nx{Fr177~l>X zZd_C3m>>}lHW#oU723oY0Fobm*I26(&nj5F6fGOx#;Y)c?wu` zF30aCjaBrtVm`-VouOR>>gv8=y=3w;WhX6}eh|3AP3$D3d=4iQHf|JJlbU;?QEM;{ zgXty8YZNKAnYuon$*zo!P5d!|9$@x>iT~5DB4TgDQQi%tg|+f#_cViDF3$F#4;qBn zoxQ|jZ(^r3sVKx17E%HevHvxQT*y9UZN%PwGJ{4x)&1ZI@;=bTd&d8&8}MPx=X%EcA>Dd5%zXQNB8<8K z=xZpvKVL_*AE%DFKMhA#nC^%=)@@Be8ok=jV_J5DJ7nx!2{Z&46isdl=^G-%7;;Lg zqRte4h6$C+$((E_geOUg-|K`B1bbJSiOBB$uOPy=|K5O(i*4nrnG>ge3G zzoyXzb1F)Nfs~arPOHxGkl@QT%oMbG6UO3}99^Xox#k;YzU<9&i<#r!;x}h+%Fv#i z#sa765ZP{5)9371*>o`GP^uogj7N5>`KkdRe=COzk~bxZk~ho@HU%dZ1XL{q>OX@S zd#S|VL6ha7244YX8>*NvLE-QOZOLGVZqUV^5Z9h;2uql2O?ekFkDv zxVIrDfxo9sm!|}fWjZtSEO`#X-Z@H8Ih@1u>8w-AV~g3MPmS)m+gtp7j93^FhQw(+ z{jM6m!<<J?-NlH!oD^aYhxZPHu5EBOPVzG)+8ELxWS}ph}q!BWH;tm0qnG}ntZOI5q5LZ z^35-k_LCE7RTZ_p1viQgc|08rBJ^qgSRecocimC{rg32w3zYW^Z-_`w6<#afNjI-&r_V z152aFoGgwI`ae14;jTV$e!cmKoMnLCJ_VAta9Pdsplp3hhyuVmiuqz3{)AM`$v=|k;!RBCj_CT{D1up^y};vIg-O?j2WIP} ze}ZA)AaGL>7^f zU=`5;{#<^fJUAY89E}zFO0+dYuAe`3!Waebm#c!ql>LP^{f2uurZd~27~+&TH%xyPwjh|5Vg13rE9N) zXe*5^%M!bdyis)tgphe-Fz?VTof^Bcu~1s62t03px$Ini@ezGQD=<7p>|kaO zTEBLWh(o~9NKLq8+}^tkdhs!JefP#f13p{UCs^8eN5KkGl9S?E&Tz%M0|RC2LoR=%I!H39YM*rJd181c6Q7^ z20zKZ!fiP0R9xRvAW^DGlS6O_bM=aH^(RdnGf~hhPM}}ybRXaabq~lq zO}WKkR5aU<2V6VEt@@Q|vAn->*iMW=dCbg`sSR*W2iadvvfi$lHtR1>mh?$aynBI4 zmiC14_x1tY*+0;2y!Ql$lvVVh$qnC|Y43XHeqVn!{zaQl^L*Xhc(>7&pujz#`b<}+ zY-8+OPx!lf65{@Nh98)YmavsRVO+j-bflW{y6~OBP-WMtdk>4IV^h49JJ8ILUem`r z1MwKcEeCeJsM`I*I!cqIb7Xi~*`PUaccnh{)8wsp=&wlC`T559-QjAcuedHlkwD-vHn3te!(PKe?A_$&|Xwc)NEWucwb2Y~I-y4M-}~DHRf!U8I4C)85TV zB9lZeSNcyF^{4JYMK8Lc;QqfWpZ)b*Pu-PD|EwKZIbBZpE?JNnG5mX?oE+ogj-Mgk zn&|hvg7wdax~(1kXab=m3e*~n%yJOv#eOwKdF0#abRU#>=2r$iKF$_e8RhKnBk%R3 zWV;&QO51oMNI~}CHQfA}oEH*GDaGdP)@p5Ii>^*(f^F@F-o2fR{>c6eUDK5xE6%Nz zjU96Jbtg|L(&B4+iQ9yMOmth~D`6y_HT zPh@yUA4rjOm@hg!Ect+(6i?w}reNFvvJCO+V+A@|%U871?sgD^U`?b!Zf~!@WP~#J zuEzJSqDF-HhbcKRYxm!)fuBC(YUpBH(|G(TZvULpmYpi3qRE~K*MgzIX3LY-A0QqwpqVDEZh6`U z`ej!OuBF%^YYys-j}@#G^T;pca=INR+jVt4x*swB8Wc|J50eb#r<^3NZiqhK*E?3D z1Has0dj^7i1-pA^yA)j~WQr}1DzDTx#g}KfC23!j+A3TBDm;R>o1U6;?c#!uC4ld@ z)&L;*zWn62-dKSBGRwIZe==v8#yf;{R<}J2wi2Xd)Dc(D#$JH)V9A=BH zXWz%`@%fzXY33p^_D#jVUguW}I&b7^XPqs!N_-=LXMC@#PB5!zRhcn~F>D+86&YEAi{ki@Hl~^)-}@PL83nXiti5+Fb)XJTBu|J zW%0NOObY|*GCD5WL6#d%IKr#&Hg$7>zy6a8@N1`bW!zzu>tq`p(zq3pO|3pyZO@b8 zqv&>k#yw&irwrg2__SG*5~sQ!QfjbMH*{ zbx!y?ZawUEd%vUoX9NtMk{BGd22;OAoUlAffL(BY=352CR9--qVJ~WfpmYMnH872V2%QThZfx!^eQzK`Fba#x4Occ?9DY+V|wL0FT=+>??k=58zLN z_^#w^y`1o9uVn=Ob^7AD80yrO(e^`^0Ab=Y5~E8)H0>>cqX@6|c)T`_pi6DAGePHY zUflQqP0+~;OxaVF;c}7oMUe;cE9`EcSQ1!(3d;4UwXw>y#o2>`cbfyDlwnH}W(r<> zX5GReUxk))1N-9{!tmj&u(~{eb#c86kI9^@y)fFRLpY{f)v4_K(>JR(SETw(uJfUp zQc$d#hVP5xy%>AFl)JOSBV6NAi6KAwSaRWd z<)42OFe}UbO4bNTVfKg0&#dz()(_XK5h8u#^*qMg7JC1@J=UJ%HkZu1;5LGS8TlcX z8T#}0-cq$@cgt@1PBz;uWjM}zINOidU0ETch!Q9UVG}HByqV4nmR8`qJz)xy^*jss z?DV}pku(n=EoIzW#~tx+(su{%|BtbEjIyL#;y1f&+ji9{+qP}nwrzEpUAAr8)n#_E z%eJQ8_ul)THS=NCe9CoJu9YYD&e)kd;`v2n`}6Nmly1Ei2BZBa0)D>!c;y~{~i&dj%hpT$fc~^)tSNo#}id;2XwfT{b z`P$S|9S5J`@|zKf4_o+&a1FzudBNzs{m;Qg*kZ9wSBs1G{p&J(!Hj-b6&tMRRvPt` z*bW4wA+H#uRtOTmc+YfGI-jZDX1q#?!^P&Q$($+3+p5?($ZcB0f6XZ=I2dXJ{unH$ zor0I4e*3n&hUvC(VvIl078(>dP(T61%^8Ir3n-{s4f7R;S``dbMMb&`3xtjvwd99^ z-4PtnSQ9B@1Fol*epv^hQr1S?b|$)vhW}-G{&RziWBd|iByD*Zr71OgheBcZm11Ra zj};hd*F;$uv*trV=+f!1u+9y#W<|_4afn8IGH+ZO?;fx!+OSVM?@Pqn{t|+6L~_%E zh^hh_7T7Ru<(%a|MiKsF`}g6Rpwp%?quyMG26poco7;J@pg_t&kB8PEX1cQ|0A{db zKzA9hbU4RzTv>oY1c(O|8iBDW%E1r~nWvFS70G&vEw;x3I5u1k1fMvv_LcWdF$c0R zgF-S%t=B|@PP59qN{vPAh*lF}q{?*t7G1JSr@!KUrEKud6O9HNA#g+~%~U2AsGyko z(y9UpRx>d{2N@cmK#IDkAW_Bw9N33YurAbk^!gMFebTvWVf>y75S+}NW72t3cJ14~ zP>6>G{BJOV#s;lxHs~8PLr3*f+lva%@P233=~ zHjxZR>zKz);@|_iIE~kXz~Q7J^><9kq$z=(B;u3)*vdG;$_b-Tqu3`UQI1=@tPxaE z7a$Q7s~AZ&xDT;piz?t)T6NFohLZXy0KOVaLscYC$7&&-{sX4HG`2pvOzs_}=kLM_ z=qTcSr;Y?2z?Q#utGp7L?6Y#H3<+Yk)0iP5!6CZL1ay8`!Ru`#h@lU=#xvD#b{Of> zN`oT{2~PM57<7JUecUpAT~^}$wbni~TLI{BHnwyRC>u96A#K;csyg&yx^ua|Rd*VA zJLjDp@|ac!_9SAQl3cqIk~@V0aQ~6U2)-T*Iie=(E5*C4{dcCC4O%yX6G#)ft1U=n zTCr$n$t1;6E2CJ%mWi1aY9=EWiz8Xh>U4%fC(ASK7&8}DmT;8ZxtSWK>A2eV3sr(m z>V@i`DY5LdGdJS77n6*T%wzh7lZ+x*#Ip28)g1+I4a>jRR#J`>TPXIHf&_*|m7Nq* zgUulM`n(Mx%d|a zRW#LQT5A=_GL~3j#CqG#`EF%y8%g5I?Z>BuB)<}a2jT~#d10xA8d0zGs|6P3}= z?qbh{eQr=~ScDPB_W%d1O5+Z1jIRZzTDPbGRGp|xKT>ZPzFgwiVSWkslm<{SwlCHJ zP@Efg6WdL-Xza$FhNErL$uT!>x5;PIHEqm-3JGP=bVd;XP6OxhQGY6jU>jXT*D%Fe zrEH%>at$Rkpk1joUGaen)kITFQT6N^G;j!AJg<_#Wbax5{&IEH@k9pDb;PyM>yw(6 zR_tYiDU|tSeopsgG<5>?vnF?B`QV+)p7J0hP{)7PQ^jU&+Hv22ENv%=J^B0+RW^Kc zdcW0;!hZu@>Gu&V7jI^E8ir%p3^!)vnBRW;ahTHfdhQfFeIj|s;Eyo)i016HewGU- zY(sv&!R25uViJgeZiFsZj4pQso7z$I+f^aVZbZmHb-$|a_jQWhYz0a{wYN6#j|ukf z!6EISa!R@<-{~gT!zl65!LGDtudAKEwEUqX#Ya^}ue_B3gy6jQj9*-9coi_Eb38Er zK1F|cC-6c_rq-PxEFO;6P0S7B*q^~lF7GF6=w?r$E&{UUaO6rC>3+BAwCTuQU@j(s zDF`I0lB&kf6Hl(5yPdOfyLG~KZrN%5#54*Gboa6xCrOQdbM0<211h36^OupvW^RcK zl2-f?ZLo)gFoIkN z!5geH%XIc8zCA9{xQj{PwB+<&Hn=}T^B|%jOM(t^MV>TN!O|w{qLO5y38#x?1ryOs zL>ETYbgS?MyA`RaB=u*xmDTH6N5Ld$`MwnOdOxYbYw~8~hu4Y4^E`28WzL0XCfaEo zw))B7EEio9u68o|Uzv97rEdn?9#SuHpLe0QKUr5=MdN!VpkUjtVWIteqIBV}*wGf2 zyJl&uX@`wx|NL3mm;55rgk%AC!o5F-HDuMm14k{k1D2KGP)Sv)MHw~&N$Xha#g~|f;iBq$(Rg>_Otu z!HY5Bx?5z+J_RNP+pzpY^z6+`T{asw;cO^kO3H|Q!cIW0=H8%(#+gSHOG&;NA4{Sb zz1}ePI3##gI&oUF9u`#eb4bt3>pQ3E(`IEDcb2qR5~};$N$)WDDSe36&?hUuhCU(x z%a66k9+ud|e2vB}>h<2Br`+clUrRagO@D7VG^vngyopsLI?fI+Mt(f1%KaosQh02^ z`12*6S8ZEh*$dmX_1N8@I0(&`OC){~j0`mrz)@3E$`l`=kuYIvU@Kn;;141?6ulpU z9N`srn_I^tE|#4K!Ygg?7wtj_G8K?sE*$q&C?~{+eeEG=x+}#hkk0js$g@draR8{X zUa zAg!icc7ohNK=03S#F?&rv>Ljv0CB#pLsMDv!u@CSHhMosgx+2(^Nnba-CW0eOFZei zg8~*`h#lWsKA4u{cK%8w+ERT-ZuYoy;b;dabx-_q?iD8g8KD-F7pjzHQynD^JU~yl zWZ~-qHQ&!9-stq3)0K08>(>vIC0mx(EuJX8>cWJmxa*ApgqP>=;GfGkZ==ga7{>PP zfp^}m74llZ*6>sm5aJNWO1EfeV9J)~Q+BNG!3)qDYGmejBA=*{H5{E9Yy3>V(S%Lx zCrPhEPhVY>g<1rYUaA%|$ttE}amT%gG~@klmb1YkjZH!xV}r(WKcIRxBAxFD!;w^x zKgJYg7+5scfmx7l%r3$>PFyv{hQrqRIVZ}vb7N16pg4V+N}i?0s+7`@n<_{hWrXD5 zawC;w%0h$FI-I!YlwloSvFpSo3p5?!Pa=&BAn1<+iwzX9xI-V}WRjS5wI}&KFnCS} z3R!XZZ15V>nNuGeR35=QW&iaNN)1k{1R2R2&eUMaG^~$H43n$T;uH={|_!psu6ZQ!z*2#XvWX5-a|qiw{!Z4-vx?-tx4 zuYL}5#YN`v335Uwj|#4G)|nvlaO1P71}R033Wbacxs)A(K^Y+vZp!71J%iV1gDLTJ zaX}2&>Vc_fJaQC$;OKGVj&D;25(G!U^rYLIUN1)c!qyIaC&}MOZTLRu>tyW zM{s#3ICty6L37w)`@p6jY)2S$MYfr&*=Um^w0h*)&TK&HM4jYB;GL~g$U1ga9yb}b zMyLlg5x94Ik{n*i9VP3^sT3oJ@01v);LcJ}+%%<%NOn@5y7AKUF{C%mmM|Fml1*>4 zln|4_P~_uo^e?Qvy_`ExzdVw9#dainQ|L|pdfPE;w3Ec}TbA8qrw*|%*&4k);Q3va zdXKvq8+FU&19`>(@IH>lYCi@s|Jcz9+et&KUG3(3tGMl~{km5fd*ifFEcU8NzD4 z9m>I5y+2aX+s>3%xq_1M+I6&}&zYKv6)k&a5C4p*T=oKX`uy?eU?hJgIpv>sxJ^dl z@mX9C`+Dn1{>2@*vE7jgRNYJTl260n``9RRaa@rTw~xP9lgsUS*nhmHN{X`a1)tk7 zGm?wYW9*%K`t+X-gF``LHICOBc^0F=lm|nYz}`c`L%8fRe#5z+UFza6fiP`?Oyclj z)DWsp6>-oK@{rl0QPZmJ>P1I$?_S$qqj2#JF$dk~=1AGfS+nKC6FZY9 z>|QqUFwDI;G~=)gMiGoj4${G-G1Sr+M-VSeLtzJ|IM!kGalH6ZDnbOYOj2;-(JxXW+ZoCyMrufvNiKUnraR*N zR7NWGXlo?xMT;Thm%1?nA+~k2`K) z3Np!#7Kvl|1C^2{aF3~}N@iF=2ew$%r1b}@I($24LY8{BsEjGrPY%Kcf+G2FP3zrV zy;ZM#yGyy3tUoKXl++;;AlHL@Wt1Ve77_Ea2@^c4y$FvN!3+2frraJMjvNZYW)d`^f1LWoiEgwO1xacMy`N%vB_C|AbYM| z8pp;7cp3$5L7CYR$jTGk`;%j=eoqSbh3dWMR$XaeeW9lOhU#Zscqj_3WZ8@pH7c06 zAayUjc0Ylr>kH~DKVapTy~kIjY*9*^gUOYVJlD|h=;=x*VMjc+Y`AZ9dN&{xIW4*1 zV~5dR7c3a?SG;IvM;LkDJa#Yp@5NNk1IXJgF2|(PYOf#C&K33J*DJB8(}*Vs)J8{y zXD>nF<{_c=88G^XUFlc5IdZZ8@e1F!PfzA~0Xu&FWktK#Tel8nit3Pds}K0q7U%13V(E6Kw?RMCgBpl{z1=%q(;Vb0n?;Es+XfV0vgRWu6G2 z6liPsQ#Gjd0PM@T252HCSdywu9CS(ARSU4v_k3E_LHGV!HL^zv*EZ8!5SQuvBb^l{ z8Qf=G8g}rsjlq_(#!WwA2qDYOC`%_y}-Su<@!%%kFo%XhO6Q#o$>?fU^X!Ej8H$j<9PX=vVGV9fH|Fi>*+>Pr14w+Fyugzdz#Qr(>VJ*WvxN!MQ25><9p+*S)WmDPB_n&GSVhR0^pTMfR#%kNA`o^ z&Qw*@Xr!tOOm}6-)X30;H2Hsfn=J~$jUpq7Nlvo7#`pG&i&~a9 z12%7?xn*0qQ4mMoO8oL68kTy=V$%T?s?Q9eB(1Rd53;OFKSF_>&aK)=*XN zjl{_86vv5wv~NXkUh=K`d#TazqPeE5vY-o&(sb2v!V}2LZr&A)MkzkbAyJNL{U)qL ziYpaGxHK5As0f0Mf?@uQtI`6c)?~E4hs?K;Y3Hm?)vs0ISy^ldOI6|%BZH*vZ|ZsW zyTnN!8I|`R&TT+pIgSfHmrudmq=v#dp|pY;CW9WP3g24GD*Bwnk;cl*-DN^|f0;zvT6z$G^wru`y93;o!Hhd@qtkcmPR$>3F zD!C8>r~8}1C^jAICu}R8%lrT$?fto&nS)*$cY0l7uJ#R*2}U2R(pe6x6h;6`G(vUu zi~rz+Xnho^S1oF^L(x*raKwD0LU~#KgxM4}!WmlhLk!Vl8wi~Cs0mzEbht(^X@(Js z8q(m(NI#<2R5}}UML|9_0CmZ>z93d#h>a9WI>9KWxlRk73OXueTRy>-n$4!;Cb0_4 zK2=3URo`8;QdzONUA5Y}V6=#Fc_mxBus6&@`GOEEkSKi_gKzAov{c610JqJU)oxA^ zs*sKm2ifSI!|xUwB~(U5g-p$gND?ck^78hfCM&^^CV~rbi;Q$~#)LfaK=+l3Z^mdg z8}*`+jumNwYN9eM8tUExM->)G0zvY{6|8?Z?0J-&St1H%5{zE*mU-we0}py9*#pYcFGHba<1=*1&JBs1i8bv?OUQG0v6*U7uD1VUW~K zQ8O)a*uMgA3}fZ?Y@TFUG5gemS?na3Bnl3Q(@A@ZOpBbCtei_ThIpf@Z(1x4xgSKk zzBRvMdXKFvl#_(uZx<)%iXbH4d+hii7NEC?I)L3i|IH>NYM9W;x)nc`xtzvJQOAml zf~BCLsc=v`aNxccW;=;<9>PN$j##6?V)yo$+2vvid0}FXec(ZRoY845=12;ZyXLBGV0mlQQxyxRaNB|40yDyv=$U3 zMNL3EPt#PL+4E$-wi~{mEi6=auKe!sQ|+n*1tJMy!{8nI%?Lp6{Y1jK3x-5Mpy1(& zLCktU-^3HpVup6^EcHYddBV}(zhK|_(~Q&U9f}2CaM;f7cn6jf*8dmw}ETA z^$PL|S062m<41nhB#pVq3k_Arjx+F=2+2nOgJ!KF&$e9CM!O{<6b9ooe)!#IHE{o0 zo>sGC$=I2s6}Nx5+exGQ9l=UM>wR|E6_ttPm%aP+FIJ{;yjC!@2IpU<>=E^Wbl@X$ zL5sd%aBhNf_)T`T!+G#g0lO&dF2J+N_Ud7RAeVrpG|VyB37bYR2fj&Z25SY$%ViV zFun9Re!4v(3QfDV&fCnP>G-M95ZeTxir93{G{L$nDky}n(gk8;z`NCaCJywVv{^bJ z6a8qRTt~o{icjy5K&D>ntuo4DiKwjQjy5Ag9y4UQ^2HbC4)=`2fd+dK4PmvH%*G@2 z#xxummYF_&df-lYjWshFWgsOTSl6HW>sR+My5owpk;i#&uH&i^4L;k*4_Yzn_w(jV z2h2Nq#wEg1XtG5%5;D5PGAqu-2Hj3`R^WeuDtc#Q1F~qMf(}!ZCEb3;q2z)-1dq|8 zhuw2m2)cEJSt&}IG>ZSGP~?V?n5yb@khr@tQ?-f?6M*+Gpd-NWesB;^l7D2QK(rnb z-z)}AnqQEq5ilMVld6Q9fNCNtrfSlt+e#K&(Q2-d{On@E^j7wYXw`)mJjF*x|D$ z(hxcwnC;hGQI zu;^r+B)c340J9_W>2~)888HsLF+%`6ZO-@x|B;t3o0U~1b0=+Nb+%Ezh^%^LeJed&p79i@XOt^jhVAH|;B4o9h0M!p4m4guao!|^*!wZqR z@#_T*jqKv?tqc5gXKu`6jj!5WHiJbNhZP?qjd z_~15+wkw#nS&weulGqS1|7lqHmPhrw(h6gp{rkY9dHUB28%$q4hpK!X)!7sI4B1rtH>T_M=hWBy7A={rx$b(f`^%kI zd$IDDzm)1p4!_0`8ozeUCur5M0gr&J*=2Ei!;!C=0iwzCkH0{AS~=lSQrq!Y%)FmF zGCr@uVrlA#s7eEIFSWP*vtY09-%)CFd?ma!Qh?`Uc;Ee6Jd@Gh;e@3su=(PJbLJ>suHBq$1b1{7YI?&Q~lSBAz`4CY;8pEXOt_K*h=CNXydCHyciCm0K7xSz?FvGNt8y z6;}MUfuZ2z`?-*QBrNG%W6%DHD9U zdF>s1G1`-^st1pRUwt9@2LlTZ{+(-wa4T&@mP}G`NgRn_Hijl@6Ge)Ttuq5Q>Q^RI zq+>MHiQ4-^ec+h7R#KS?l1 zHblMf*D6$egt+xb@ru3Ho5jbsfb~;}JqX)-DtIrhzpzp$^@iH(2ztLACT$WAWK27s zQOWu{Pe>F)RZ?aG%BprxINGM1r43A))m}0cFkZ3Ei&Z{>tvyoH=AdddkTrSe(%C>2 z^`=t?>h+k7L2w-FdHFl&KvTcD_9d|H705wRNkz#yG6^?sNeA>dG<0JF_NLlJn?nQv zhjnQKrc$(TZ|P6rb;Yk!#}ol8;gu@%C}jh8bxeP_-JmN3wp@utYtk62;*WNBh~HGM z_v50jsYzLyI{{RFEsB~#aiif|qMm3eg2fG_SusCy(1EbhPK!w2FyXF}q(l|bN3Cd% zs7dG>tHPeX^{je}>F?QtTBCad54K!0eo_&J z4$T#a;`WbR1u|v1OkyTRppRo2zd{S-E^Ce6s!{kC zo$WHe^RllwEW#ie&QPq8A+dW=Mthh-W~IL9TeI>_tT=YBaOoQFve zfA?l7_FiTn-qaF-V&0BPvqs{hMefqggH0Sjq`0?{+761oYipb}}xsf@D#nL97e~$2W?h$Q_7m zM`B-}r*p5cb0c$)S82+nq>chjGPE^rn|odjSC_oFAX@NfR;}KMFY zRFG_V;&0+YNpwp89p^L5A8Hc*%9!`$Erfp~!_xp>ChD-@H}{-#KS&TKLlRO7O6M&!>2M*x1mxW+ z{Hrbq<)0b*hUM?9&l?dePLp?@yEQ%Muh*`rrSGBDC^kr>EYV~lJ27oiPGeITGrQiGzfE6Ljf6(F7Dl9}ZC<2A=Keu9e5C%&Cv$)cdFA z6P$2WFlhZydpHz&JFG;48tnsV)&o%CZEYBClE901(SL(LMGRdq=>V}PxjpqVA%k$H zU79dtZK)=E{D%rZhwq_V9;CwJa)+>?y=gYO&}dQ^)lb={Y0bZ?%8EQGmn2!fPL9?drXx0tb~08D=jv zdK7Ky^hRqYcySjX{&htu<`OBOghn0ZEV0p=LI-hMc7w6^%X7^`J^A#V=Z_wxmsuzqo$tNFY_8Y<03C;s5cc z=4|n19)HPYOcUDUcF>x&Yl@_%uw5Zao}2ELqA}M8i6buqtynRwPfwqW|_i#CG5M3R>O!>--Qb zgNr}WkN3s_$Qch^(Gh=5o4vhlP(i4tDf09m*}kP8$<7rxIkmKrovCfaB&UEqc=ets zG@7)F!-1e_e^2eoFmW%n;5x9w?``IO*=R{9U^AaPnLp_2iV$|Ss9SD1Wq9;wdE~oZ z*1g{5t?R~bbh06e(Sr=>qv2oH^SeNCkfTIRTkfV4ydeF-8)%S^=K$*P10biHSn^|v zz83{8NF2NdV);jT?2-h_mS-ds$7(;NSz>sU4C<2u$)v}ObdlpVhdh!LS@BoQ75%@< z8r07)ibY9KD5*XjzK&j z#e%PZJv2#$*;_fhwgzcG+VD?R%VkkVLC23bB@*iP9iE{G`=;LWQvT_yUY)UQ>#)1^ zz*GOhRxk)*QPE&JHmhoJXz~(In50a$V#~8dOgBU1tUSMrL(d-jw~8EL{^j3ht%V#p zTB$X=i03Kr)Mp;D$JfHtkNf;~B!IZ?&Ls&Y}01f~Cat>-Z8rirm`LoVY zxcpbhfAmAj5Nkfj-!*Rj4WASgATyVdc-&jyIfBct4<;3gLn79q7u^&M2HIR?amZ0p zQsY}6bn^P?2Q`p5TNKp?y93&mmR*#r$0aZ0RoTCFoiQ3*k9w|@$2i`Rj7d=14KIW^z!YOG1Mc3CdVt?7SoZdQL*vNuoVqlq= zlr-NY=;_mn5WQrzX8P%v+bGp~wPP>`?)WJh_x>zGS%y%_a=h{TG?T@46%gMpvfZ5Y z&XShQypi#rLY)kReAOKE)vLJKzUEYGtKDAa1V_?43H(fTTF>LGvXu=Eli6=IUGzL{ ztS&k_a&q`2wevT{3y(WYZ2zP9%4&eTv^j0FS(%iOp5Ka5<_b~75hkNKRAU^9dWg3l z4~D>47r7$zn=9O}W;jxa&gd(Xz$Fn_fb3uW18C+*o4c^+oBH)OO0wslXnjfg+LIPt z;;IXVn)Vm$uok$=%%mPQQe=qOeDOys^e?q6fyKU-ckDNQ%yzi0ZroHp5JSi7y{9@O zw5cqU#C_AYKOpIT?Qf^_WPLQ!f5PBht-s|B8ZA=R#={8LwKIBPq?pXQ-d`VCgpP<^ zjj(L*u+pVM3A0+23&(uvoLISe5Y{V$H0%Q(1cMWKyZ){1>|6LBjQEp=KG7YqXDTKP z4vY+lZ3G^u(>at)aS!)k~L` zTdzB@y}$ALVA%z{$Cpw3(wyUoaI1+~dAql#br<034tSdzdH=EQ^E(*0XCE@nS&bDW zwJoYsV~}N;AvR@G{+L=9C;@9s;^>a2_eS6E$W!l+5Hg*qu*8Alr8#eHeWH5$Agha2 z$Q$;5o*xv*cb$4sQA87}Q(>$)mTVS?bmup)9FK)K>b@x^CtyyePs@ z)*1-DUdeMI%zKB5=P#v#iBT-jG3?rT6QSa*<%Ej-)Gwuux@o)?F7CZGwUUOrw7nOr zwb?rmys{rkR+6QTj@jp*PG_l3d6Uffa%VEm2zEEKjuJGLq{@4)RFuq|g?st#PrMoJ zaU)2G7A~7#oa^qkD4w&kJom?ciAepr_dlQ`5A*3aW^P6Ko6y}N_f)t0b=miFG~5&! zQ}EH&&f@!ST@oKtzF5-q=oNJGw93QBSJ zv2j6kRe5bEy_z2W$>UNA9F!Bem}Yb%{&(i;~vg` zp#PW-m<&KQK+-bMH}2jGunvsvgR!{r4Fgh(1z>W3Ty%M(q{ouiz*Y$B>+~tIsMFK% z2C!Uj^eQgBJ=gHXk@ac!(ehNccjQA!P6i$a7F!IcKXKh-{ea@OehD>$&99ZGQ!<6h zW6H&7k7gZ0JlVIRazs~WNDibuAOXMmk;WYD%i-`>nh0secykh~$dOeUb0FHLl=aG) zwcbDQiu^y`{V+_tAcAt@lJK#p<@Sg=q?MNK}#t46InCf;uh<7#bS-`+@pY$Dl?dMM+C79*ZTHC!E~= z_(Ep$;*8T<^%ZO^Ca6y3IU4VHu7Mu9B)UWQ5sP8x9b1rPetkd}f3v&=uh;ni=Eqze zLq@#uV7z+$2lmMWwot!wjBE2egyB3;GW%sRnuo)i;w1&m#NH>l)GW>MFvmFJu9KMm zw7%JRC{&l@b{cotoF*W7>I&mld$F?p&t?SI^e=wv3X@vXeuNW_+q<#a=kMNUwxxAE z@tX_eL!&RRaCKfm;XNaH?Vbl1X-+;{x<7kdc$L-B)4c-%sya90tS@^)CctKI2PRx5 zXkb=b|Dy4&n?(8^to-vT#)10(5F41M{8+X!CCWHKy5}mTh(wxq=`tW*EZGtw7!*~E zDv?Ctc1pY1G^37UJ!k+|7&O3Z$?&jx)v!U5F{_6ZCpb3SV_RG|iI&9B~(zfW_ zQHt~6NWf0-q2G^YGJgmt@q^HpYf1|Q;%Q(R#MRk-Vh6U@Oi+(p1LkppI3Je3rkvcoC3weE^8o;`YGC)xOrLL&U?ju>*!i zjqn>?EMWuyX^5uJs3Pvq~T@UFQ+ierIX??1`2FV!Jb{p84~BmRHwq z7|Sv$Gr*iw?cmN;Y@q;DX3C5jOW;T#IW`zFmMv^&m)A?@Yas4NEBwo0)UnI*ImT5aBJS36hr-rx-XBUWEAK>3@RbY_If85d{?% zA~JERn$78TIJQsss}v4&UGXU9^npEtb&>D@)Dh3xYQsNcIXsS6C|wOCDerCCFJ_Am zu^ZBd99W|`m|*j%BE6qQ2KOt#26*}`YARjgoco7Xuwf22ZH?O+i_)V1C#x` z7JKB>B>OFr3xtWX8BRl7%)c|o=yi(tVu)I$Hsfw4A|e5)!EPdwylOZ$Dbv#M|8q(D zZ#fWE3}8V*L{yvz4MV9)ts>6E~= z$F%}!Me*^RhM3Zbjyd-uY4de^_eERYM4K!8Yvz~$V5%Q6)tbjQZQt2=%2tve=vQsI z|4ZqlOdzqaZe{wLbLoh4I-!QS+l1$!+RBk;P>%>UPaEbhv*d#=z79ZSI7w1D>wA1J zIJnJsDWV~=Fl8qm1aaH_>R^}-vvmu$cDuvWW82`?t_1w7Cjm{r0uXtG0#b0~Y2;)jb zmdwsEM<4)u%V@6;tvr;rtV3Pi{AkRzoKAb<8SEJR8vQ9LZTU4s`?*Ll$B8JV`7m#8 z66+JTQhO@H5x^VBT+@;3jNHvG1c);?Ls=^4rVAE?jw7dTvU>n^$CBtZlXSV-id+2) zy8du3-y6rs*C?VAt#6HOaBe6vI)u2<1p^|!Gs-=yBQ($Mtbs29Hq;*d^~RQgj)K42 z5!X*BK7WyU+_rPZQ{@YP)=)wbgt)GAkElG_rt$tNZZm~I%)^s?ea#?bv-~L`krYEa zxsJc-bGda)*s=4BZQuI>U%R!sMKflKNH^OZnMMCs=BMmi=USg*_RbOI*)qYLf3K^f z#o(+-jvJJ5hd2??qnR)!FKN(4r9YWAoQd{l+y_D28KsGs(9UHhEJ4OW+(r=eTKilB z&d_QfI*2hv|4B~}cqbtRf>!Ga6C;@Db6U@{$^8GDOFIxQ?IZ_;m<1Z$-MpLBq5Tcv z&abWFe}WHMfVHUPmj~|nr8(@Oe-u4Ggk-FsM((tccS+OO&rW z=jsNm3q*pYk@Wc#=hXsH_TD!;G@k}=)d7U+^q8?@FlOFK3IG#ZOrYJ5Mm(ep61>L@ z^>Tzukdg|uhutNt4s-oE{ohLVd!k0d!UH1{U5}5Cvw^?jmx%*Wh^C|r`nyU=dMsxq z=NG)M?lU-F2q7W&5-P59bvq@*&CbIDPuE#LQD3XSzqN0ik(VTC_DayTn;|uxPL>Z#jvO9z9zZeRs;wWD7=5nY5A3QMtT;~MeGN<3gDZ0n6dEa4 zg~Sv-FZfM93evvapAc1W$ zJ|9GgV*}v_ZYL%^@5`*UyPxx5gAgF(Ru4=OiJz;-^mNO?oiT1zL;REU=!TrzGq^Hh zezMGG(p-ij$J{e0R>mAe8B3Ce**RGVNS-;GDXcCvO>>rIM0v#5Co=wx?mvM{DXdcX z4DavYx9-BjB_{<56s_i{k^>9qc6c;MVx|c($3!yBTDt5Zw&}!p0`kV9qfTlnjxcmv ztrO>q0;3WHcZqf2-}_>kxr80}0;d6ovkN;JlCY2phBxN>lCE|P6dD`c zQm*RwL(F>K9Wj2l@Abkvj(+{^x)32anRI}c^MRX>r*(Qk*)%1^`toPOLMRqwr`{nm zKT?H2DEPy}#^M7|Xw@6rGnB8n^-g9dW)fr}2OD~zq$8U~97&e#6sMj{C-rYeyT4rM zd(tZ~(ceN($4FS$PRVp79TV04(A23$5%BJ7d!m3B;8A~<()jEChQ3uws=Og<-K8b| z5K!DYgF2o4d3Bs{J)~gwa{ogVkT94vUu-Qc1cTsE)nwPH-Mf7MQ)DBg&v@Av^GB6I+qfL4rP{x^(*{0xLl6#Ms{309B& z&YjV@Az82Ce}JveQ?<@;4h~z^DhaRlt~Sk_fND;Kx*_@MDjfMchOH?zJ#YHs)8(SP zdFph6z?!bHzrB8$DNu{v|MZ^{_iz0yL9_oI^nZ5p|D|1!*g3ti5+toWdEvQl2pG-v zIlui@3$+)DeUbseT#7^B1DumeT`Tu3WR z`pa5L?MPEF7;Xw^eGVUHU;D8$xt^lhVPVFqc;9poQ8?!juz!6-p`-~AIxU3XX&rRLyPT8trIIT`$AbIEe6 z7!=_DrjEX~l9Nnx_^&MZzg6c7XstgH*$VtgM|i?yfuwwHq_mMgz_r-?}g3dV=! z8IMmA?Hp+oKyCa6ILs$7eAteikNDVvt^-_Z_*PHc_OpE0?ilSl|A^UnUvu((972Yt z${HHTe2~{FuIT>Pwto&mC=pZ(5{mO8YPbgWV)`?e1keK4SivVb4dEtaQGY3GWt+ai z#vJ%M$ylTd!CY1<(x?5Ltzlxwi_JekgRv&;VT`>7H3^~JVyOm=jTE`&m7o*h{HPHp zlfB+H8YM(7A*gCy%{b=s$B@qe=rhMC-&IK|}|9tn39fL^Ug;UrXdGmlU z$Vywyf8uw5^JwQLiy2EH08{A6Zl$j4ZE-4dvs5fP6TG?;Rm4Go&Y2PnCHZ%pUH2Qa z%Wqj8He+ZE)^e{HIt~quKmT*0Z3u75kx#+{}*X*8Qs>hYzrq&9LF3p z+i}dymYE@DW@ct)W@ct)W{R1aV`gUNr|h$neeQVY-1m*~tsfeaWi7S3ySi%5n%!kX zONPyX^_J=}#1soGK9a)7Ct}Z%F&C5?iuu#wBrr|$r=4NvWE&VUfQT-hj>vt;0xTj* zL3(6zR)Tg$n?(8us9xvvTuz0|P#qa7l@5l0AQ@vH$$yZbD^4re5!;WnQth&Cch%!4 z;T5!gSd@?J6Sxy1TX>kW3w1U}bD_N=8RH!4gDuao`K~rJ1Iyq*hjCYok59P21v}IH zK^l`cpIm#siYNFo{G#7G0Ck_D)z?<5%R|9>U6Hhf@nvj|g3Re|tg{D&`3EAQ9{kuV ztcIBoUPWQxbFG-`;~8C1!9~Qs+bTzu;#AL<9a8=7+`atAc$4{xVzH9Pk1&PGk z*U%+L>kzAshV*R+-9e^{5gu*`<32SSn^T4kdE8Op^dXQ9rpbC;TgfqBibBB3byr)m ziB`lr67mR?Mq26sE6sK}PnrEn8b+8o(GL$|*KLnB!OjueNf8A$yN_T@clpe^@v+jy zPxZtkHBDI>IFHwDi2#_Ms4qr$!D5@5do2>@%z&%D7~Y^*o`bgm6;Y@M?|yyr{*8J1 zCMMf;YU7gn+G;_d%lT}QVE(OM1V#I}Y=1$0cYW1|DeNM{4)%_~ZB)#FaI$uqqV(XJ zIznT1e>wKFem%aCJJEVv@E*8sLrr{P{E|8ybn%!5whj=Agk zp(6{LLh^_*%JBw_JbfyaVBQ`-U&x@(&AdR6sH9Dt`UN|sKVZ*%?{+%caJCrjq(aei znIJG@_0{9{l$9;G*k%3Rq2AAz{;RLAB*KrLcNX67;XZyug5>7||Mm^!+xgEzQqs}B z674MFSK@D9z3Gv-xg@!`5aWe>;{ zW309}BOve<);|yHznb@Ipk4XcEonu!nnm^|B;>+AjFAcNcEKn zFYWqTbfhx`q*s_%5 zU|1M#gS!rTGyT?X{src;t8XqL0B(CKG1K2Dl_^fcy?M&*jMjAufH_Q(^;HX_MVxrX zVqGD9#FE2bxlgcj`yi+!o3~Dhq;`p9fNA7$G2>iE-tUl{^2Dh!fJ|lnB$sPcYq+81 z{P5yygBtyZ{Ot%>$J4S=zP-hP2Q!#651srQ_LcK_IZ6RYnF2J-(J|b4Wv;&PD8%P8 z8bb3?vI-Izf;+W&-}gDE1-dsTtxXJm+0ks`?l&-y9wm`GQ-Ca$JLFXLhS)SEn9SH( zF_T4=JGf7FWlrYr4%nPA8;+C45__e#;}K(Sj>z+Y3D32_)tPwm-!-TMNu@c;xb`-J zT(q#`b2ApAuGt)*0*RilDOX2mZ5*P&)Uov>Ha}^6`<9`tcxF!zdEUD;osk07WICzV zUobnBL!S2Zp^3Ee?8CPJh4!FqHw(IFpVeoUH_&WC!ShqEY>1B8$$c*pSNiHjemg1Q zpx0bZH+}3pddlQ!$~MDgdfyb|-YT1Un#~vNZ2oR05cbV`+WJ@`hD0 zY+2L{QZ(MU5hN11L8sWF6`yO{wLVDJ(s#OU9qAf- zqT8LfTMP9$U2NIW@M8s3rab~gU1c{#1wQzK1lsh(4=-k?9BGK2rLJi|c%9fwDwLFg467 zl9&~OhK4M}IQ&qIzl%McCax2nkZy}!vrxZmG!j0wBChkxe5CA#Y~Tf8B~&kCD&sMA zp3K=p(oX_TO1$u6lc`n2=r3*G_r|drnChIkNr^h!!=GUa<2T$=&%N9(+??bA-B_pz zIe6(EQMQ?z^^`Su^OZQrTKJ};Q(x!UWKx?U!xcn-LC+Tx$jn773UE=Tv7%N(@qEC} zt~p?Ge?&-p$8cHY`$c&(4LQ?5Yp2n8cs5jyVt1xq;~d)#7Q-GZ zo6n3p9h9w=Z30b&MpN_4lSWGd^BU3SJKH`U%JlD<#h&5{S`=~K)`MV9NOC(=g-2SM zy@d~=u&Ux6Fs!JpZs0%-qO8qg?U2KhUx)0t;m%DJseV?(94~B6vBDSX2#>bN{ zz84R+v|;C-gs?8U4ZgR`GOGDh2wsJ+dfQyvW0)naP1cun^+R${PewskV$&Ah7Tnm| z%eUrEY4W0AQ1~>===2tBc-LY*HzfQEh4RyZ7#dYF-H!6AT&(Jx%T zv(}&7Lm!=WvtuXE3d4}a=siLgX%&Oifl>ArwC(;}cr3!?!i;LRgwnpHf(t~BL{yfY zfL}fhxlQi{4LsrlDZ5!GF){6-CTpMccE~wQ^RKl9BCXGy&)(GZ%I#M zv@oBpH#=3v))070+@TIsVG$s(Hzepz-ve$-SH2tj9ZkXK1a%@wP*JtMC{MJ{+qklw zxRW2nS2vN;ea#cRw|9fO!4ssT9{9)t>kZaAc7@^wBS!HPL25iBa!&00_Bu;wxSsE$ zliWgCsV5GyyZEl#v}1>d&GI`aj@pAaREK+EZr~B2|LR-0f@3Aw&Xws8mlTR}KjQhH ziyX%m-p%p=r(GMnq;O6m-77CGwz8nKG(8Qti{m6mf{_U{6gp6g)PhlxTanbc9LiBD z{2h;Chx!erJu1m$?7Tqme2RAj8xS%D#~GEJCStye+m7ur7P&62z zwzkNd`~Y=eU@f#t3$ioa8Tw3)goJ1Ic%A>cYO#=LzcH|+)2_D?_38R>+TVr*QZU~y z@(t@78EZhOhDQF}5H3_2iU&6+2nCIDB1=bqRVrrn=I!Nds5|N$%+4w9u#i1h+=_$n z{Y#lw=o3LGvmotviHNPECl7;*{Ab9YB+K^@A66g3k{dV&aw6C}r>8meh9?c}MNd5q z7TX5aXri!v0F%$%KDv4ak!IqXEmdmZmkuS{aevq8^Zk-g@R1Q#PDcW(XqlU`&T!&K z%yaI?YBN&Jo)zARS?Mg)idE<}0r!2{O`+kAOPyBj%WQW%jqAlAd_5Go$E-JjTqCIQ zUUe&1L{(#k+$h*>e4-dR$;CQBXJv^8A9ghq_$f2WrdMby08ylNl>%*kv$SQ=N~E(e zOOSyn3Lyj%6?(v6DKI9T{d;V&QBhE_OUVyp5@cIEy@u zoE#N76NPV~y?nWdN;GP2j}a>_b>!yUrdBrI$8i#_!vf zy%z$P7a8>9z6GJ()UK;jNENiUU7fYv@z49a<+7&C;Hg7RBNb$Mh&I-kyC&g9;=uEZ z!0}ZN75eI1YmG=aVpwQwh3Ju4_m>YRH}l^aC*}xIH4;PTAjiaf;f;f;`+F(*Y!y{Z zK1c#-2jFGOiO!mBZHM(&S&M&JNFk{LLyW+4%mVX+ zm4&x~L8R@XoxZZRIiLMz2WBUQ3DG*rdPGD^Ff*QCEL6!YI`8jK@*zdxhh+k@T|K0M zOdTZcQ|zi-^rk&JHy{E9#i<~IM?aRk*28}#WVTMs!~FgZK*WVKnzcC}8Y zi<^#Wt|tvAHRWI`34o{-dQJH~YXQv}@|ZQc&YObiUz0?YwMYo>Z+ixMU+8C@X!>T) z7SyN5kEcetZ!ImC2{d!Y!X=s>s;}NQ2W;qH(0@iISL4tpA%yNu1V`%v)T z>m4BQ5`~wExfkBk zILbk(^!sIkO#t?q#K3d=sB$A(sC?v82^kpTwhPggwbvm*Te*?IB?@;W@M#h5Efsx$tX*Ow|GjdQi4_hF)ppc(cBEImwn?j zrcN82a_Zx~5BIUX4{N=-3{OHMb<&!n1XgF(gsVuBS!XP+<{3W{o}ve<6bHm!!wBBm zKr2G7l$<$FlW(zzrYvysueAPUBZ^_dC8{8Nrx>cOb>E!Oc5W zYi*@+BTMwU>J`lC`s`=JlHi9+j#e$6CNQ^#C@VAL= zu9dPmWI8%-Xf;&3ELF_3oG>0@98R3hDIdRVe3fTACv};q?Kj`(u4*$pIr4Ym`Y2Ia z(w*4*mQ%3zAP{!SF;jA4&)wBoR*%DRst4PDi^}0tNYMG_6c7E7REjy5yuPlCx(tnC zCTfIde2O1W;|ojV(4|(yy@XVk^hde(UAr2(_5<>m{Lus+;oV z`3SG#vEL3I`}JtL)=h(i6w8r1dK2(BoIxD6H~Zh6UNx^jhMojWtue-6kD6?F^gnIi zruQET!KGpu`6;vSR_dt}!GxKGRH&GBy|x4}(gCi7G%~8~YkO3`7rB91m?;kaNJWEk zcSkBPmyp>gw3~CX=AUoHLuQBk|v4D=2+?!}cZt?OJP- zAC4ED60dE%Zb7VRr-UTNdsJPMrjd?!Mu`kG(P(1^c{Q|di81K>+Xh#?FH8E{gg&OV z*k%obdaxi&_Pk_+8GbMmpwxW0+|l!hr`=h-8id;0%8_h1LrpJiNfLT{Nn|eqMO1-* zuv@gx0K%-}c`yz0iZ|?^ackGt%)?Syi&xXP5 z;o5UeC1W(#0?7KrRU{U%@p8VvlvcFXr%ge!e(g|kG1oxJMqNw95!{yufp``!`eM%z zk=NG6{<3GCEBT~0fBS2kAP6NSRFSZXM}@Oi#JT&l=Eh%racagW$bdnrTdK%iN54#k z`{?8`{IL;d)eDeekgPD(S43#Gv*2>v~dI6WU26+%KSc)j0E~@!~`#Jp0BI^Gy^6RWE3Dn#nbPjt{bV7~+v7c#LKD z9t93DJNWu13WwGh*GRdVyUu(I4(5^?IxWJB@$LEcY;Nq8VC_jqx_<~DnqctxFjzmd zCWGrc@%^r#x7H(n$71iTNemc5OgffKqqZEjPQ~YQkd3*NEqmI0c^f*CmfM@%!Bi-n zv6r`)ei7F>B(_WKYn7#*H3}`x`j#Hg@hg%5R9~k0PIeX+0T# z1h6^SP7%_ywm4LGL(1OI(iD1e8yM!!@NdsD4jAHBLuC-*M2`LF>(*TGHCcL9zX-!-D=9|PjBHN#ZB4D@vgl%94XLyuhaCVTgngqSwaBBB2&bUj?S5iql&ZI;$}~v zXHs{STJyNVGsxRYKe~rMq*Wn(`1GmjtH`97`?sjDRH##DDKduqog_}z%rp|msXU2C%3^5CjA{|gd=M@9PUps&jx;IAkr z!~X{qo@=2{!8K>K%JbZ!FWAI{GDi+13t(=!lY<6H0sO#r+cbqz)*iEptlwaE zX0J&#vzforvImylrc~Qvr3lNY5>|Jy>T{oKrSoR6k*B%4cTPw9G7=4lP^~mQt)@|6 zA4^zYP24s2s5E{3B;LHQ*r7}65}}v=<|?<-^gFhE#yc2$=?4C4a^zB&%2JQ`^L0kH z4jLRxt;$CZGPj?j%cJ~D6hyJn7oAMfA z6>!FnWUb6TvL4QX`mSMOqxC+JDnPz`qYz7STB*YnvUzpOk+KT`6f}ZyoVvZCt1s_p zP?DifHPbh-*XQ8M`O`~++h{C|a>%OjbEr|XJsx*d1#Q*Ys3XR^yie|04e*krOLT|0?`)0D);clM6jaEKdNZ=&bnu_p6D)LG zBTtYU6>pvR2Dvsc!eH>)I@NgnrKW#0+qOp{SAQ=$#VN2ta4pzGX&ywtbaP~!3jWv` ztVSDh;Gz00qu!6P*2xEtfV3DdDWatb?(wj_rZ><7mER4t5N-?-l4zr2Q;E<5@PQOf zLrk-4w@*~{oEENJjww004-AtegYNnjoX<{mph^`I*@O{nkCVsIE#nfa>e4|pw|`xX z{_Xs$(juB}O3b~vseiw}%Fwfg zISO%D&-f0t4T#*afVIhXhmpD`;ja{|olhVr>(HkLz^tQV21y%d>P&(rt;@-7rbGI& z+7VM(`1K#sPa*y%;GTn+vZs`jV?atm>v!_RL@ttMg0e)is}{KT4&hKOjLv9BB)A3~VDEFSZ&^tT<`hu>OQiGK&)0+w=gK+KG^81!cSv7t20Q^S*! z+K8mGBrougZ@x-g zrxp>EQZ8*Anx6QwF1vX`+EppyD*K1>X-oY0IAr-aYgm%{efxA`@#ERAmKT=i68_FX ze8%NtIFAsT+6Ot+K0ZQdEQq>l;MvT`vA$dJ)vzQ>o}*R2bN_=q%RjDMe7Soo5cj)9GHz&(2xwvK zKWR?A$`I%zY9)$#d79>|#(`a33$q_JR+~<45ngfV4FKbO^s7jRoTTnl`P~`!CTS9F zjrO~K;|i79vntn=!&Af@>`pp65c1?MUSs9+8^?X&qCcyXgs{rp^nGqpaOumY69pA^ zGv&GGU-^po?}S8>9u%)2gh1K3Kdc%I#=2Y-?Fx|>6rZcBrhBdT8(ci3DmYqCY_T!# z+QlC;i!Jvh7@pbL71bTNul?q513SJu8FOEp_p~u3BNS~cuhxHhm(Pf0m}Z%Z8UU7# zjl#}%q*kP~g}#Tk{|j*^dnW(HnYbxGZB)OkwJ&v&Kc1h6t|qeLVCVRmah8ej669}rw8m?#q|I2%&>xZGv;uV8_LWB=HVEFwq6 zvF}A=n73U+!XwOyIhvv$%LF9pB8UHuOOLXjl~jOPB{0Forjl6uhK3$bul1#$kxe#4 zM#W(2=?1*eWf}T6UWk`w4~mzN1040~MJq34Y_&(=Xbo`-b=_lBQPR(-t)%{CTSM_l z`zcq;`5&dXP^S7<61d64W0I4Ft!JQ4i8BVW);V<<3S?x2o~_u*K$@=}@2nEb^hYji z_Vr1R9Hym8CB72?(fG^^Uwy~IVRnEaigMVJ=y7|}@|N1|gjx-r#WQAiJj$z&2oh{z z{l#KeC+yRt)AinQn9xpWukd9(iS2E9B%-Ae_coYHkhsM5fQ3h;$C~_Z_&yg_2=oww zet9h_b&A=f()NzZK=|5dD=vjq$GWPV71g^moE0JuF5>;0HzXK2#m#4)(kbCoZoN^qbn&USFcsJ$Paz0Y$$d%z<0lCB)*7_T$dFBg z9aY;M4VG~+9>3#=K#^kVm_A6@mbjHA_qShfPu%j%?#!nkt>bG1L5gRc9%J9z_28)py zgF{+n;4m4Z)BM$>v|{m(bH#T&^O44*bl)9oL;6?QRu$rUH5f1c{$Vl;=_an@LTP45#=id+)Ei8 zCd=fLtJ>L%N=OJ;RJQG`Myg}Nsmc31TLP(NwdHgjX0hOK{m#W6oA5<7v&}O#)OKo` zjs`_z*A~>p>1q&7(SJnuazY`IeW))~%c8m=^ZC%q0-k%1Qf=S5Gh>X8-m2pKYdN0b zHOz>gA0jfcw4$Nb-aRtP$od2eN8Z!gR*^!-uAx2nZEePaJK`arP=T=}4}7J$Vg0%} z?|8)eU0$x4^6+YN1a+awXf@@0e*d|=}3JNyrPXwIN#SavB zJpVZxzC+fT$TGTaXb;mg2SRHfn!D~1L1Sg2?j)8(+H}@T2!(3LNl>D(HCjf37`JV8 zS>m90ui`fn6K~b$Hf(NWcDGHwWi{A~K>h5Z$Es0RZW-8jA!WuGJX{0MkBH=z9ox%9EA*Yb z>ntDHkpzq>$GhC!21JdHDN>d3P7sH0SQ9}=1G`^3ooH~cr|Kh=507DSz=%oGwzZbU ze*?HyvdwkTYiql&{RvU!4ms)rZlN9)kS&LIdBc!P10{)9P4Q3WjX`s`EZXfX;u|~K zwV^fT@6Ec7q>c^y0~21D9qJf-ZW5C}PIAE-6q}~{ec5CK8+&{EIcZrkov5DglGYb% zT)3}4p*F1UI-3~p04;ZoBq%~RTlTq~+sb`SL~btwha#gpL)e}*Y*FnIh?Ie-cT+(A z6;Sd26ZV1}{a>(`qp0g&`RZvWy4zr{#wTt${i>7svq~z}6J)E2^ zf6(W~dIXG!qt9-cu<9WKG;RiqwQ&3a>ae#!=-bbN2_uPE%w`~O8R_J+bh+qj>C8?O z9W_qAaR<&xT|M1R9$dEECIv|s6cL2W@0W-WfF^&%2)`S);dQX26s@*lT)xvz=6`w7 zTZ#~w5mc{=&JM42V`PL~;W z(*?Ug_&QK0WMCSW%fPhP(~JAq<3xSeH6!rD1R7+M<2SEj5EeDRI$4Mzf|yUF#mahA zh2=l~chsAIG1=2bGrIDsx%dp7YCBowJ}7&OJ0 z9V36!M*N==)KMl2aw2&>@}azYURk6KGz~;nEa1%_89KST7OiA#J;c?SF$`Sb`z4gA z=a$Dt=Hp}3R@ANjaE1L7X-@$G31EukiddRTZ{!=DW{&PU98BcfNz5rdR{t5;rn_V5 z=I8TjGHuvFX%>ntRHB8RSO%$i9$WDdIl|8sLED9Do*Rqiu*h|ER*~4!L*_H+KG01s zz`apWg)MPS3qdA+k?(b3HA2S;b2Fbz4|IO@tu^;x79Gg7XE&HQ5-m8x&3UHfLr~uj;M&!2cwYu4p`d8J#(dV_S6n^w&!5_+lB@^# zWyCnG(XXDZVFKRyz?waB_F=V4M+*GCUh&P6xFE|}-d%gTLdqjm>cM%CrqeNU)kPY- zm#~11og&^1eTxhYn2U-<6cg>jR<9k``>FiFr|)*>__UAoz(k)KkONx+4+P81BX52d zy1U7A9_j{a&Vh?t`cf>LMA9`jgQILc^Enp1b)_cn@ffXk_fWv?+kBL&K(}2NJGySC z4A6eIc1FP?%|?y%uPlFdvs+sPY2*yeZU>q9yyxc^bFOhg9@CKWJ}$I^kXp+|hmg`S z>GM1(rkL1Pj@U+@0_ypk#Z2o9H@4d`czXIRiAuOYjgB=K8`3*45|MzbiBQ+{pHTwx z%E(5n{xN_ySUmaMgfKg~6-Wfa-jwpQW86_!|7*G?dGb?hU zfYQb`SB4xM|2p0#mjUZ&EJ?k)GhYuqO_ysi5Op2dzdpMOeuO!QjOFqrU^JQa5hqm);1>2<;v+*1=%C?*iwvLX22vc3CcszTU8Rtl&cq%A0<^{F4l zcT{)hXQP8DSiO8jUW%)?N`Ga8yP@qKenU`mR5_($IoY^PvsQL9T+p}^|Tf0;p`@piHPRN&F5_;UhYqu$BKXOQO$D+L;Z+{kFH zw&t|#9=!pk-?{rj4tyb*v_HAutqk!#rQgQH@KxxS3L_hR%7nOBgEg$m zgxHq?7+uRMNXPuP)?c?SY^O6qbU=UIF5ZqIZF$!Ibd93txX z_0fi_^R2t+3u|h2PBAK~JA-TE9x<=<#4YjYL>1Q{T2B^Z#>Sve1XiivsXB9{K8wZj zg{P0T!0aqTH9j#AR(b($M;x``$0fI0+q3?(kFUKtf)rWxD%ATEot0^>)CIn+e&Pj#*dF=0Ri*DLGPV0@DTsys=$V zZpdCm=-7!_*gqxs+n%Wh6uzJp*0EeHq%poSB?lOr_PpE$Xz_7dP-{}7PGl_c$`o{K zQfp2eQ8iuUOYd>4s?qz;ndf1;dk|BZ4GQ$efUTzT6N)f zhuTUdI+(AVs*|voh7s_FI8$7$lY^fT+^q2tJ?l?K75-x1-^tk#29ypW!!0 z1m?OilLm7aWV=k}zGb@@pkuL&nyv8AfP@1*5Ln~#ax(I#%x%PgaKv|#j@w^}L^{V8 zLgR7Ek3qP4H!L%Mo)miiD!1ME7#n0TelXHZ_Rh@+kxp=Y^ZP}UIMLP3bA{8ztvkIT zL#W?U05z)-)ltIO#;8O4x@5X&9vrYjq)zkb#GH|6WLD8Ms$kTWuq6X}BTJakg_21D zO*1Jk~~y(WFx{3L}NCdH$!jvG8Cehjdn(>p8)MSK0yz}OLY zp;9B|wHebNSoOjGtMaD|wbmO3^|l~)2zf<xDK>6CUgQL z=_5lb{WZgIWA_ye)MXD=?GMHnf@6)-^*J~!vcVGAn~hgV6Mq)HFS9&;gJiwO>3}qg zaFs$dSJ)2$9E0alIZiiey`~{QyCC1J-amosvri=$rNB#Jtc@04r)jYw z@g9$8khs~O6ab{RQb>#wun7T-ULwIND|d=C^UiVUohiF6NkJD9h22AkU^z9rO{ zW{9;b@C;3S(e07tR{3X4JbbG0!+aJ%sV*O1G|sIXyjojqHJv<_6MM=Rbxi;YpKt=6kS7)&kip4Tg&$50QxcR1_ngf!J0I5}fcHnSS?;nwt@0aZ(EltG`io>yZ3ywk(EwicT^iEz2B@O(MgJrmQ!_D}b` zMed0I=5Rwj93=jkHRV7wM(^yC)ry>@ZQbcu1l&)lU_3XWAVn35TCouOi!pU|<4dE$ zJyAxGl;H*C|6`(o#)WJ`I>-0OE`av_*zvoV#5A$vVk;;Q12C%t=G+taq`>M;8vi+o znnx~(hk94?2T)xcmGPO=SEOooXP!PY0{atckg*rZ3!U>~)cSD94R6)Z)rgjSxBj47 zHu5H+=VndnP6pCXjZ@k`OzZg#q7p1{S=|9w{2C=KOf#xHnnMFYdjh%1Ds>SMzMD~;|&~+>i-{7 z9C=*aobJE0I4Jt!|5@e|CjDQzJ7(6BKJ0EH*gE`s&hcto&X~AvWdux2(E+oH>YDB5 zu%tf&$QRe5*%X6k!dlWH(HWn@Ykr|MSH>3DL>zGJtvplY#wc5tt~(#$@g+u$k6Rh> zXs>Ly(!it(UBUf8W^mQv;Y=kZXsjmcs$Wp_jW$ld%>}{uUF^?m)K%Dsu<4{nGxE7; z5OI$?bPKnmt`EE}n>p{8h{pgi2?|_6#oEK;cvMudHFcj|kf453-N4XWqc@X8xXjU) z3NA{5rqqEo8T5Yh5?1LS4TB*1_o5QCd+dQ@Bo^I=RwXy$Npi@vYGf*t4SXQEeGb_K46NHde6w*0Uw2}} zbs)r*)A|}`iZ4rYcIAq)B!$n@#ID4ems*| zKz;suuB?B|=KNI}0*jfl($K?|gl=fRH_{hWSS1Y5%Z6$~%Cczw_wBDiJ zil5smS(XOgR_1F@+P88yJUcP86g$K@*{F`6t7_dOP!7Fs$@tSq{k7}9*a9T9z8s1R* zus&+OTjmM4+Qe(eRBrOmvFG})@IPns8r=nR50h7} zsz&G22QzPR;%{Zy;S=y9#8d{LoY*AQ&;7`(ax2PvSNIWJ&WbB0=>=j2&hMpFR)iStPF z?^q2Hm_FV*6-SZ!N5wqMuTgjms@v0RY*+?VDzhm(qsv7LN~@+{x&O+np0J7k>5lB? zqGfBMKa=LOmD=~{f5&c-e`PBF^*$eir5wXAweV*vx7n=vY1H1{hZf|e17OVgglErs z7U|E^rciq7QO+ro%xtmexUdS4x%{bMdizJP`Z?~XN$ zq>cq=Z})9}$Vjxz#Wl5W8^=~%k=cwpdm1$+;c9bO8RFag?5q{~FNe51p!>h1Jli4A zOM)~kKVq3f7VxtxP_PF7rq#aisK$7RM)y%{S`mu724$VFSCABCUmXpCb@U*-9VsKb z*L!hlH(c;oBH_FLnYg&+>t|&^&ohvFM%W#j-uh}Y$iSfYqW@X!s8Kr6p!XqocbLYi ze_H}I{tg{5pC)EXv#)BL*|1nq^p9-&%b2P)Vc4oBaYk5nqAInzv2pNfTrxwsm4DD! zLZp2ecwv#-X~^m<*1SmiTsXt{P+gwP@@GTI5W0Z77)94xA{Z4o4{@9WODnsFJyBlx z-@dkIt(+0XycE5alHC$Gp2n`tp-lLG%eOyfI_gT|*aCNX1U(m^S@#%{-Q zEYLUH66UmI;mwFGBczG6`HqNgQwE*z3|@fDA}Bpp@1rkJe=8#-CZ_uoQr1Wv@ZTi3 zjj?IN1v_bpH6&TTwE)X&IjmYY*Nu#AVNcH2nf)(nO-=HCAEW=HZdTymMyK~-Id~A^ zjq*;)f*;nKrCr4QEMY%^BY7?iTTiD((5Z4~F;41sfx?xk9;M7}#S%7fJ;=@uwdUUA z`kHO6aB?NUxo(9NU&OX?_O`pfjXX)~zvk~a%=ih+7B#zYl>GB<+(e(Q+07T3lgoV) zqT@BVxEGTN1-rCA@%zFdyHnu^%=h)LSW1bZV@TN$RfXcZGBqKb3Pb;FXG$qhYhRCJ z2CI6XNtAiD6^hCnYaRyeSbN$ngC|IPS;SF$$+niPum*dt9f~31!jMy6g2B)P&C9T z3atOQ*)S=_dvF>-fj_{wM%u7JXZ+X3&Hx7W-|Q98=kFMiVX9WaS9B%>4iuoywP1~c zxL({|?-BA+s@Tq3h;+w{ip>p#;+3s@|16>{2}vjaa)D10l>Ku zNJgz?jh&lCuPU^=v#Oa|khk6*efG>Bx`ixJKttd#XN9gqYUy6?iwI2IJoD_)sigo4 z59kSNqgtj>DRdI)Z(^qtTk)8ILdDgSJ^H>D6*Fjd>(lFPU?6~rewxs^^e|;Pd+LfP z7VQ7_%jX4RDDD`qk*+Q?>tJw-8Vy!S5M9W2JRNXo!hluW8RH0kDngrY?2cZ|2&}&u z6dESjA7ce?O{S#<1xo@nQiNfwja`|HO&lf>Ji9wW^MdGwnEzRtRa1rtj6zS)sHp>A zYzV3Mo_u=p^kNK1wl^`ufJu*)hdw0GooECzSy`m=EzDHfPs*XmkCOUlQb+@Wb%@auE$t(SsYXC!VJ4 zX1KaS&1Y=c6O64QlagV)Hhkx4JhK?Jy7j5+y9x5k7q$Y$&+$uTFb+{W*Jl$2?20*7 zv>>oXy^WxiUPtKddpw3kwiqXDr$7jOn`E<*{7`^+5bl6 zSNwmb@-spd{4oA6CSSYf|Hb4hl%ZI3C8sVzrphN9T@8(exFb2+e8GBW-{;bPO+U!;~{z-Z_Mu*FL@K-B$Qv{_L7JRsSW{` z{26KArR}8{GUBq=rAu(bT)z@ACy_(mvL=X%ic+v!&XYAg zGN?F91LSUaz!?W!&42bV+E}065Z4h!XT)Q2?sD>4$*lz`pizbZgFQZOm85dsbK`(( zS~C(oEav;`&jIFvmgqUCX&oi3k3Vq4b*kqLn8J#RyE>I^2aePUPJWGxM;$N?fbQVX z9B4d@OOwBukON;D4+$vkQ|A&;Ev>%|zWN?M){WmJl#3t?FYGztV)5;(0zY?Ud$yt^9NF#a&Cj@_x~$KIfttLO-xMo-zP1rhv1SqA zf`cCja!Axlc(+eB&7Kfr^EmQ0Z~tbi_uM|+-xL# zgl8#nUGvFBiakZHI;;wW7$WuI1!gJnx$4^Q;ueD&WjUki z2F@3%6Vb4k#4&Ia&PYKVH(&0woX?s(^@0Do21pDn#DK$qjeZA?qQYq@bHd>osb5)q zz4LH^GqIH1Gb=~AQ_~tlH?Uk`k!h|609|atOhFp`{k2HL=?^r(nhFD}va;RD3ql*U4n)LGIOF;OVd9gK#<(dfR@MDYp6`8F%VXmw>Q4Ui4t5m9A~ zAg!H?R`}8$<)_j&y&23&v|63PlaQq!wX61z-vK(Ch<1ryowux;*XY8Xj_PBgVut6| z((_$LR!4$zPR(3nTGLLJ8fx60^z+%5z1?J(Z{0xc+@hS;I{K!!kZYLfVs126$Hgkd z4R{a5rC%LGZ*luybAPuMvz(UBC_CtS=d$0zCg^C&@34Hx_E&dB;mg$)7V zb;LUJHUD&U#O(D;#9hQ1-BND`o4?6YL%v@Hq{p$Ke>2Qf0#bJZ)0XPya7{c10+Lq9Nj^g*K zb7+bgR2v=9K&hsziF}Ujc+lVe7%xzlYj@JE@wlz_j1J(W3ED}*b|vYcGh>#mKx+VO zYz`Y3D3cZyu`bBDZc9vMaD;(#n&%Lk5GnPFB(c3x*(06e({;ed=|}`YmjkEpuWK8} ze4HL$5I9pn7q?9x(hgKkcT3-67}XdNQstg)E_Z#;#uJG_D>tqMp1T21Ux<2SD279b zYQnjBBoxvMs z>cfHzP2O6cA8y?UIL$cq$p&8a3CW53k)16G972Nkkm!QbzzCxCKa1f;5b5AZS$3u1zu3n;FGFaL~l2??m~n| z6)r){aVp5|_$2BFCQQ9KX(h}Ga9W~jD&P|I>x$ayt$N~&nv9B!;Vs9pZf}ZV3|q3k zyK`C}$8|v=5W;+8tT**h9!-YZ`j|Sq@3b^q3j=|-+*Ytm#ads$BaQJ>*4~ROeh%;& z@HFsp{?dAYflG=7W8M@$=QB04?jDz{D1Q8%g+KfG zC!+*0{o(k!Qjzr0?)Hsmm+TOevzAz4WKY~1k2fvHpS`Y0sXRmVd7*|^>=v(7Ta^iP zO+PutX(KxC8!k^fx(xphZEqbESJ(9mV!ViobL30ru8!L9HF%RuuZ(QrRk2MCMX!7j9tzO+jRN%2KO4* zmhiptwRA*E*+^Zq{5We(G)A7&*$ax2#ev(1qe`~qYY>nukq`3Z`MZs0n7?54ld?S! ziHZC{XKtZ^>yG8z4oGf#E#gC(^(chBE#_G2*lItv(>%8@0H21Ylsr!A@yg1+6X<7# zol#@$n7ioq?veZC&M2dO?|m-us;OZ7|2<)HG{j|N{J$?1{#KRc{{fkQT0G|a|8cz@ zW-|qU_7JpwrnQHkfqK3JR0*9wb+^*Ot%-)N>J9P_?9`sxj>aG@fif2Q8@RY$T?0>M z=?3^i5Dsb!J31Hm8N63jmOxc~PD>ztyd@%B+`j9Z1bd*%^RhV^zNXMT|A5D_u{-`y zM;hXH){BK_LwJAbBfN<+|58OBl3K$5>sm!jn-xdcEl07nMd~=Sz(HEQ(U7R+i_LpG z1dTcAzjrZ{(pzuO;4goVJZTH{J|TM#n_`V7ScqXcZHIqt31he(-BV>|`AIFtu`l0? zZl3t}w$B{!+kOC>d(p27s_ZQdN#|IY$oET@;`%P_^X^x^H{YoG=?WT7z$U^DVIsIK0Q;W+B?5wYTZ@iUR6GCtzN}4SUD!;ZLb?qPyy-#ynx6tH#PJ zhgs_dD)ITXHrRSRA<$oBJnxk3B%ha16Y4f;pTv&bYa$}vF)McoZbJ5+LLuEZk66oo z$L~WPED6<9Rv}%prihSNlk>qw&`B$rb{0{{JXWK^q(q=ZdNtK8 zKRoMESDlV8%4D>}{tXpW?^V2iYL?iv9nh;8(g0BV8gCzV+OP;0{B8ak2ztEgPBybl)#Q0CC?a$K4xfbqX=34N2eX0Bn zL7efQ9*SKgR&v}k*aOUfANCmH!a?x3aI~zYg4*x$gM3g|Fk?b&yw#Zj-`_w*+fzQ} zv6*Zdc3tSP+fdPW>@RjyCX@0hbEA04d-^sYaPZ%?7M_H6+OONxyQ~}h;teSWlcEm( zY$;!)wnLAs+d0;|yJkSfv2_R~)ET9=6<);G86yc=Xh+Kyw~ZM9NGxB%at>kLYQ^x6 z^QIGcgOz+XS?kDkmLuY^+u>w%_w&or*}8tZ>uIlnmK&lY0LG<=sTr*MA2Q$mQUK<4 ziKs@$!3qvU!o+#QdLI{h;u#kE#ERrEA)hdCJix8TnqG}k>I?9ia|N1iRCtR&F7|G& z5!JA)`^dfWf4&!KUA)|D;dzaY=p^&;Cdz>~64z%pk^@LhB?fy9_--)rC{Rbc_)58z zjB>l?SC+@!LaZ*xwADi4FQYwe!1pT^F2WoKfj3EX)@R#QKyQ`(ScOF1jT~L#uTSEE zbIfZKah5WZl~thZIMHMup&#+JHHs1rwc*``}fHr*o2*Y~U=71td4 zPfQ%0TkF>nl6NLf)R}JuR=2@E4Q`a9NJUpwX~Un!^HQ?R_sa2HT@!U5z_gwx?fZ5Y zJf$P@V)HK7O;2k#@XHzz`gKr-c$eZarbZs-`H$u&Q!C4T)|I<;RQ@dm+SM> zCDPpE=efer<67B^?eyyJIu%i^Zxf?LRT1cEMnz`$3py`&tzBQwcQ{F#@Y(;J^>$r0 zUG&4n-TAQGDBymJn8S0wc>Yw;tT(Ga;!aN%K>2qMxRd>>!u+r3@;{O0J)brFsm$36 zt9K1thc0kTo3USj_j@}bwFENh^liS3UU<>KCDJaw1X`@`wFe3dRDLGI4`bt)&+2{n z472%MEe6NYnWjROw~{sWcg^(;T>by>mPAsJJuqtS$?b*=#(YbdY(FNvhPnj$|I&H> zr{49y>$*wh8Ua4B?f2q74P2+5;&suU^;SjpQ5VVd@ExG~(KqA3lX3}UBMv0PCxf$n zEw@>keN^YXBUvI3<0wDFI~q7bChFOd)js@$&*teT!hqEY>!#vfX z`NH#4n~v2pjC|;?_^8D5&uadsF(c6bAHJR&rVN0CHu=!2F5e%3G0`QE?L)m#Dr+FaoR&|T}R==@TzmSmjlcY(eYB2y`0waKx{$nkFy*^94}c7}DS8L)BGhGjmh$rk=s z=9X+^R3h5rKxGRAjk`M=Gpm0uio)*Kl0*#L3lMUnA4|*b3Y%W);o^9eS-F(_OdV7+d z)(k5nXlwG|6S)!jQEF;@(w5If_PpG5({|U$18nwvMEDnQg&u#@E)UC|b5NaM*${@86fn^2Zm{K|dawljdB zOUqz1sbEEg)=A?N zi*MQ{nO1Yoq!SO0R@(N}kFZ;L!)WZn2L#1ANv z6UsgHZm3x?q~kyGn>DSy>G4)Q*_{bZw04eKDYshKF`B+sd|sSc(*^I-RBZhgb%A*H zi6FoMnQ6>vHVRC!9rBB`~-5S7A{Kwl)U4m{^~6QmG2ot?4>ib_mK9M z*7uZ`l%SONcZ_%eI-^((u zH+r;pvQ>elQy+%^)nS-qg+?>dXAW-=RIga>Ms6<_E-%o24g-X4p*s!gKoL z{6=U=1-co_3uKsmiYBh5G7MU^CZfG)LOwF#jeM8mX&)= z!QBE;?PZF*66lhQ<$8@qNlL}m?`W-){42iMHyvNcg@HbqzQutrZf*_HOx)McF)z3+ zLe2ea2i&4gyorqw4ik{kt3MoN!7CQeE58%@7PUYO1HJbfG|(1GiGbbp^33t?{tguQ zXKP(S|2zig!@?RD+o>bna~{`O%UTxN=y4Vz=w1Q&L%?XwysPr(hg%=iA(NF&?Ch}U z1;3&Of!(T+YTYBxbU*-BWWpevm3l4C2zRP&2fZx(0clv?UvjM55<3O%o#?JNT8xs0 zK{`p9DaME~#Hsx5_-~ezuoel<#(QR5I4Gj#V%N^M*u*D4fff5L&WLsrGBWxrK0G@7 zCC&7ck(Cu|*fK^GjOj3dF4FOkp}`ABoqb;XSS&<^Bf2_Wkc~GFJ#wr`Bw+OcGi_ARpNJ*5r>F)Haf2?T>Y6Z zB&6U11(uHuyS_4$_~uWx)(NFrZd@JdFV~+cHCE)LoO*sp*y!!FEY0GUCzagAMH`2l zN|)jLfc?2o%EQapTJzeiFcJqQvY~7%Me)DVRiGh(RnnSwraac)q8Q^K-3*~WqV+UM zZUt#8&Z*o-NU;&)E7|kU{HNe*;Mw-)%}M|$MVwelq9W@h zORx~WvH71F94pH4{n18SF&=S>aUB4wSy}?mUjX`u%Tg@CQ|mWg)9aT_OPAVo@Y{(f zzn0C75iNPf>E&w**(;lzHwE^ze63cF?SwjH4rEYtF{YVc$*@R zH2$E|W(C z?svBXTXVe|hG+Lo%V32Ku0L}rDH0|8F0cPmZ#=@W{1o|cMi=dA{gG0ti05@^RPPeT zzX}fAs=9k-GeyqQfurPmA>=ncOlR+1@S68uS<7WnL`a@Au|I@fk1^l&Ur8iB;?FkS zKceS$(F;u}zJUFb*lkgJvWsjO`w|E%2sZCdh_Kb@Z}fza2_ksUw+7VC77MsIx>*EN zzK__WEZy7b>v(szy?JT}3?XbYC)?8KHip}$0_GjF-u~M!&0t2tf5$*W(3QEPqU~WQ z>~1foxqqbVugVHw%Z@X)WMM0xP`%Aveg$m`dml#BQdiY=rE(SO-mp!)Vh1)$D7x%r-4@=-`pH&QBg@0#z4skZw zFxfKQ**1NM(+(nBf#h1|q6p)*B}VE1`iKs13YgPPwTdYB3f|g_ka(`Tj4m$>>VDU( zP%(ETU8?bIDxJwLN$O6fxP#Fte*l)e)Da?ouAs=v+gle(7-oLLo_#zQS8eZ%$q)~C zOGwZ$v1RGTuu)h!e}GnIwP35HkEbdKa&EmhOxIur1Nq;PS$lk-hrq&S>#*M4?2x zT-lZ0%Y}f|pis_iS0w7>U@qyriRXlk+=42aRyv?51^0k_Y1-Px2Br8@W^Htf;is2| zRSDJ?l7D7sF&Y8USw?|LVEn!)kMY@IUM}V}@9XUB z`;?1^t@a{)BZ7C&xPCqxeyh)){$rG(*TnzIk1~0lzy!bl73RBlS?;+@`~Na+|R+%xQtl~OdzTcN4kv> z6#eTJiBdijGW;jTz=8i&@(YUb;dIe|XCMq_Vu=_5D_gwN=L#NH#8T}k`b*V$G)$HZ z$}gkuSH^pNZKjG$kzOU|n2f*afRz%3Cht9;kmK3{@6(q8<#d@;KexY=Qt?J(ux3R|BKw-h zjo>%6*9KuZee7Yt`#w{q9Eg0DUuCT*PqMMeMz(MgL~JP<&KHPrzA*TM&Klcq3$2q1 z`8JxWvvZ75D{=V2c-K{ZYeG}yfv8bzI#B{_t9J?0WbhM9RF>sUDgfk4p<0$Zay4GN-W>scI8jK-z1Y}I}m1KzmDeAFfD`ceA@n`OiIlyU7zqCwu6kx)% z^u`pElF@3A#JHrQ?KClFimF9~#bnr!{BExw(O?SJYE?9Nrfh*b&#~xS~ zqrLwjcX)N;POE2_9(sk*;xCY+HqKgDfa45(XYq4 zkfT$}+VYS1sRJT-*sk;+E0aT&$v)!Nh`EDly&iAb0R8CSZ8c&KliWN4r&U0EZ$ z<^72LC?hVi8}rs zj-oA_VIeF{N1J|^cH}|9-L3ZcOV?{(_D#x&^>LbFMPU3iBN*#+1?t8QWO02Zkmtdk zK<=Dm#*8iFEjH0h%1!lOjLW=jn7N zn~3H;-_iGm`a`qJ=-hV3C^Ugbi$?zsu{ngyUJEdcjPM&g4;o@7JUx&18f1 zIxQAV#m6M%zL5Ve4gLsfh@Lt6>7sy(`WjzOhgwRfT*aVH+DHbIiR7-q%?y`+_m_fJ z__<}gUmlQY2fI>VJc#75jvaFtrV)97eFFu|mc{a3+|FxKeT+>;r zT(!FY3$t)#D2Nc3@V`hrMhSf%^=Hpl7OvW)H3Ivs~f-{7=Zxnn5{ukB&x| zd~cXC^a>@w+q%P)5M3irqvLrK6Ba1jVhV#VgqS8bd&(Q3@R_NK?vMI~sNT$_D#opf{*T z>h9I}%XN+ClW%_EK#GO<0g+#r$7tW$c`g(0z^%KV-F-0pu-%5+vw1};JWHASlJ8Vj z>teY504Px$m5;ApvJvi5&AQ}5Sq)T5feoa-5~$~_*jj;WUd>0tgX8AWDl_##z28HE zNeATtgEs#*+0_J6ULNn=7b0Y9-BpFH>ci*NyVD|nR;uakAuUnfvJ;;d(oaFncj$#q zO*F;7+i;XiL#e77MqX(rwJ0x56-#0r^h=Z1L{!tVW)85C;qS3KGS^0i-Fns8t;JZ> zv*3HhElf4GdhozNI7o7qZbyRa2uL#)El;mOL~D-Dow*t43X%gOPe%Zm-xN)1^~Y6*VE zUoNk-i<$Xs=5pniC=I-N`|B_PXC{d4M*q+?Y)LGUp=nMo@qlOG_F_!6kuljdW!&^& z`7Ujk?HaUsB)_{jqCI@mdk>yuk{-}VxAkkzZE!}(a+Jw`L!!FDe6i6BNu_EeBL@ndcg`KZdp5M2!WUTpiwDyy`9Tc*^xJ^oavqCL?&2=;oyjFq;tmgdysK?XEK~2p_5~Pv-*}4-Xe>tXx7&Lq?}r9VxCZRW zmVe+M&r{bZy*(bXoYF1$2!V~a(#oc8sNV2|SC@|wsI~zI{Gh>icDK<5&9;sPpSQ5a z+bg3uf#Q!dKBAlSu7jTP40e#tHC&C{wZz?ei4~4XFQAxI3{v)#barF;dn9e$9cDRkAv$Enxh55T{JWwq`ntS z+J)lg-D`J7y2X+1a8k0xiE_hFRggki=ury3+LyIBSoGNv81T^UuoG_LT;~iIh!%*n z^tBZCK4s(C9MxkGT62tAvz>jqQNqWDQ5Kp6-ML+&^H_UUK*@kU)>$A$AS<%^?q;?1 zfaSwLEmW50XrXXC($U}HY5s>@M@P=r;owxQ1%t~;i!DCC#M*7(#Soi&Y7W4j&!}_l z^W&FRc^4GF9s~O<9!0T%+mEXP?OXS0&;>tWo9);yb_sf}iGtlp&)@;ahXSiDh{g6N z;CT8QNBQ$SUU}EmBF!xxzb)lYR(|POW^DtG5ed(noevr2J_EM=9h2iI8{GDe6xQOD zA`?pbaTY!U#B0g!4ITi(9fe5NbXKj;xR4#n_+5F+)rGRHwpGDgHl}lw{DD=6YpaYou;d8)NYq zpX*_#i$CV&Nz&6K-|_Qq2vh9qp6LYo)^D6R3;j$(eYm zQ9oY4yTe60mXSeoqZ~EQCmV(l8^b@zRX;8biQ%g>RG$+ZeOO*NlHu`LKD2OZRbUW= za$g)2Qh^xnfAcxGqq*+ED#3{I^a8eJzfh)|JQKnr(YdL)!V4aMbAY&LLqXRkD}ydz zFCAet@YbUg->zb;GkR+JFQ!k1A~vq}v!&d8UPnuV>Z#b&+sOa+-9_`hJxq!e zIt}^s$A2l54%VgK%(&Np#|&06`L;ZTB+Z_rmwo+eU#$RN>hR-!akXWPh8etJsnfb>c0%`C$^-xH~57_ z^m4}fr9cBt_qpTV)+dhb-PVe&8q2~hp>2oqyvl+UJS8{s2evVbeD0H*Y*RO_hriueltR(;*>jWP0spJPUpS zZ&M9^#peAs=CbhO_7vw3vh4sqT$;6{FJ*>tv)HvAcJB0s`^P7oQai3_c7U@~k7vh= z-<%24;FVg8Tk7P-)Wxv-#1@(uBPe3omdIVSy)j;hU3G*XG9Z&y=ib<#+pP94A;Z)n<+)LijK- zLXLbpbnnV;o7*zgzHlN9`PTU#h(yz1pxCs8#i}}~^OX$TECTkj9Z%>M%z*ti=Q>)B|Au$wpxY}$wO%9@jUsT{$Zb7C*Wf{&*B|Y} zY0t()W_i)y37f9Fq?!I*hr{-PwTk9$PQw`bc(ZnK>^-NxhS78|S+ttNyzyg0>8JbJ zF3N`a!cp*>rmx)o6Z$?)R!-e7c)#jP@mfhKGp-xWuJt^VX&BW-p?=LOBK$(R0=Wi42a^Fl^(DPWOUgfKbG2#xM zOk1D9@RY{WJrL8W;QB)I!$0vh6W;MVT^k6U)lR}0-l^_eN(@g;>8!1d94Uu!FYfTx z4M)qGMXYD4an!ZnDT_2@n>?}3l5dgY=+Z+*_ty3pWQJh{ZrBm%R3}b7*3QS%qHdmS z4G-5sv`?_tkWQf_W%Cq|YU zSHTFn>RQh1&)+T4Vz(Bk`I_LBni;SwWR+qs{*dzra%i)f1`n$75o^a%j`dR&{xEo> z&u)Oru^PZ%?h@zZ2M|X21ll}iP3w$dMb=HS`c=&2TrqojY?#=XY|k)_hcVH@TSA1g z^w8HgkUq0QaWnx5SxyubgfF zyKzuWMtz;}9P3fh-6zZP@r0!yE@H_$3?hHwrD<%j%`J2bp5T07jABs~z?{sQQ5d{o z_kPa7*$#Aur13n`g^*keLT1PNP@3?vCKC+cF#8nO-?MLA^YyTzeQz(!_@5Bk_;!U0 zGzVcZv2bYbAeZB~i-pex6Hxp)yni~g!1v|tw7=uSDIW({g(Euqe;AI9&p z^JHO)2x=Dk=nu%+a(I-)PneL-3ml-MY7a(J>h)%NUyXJ2+ZNGlA*x4rV*FJSxxn6a zL!NjHEU}lfA!rP2=8q#JKG(CZqR=7apvCP_rjarQdQZclD^YjqEuKW= zGa75{=QL~jOJvJqD?{Gr(Zf3s$zM!C8lHXBorKSZj>EtRkI1Ayz5lUSmh1fL(E8P% z@=1gVxWc2UDgE$xczR?ropm@N_ZFp#M1F;*Wzw#bzI38*;?j;Z8Vk#P<>JpAHR!i1 zQLSs1)VHjoGV*08Uzpps9ha@uQ=E|$(KMm`=zZ~ZQ!_ zUev;HmsRJhro7th8KL`$S##`bnacPJa85k@K^PM>-n2eScaC!{lyBs8vQ|1cvI$ zMByG_H0apZl#&_87ztW>RTT2sm8eSQ1LJ9Jp!+c61kZMA9u|sv#5)SoS>8U!a z@!M5uIEuL`--`t_tRL#8{9=FnL*PRli|ZGvowX)$NSNZP^Qh&+f57q~#6ZEUYY(mJ zgO$hAThtre1z7tT!zIZKihoFACFN6CC$dl!ZQKRiT*a6vebgKay}ugAaMQyk<&P;P zZ0ohJg#5I7YO}g4Ya9Ue z2wP{zsK4K8wXrfURcXg_LVqmI=J=$J+O|{GL6En{-CTzEmQNa(hcQ~Q(rR=T!}z3i zd(Y`M;qGbc)Y^HkHmWTf-7&7eL7wA$o2c~NK-I;~t2XVsuOXs55q`n_D8bv++oL&A z=}Cqv6pcZyv+W5v97g&d@o=;6SbV;cc%*bd;`aPL>jVHZgs(;P*B&`f-Q+9_zs(rK zYY{v6v!5)5hdYkZK;A<)A)XhdibAdOvJ==Y8!_ebX6ZpV_{KZ=&*eY6P@et*)yxT7 zle>VW6wY*>J&~(L3V(s=)jNE<<5qf2`AE(T3_F z_;5V;WIvAiKJ7}2p6oHgY3bcL>Tm{{oMP)WhT%SxBv)2TP_cGp^!`+ShV@6Dmh^II z#jH=d>DIp*YOUwqbxSqHtTpJxoE;SCweA+kG;QTMZnz1;Rb4Ya`|k4ps26tKzPjTs z;4^0D0tJs>exHO=?7GFS!K(!kJG>|JHU$YwyX}Mf{7IYmO23QZ9vl9!TWcFoU7Sur z()=)FpD8Vxdz9>a0*hZ)KR3C@(vN9Y5ZW$JBtGlwOKcRl7--?Ys`FN~FT`*|OUiua z)Tq}E3}4(M?c==9{%wohpWsf*-GE@8j^*+)-8#2t$CN4> zE53Epla3P?cA-LQiDoR1$7YUshY>tL9S!#^I|DH5lpwL0tvp>9a0f4|W&)GI{U_di z#cXPpqD9u&kJ*+mpK{m)FB0HLHcIFqa`K46+Vu~$68wNBq+}|rQV03G`COoG;?8P} zD55bEi&?T9sOZrC9Rvu0+Kx;Tg>tR>aBH07aV7h3PvJr@@P|CkhveA2Lww&5{!pQJ zncdnPJf%C2EF=~e3q}IyQcr5a6ZtwxU@l0*4AEa){-uIHRs4Qkf>vG&?FRl8Y3W+V z7c&%%&`l%){(r28zk};qqZ>~hln?4+J$4d1heUpoaXzc9nk(IVAmU+rWlRR056!<& zPwHh_70n8-j5V*;=KjYF$z?{*XJs*4sYhrfsP*YfA@!kdjSrTPY>T$o%NUQv^p!X6 z_uBkrdcD^M$A(OWL_qB1e6vIvf`wb?*8W=Ovpj{QnkK=|hlS6(UV3|XSF zH9TlLU-0oig%7Doc2{H^jj@Y55xx5dzi5BVWG|RbSQzSbiX z{t_6IkqwkBBJoQXuE~~(obnTv2%UYIZ{7~?z+CGA%&RT>2uSUhozn!4!gO2*%8e5% zcFtX*gboe*L0Ph{<`j{cOa3{>zzZhv-FB4er*G<_{)QTYrI_iyI9s#cv7Q_>&zgi8 z`||h=JRt-{Dho`bNHhx1SZdf-c!BNY4n^d8UVhkbK1)teuf~_Sg*@A0;{!rySK>>b z2sq2A=nB6fu-2k=lCZM+j0P%?7pyxPb3++VlzjQS-f6`2#&(T{;?iND6~#Anu`bY_E6Lb=KI zveiIOAZ%F(;?#=;qqhpQbs8W*zUw?04|6rI*?`*vTrQ=a7Mq{@odm}@T9PUGGHx4` zERW_CZ%R+q#vX_q*PHzxdpbpK_A(|f^uC#iJ9pYc&agVbO%JsytZXH9mv8yVn`1^T zy(T^Quof>D@;);qv8Zpi&gzF;Qno=ug>K&_&I0qY=LlvF=88OB|~aEQpbj{1buAp}M)F)6tY zxp91v@`6jtWTgCTb2(^G;215ar8_k|uI6wEHhxD?;YBJc>xb^`ce`nHi-dlFv?lH< zDgr+$BSe2ZEZjtDFyZP5kV7s^(c<9PZj~jPwsIKS>-cXhKv!hj<7Zccj;javVU*ny z!HU!6oeTC2KuS7f;KH^B+RCBl_E9|g#pKsBGHh7qub_wJxW{37A70NpuA^y*al})u zC#Y-DxD;xgXTTcX#ihumH*`IXW0Klxt@_?|mV3YY$w1^t^*nYXOjTKh6xTD>(pyTh z+rX%8pME!GxJ)<6>+DG2d25nA$@ygX!*aLejf#od91BT`0Yk7!^S;L@8TKK?hsl|4 zB~aUC7n|M1?#1~!(I1{udSQ3(kXPUL( zCWga_49vx}E5ZXm;6^TudQ*+%&gZoLLu93Fl=HSSr_^iz@2xGL%Jl0Jcu8G%o()Io z0c|4}TRI<|^nJ{p3&Ve6a7<^>Bs(FV*5U^R$(@+IUAtKJlTW3|T(|xJjq~@Sv&yEE zpC(5~JwQkcpy?VcPRkocU1fcI#@7lQL^MEtK(!N}@NL%bU25#{cJDHl<&>mzqq$5& z|K3&FxiqNi{dxj3*q}W(GA!%?|0qKqpF}y&{0x>s$8Y-~uRcLW`EdOV#-&X+8Sf1U z6zVDY*s8)-66`Ify4qDRv)uc1)_D33?oDVCj|Z3PHM5-tkRW=J`I(QM}uJ`aj;q@2f^JCi*&!Ex*LJChy0dmO{#Iyx@Ihk0;Q3(LZDpzYWoP# z-NF0Sm3dHYWqStMgjE-PW%SPEg1v{!#;OkcJK=a4dG({ks?>44{vAK`npmOK3!m5D z(}yYC-RXPItDP^m+S(^B1ZS9kLOZ6*6)VxDi^Z$Ry)!v_&Bzb2A}#<8lRLV zPfQY3jXJ21K@Xzmc;`%0wRXg@u^quwp)>iThLV4-Ck&NXm;u)aet4cs7H229omGOp zXu3+mdt6W6;{;zap`<*niWZGh^<-n6p>+&fdl2w?D_C6ii;KeDEYH=709gHmmkaR8 zpRBvI^Y}4_z?Bg1v5|Ypq7v(Y4{zHO>@y1?A5~ndFxA+u6X@I{*bEr`{JFq1|CpK-X!2gU-jU?+Y(Tf6)AoE$Ca?UbHC@CgBc^4i z1@e@XHkieIZuR_@e{>gg5>Yiuiah~#^T+> z8e||bOT74$80YO}YCpRzhhT72`W+ByYakl9+{9$pW~R3QVy`ftx^<2syTRdUkoq($ z!p`S$SLoYUDCK;xZaOi@==ucN5GC=TJ+9t-=A{)#r)R$xmAyXRyWO$P8>HHtlNdky z_+I1D|7c`nqYuPjg;?K%z;8*yNzA?QOP~lFmkeJW_O#xZB{S8y#v4N-;f!9W2L!x% zf8`6hNls@F+UFj`rQCA)$ZeV8FQxcQ=9#a^4&)G7^ZgPnGTjk~sucfF?J>wU2ub}Z zz*(lH)w6_WxiPz9cjtx;IORrQ?&$7rTAm?c;O2HgkWi7az_5K#deUo)ki)n7usB^@ zT``}~_;yOD(Hxjbi~K&$WkVE0A28y%+C-)RXbjI1r|9_;+!{W?Wc{Li-C&=~t79QC zISJEIj~R=Zx_t2?%K-;}1_$}Q)(1oF1{THxyUZYl)6;pYgfaa(Qn1Il1Vd82K@Q7! zggjp58PaK0FqfSvwm+0|L)xNU#}o9G)%=b3U1mmoX+SJ`Z5oM)7rOynUnA1eL-~G))Z1wTG zI0UKud>aQ@m@q+|G__5b^o>_L;Ti6%<%~v^)Gwcaeu)d8OqIvxt3Tgcy56$d`%y)T z*-4b6Yw&laa&#}C4OE0GrLdSB5NV8$03YHZFV|EQmgn z@yJ2K{E}+o>3@H?zD8b95#;aQlWJ;mJTq zd~eXn-*vHLZ<``T6E8>i3>p4J^RCIGDOXEL%CIGnDUY!J!B(x-g;0l9<7EV@0G`g9 zDIw1Ta&@%1TC4lmG2Mp9A#$RgMc-jaR>M$QApx&Gn|?WAG~D!-S7KEUUvK5rXgwp6 zL7O({?@vwkpN0mo7P7GlR@RMpXOKW)i&r9%glrb$@t&*&FkY!qjlE+DqNP#0G(7 zWLZmO3Fk#;iyOvl04KS{X^vS!FEP*XSmYOV(KM))AsaffB`!I`W5EPxA~-&rM8d+t zBK+}(w+BZk4iazoO!<~O*~F_Cgje}G{sEl^#}Ji}cj%?1Ks|2NbXujzb4qWdql zYL}80{jZ*d*{tXCv5vFs-d8DNEDF!=f(su6Yy8NZ8%6W$e^z{4bko2nd`o$9DQFZr z2UU$yqJgdd%r<9fMbBo!iG0X>`OqzzX~}w8^+I(yqDfN6YQR-#*EE?YZMRKT$HJLV z?7r<8`2~>-XPk@_l!j_Lu$nvvY1;D@hlt?2Q)=AzK-OUoO9X&Pcw4c3ZhCGP_gjQN z*ir~ARdm&neWQ$|z;I8{SgLqS?+E_fc*R8n4nil!W(0Jp5JLED_g+_Srvg1nUCpKun0 zW@Wpq3^#HJt4tBv?yy9k2It|52Y*kzzqRbD!wT6mdTF_WrXK&C#rYA$lp=b@Z%dzYYi@8j7CZ@IVYd!u(x*YUPEN~J;1BUrgb!0x#O2T}4*@FW2w%R?a zLr^tUKYS2XN7T?qL3>UeoWC*3lh!4Mj5i08aLQJ0Ch8dP_O7umNsnQW5#K4OdUag# zPva){zm5^*z7^267a8y0aa1$nOKh-2!WXc}ExuxY)c4Fyt*owEt8EjiB7$6JsHW?6 z=b9TERTkEljIGqw!t-QaLuFn_DTvM~EV<;}{_>hZF~XQNi7^}*Vv@y*R+O-iVL+15 zmSGa&xtt4AeMLy&(;g9pS%qmYz3ArfzGvV!IjhF(fk8~U;gGQiKlCsa*qClbk8Ry2 z)B5oyT==%t>*e(&!a&r%H1ga}FOBZQjjy6+PboOCsYomTVK==M_WmSU0BHO{XzfBf zANat1W9+%Pgd821Y!_2Yf&(FNRZW*949`o{9HV}Z>CmRY^JLB!*(8O_VAedw+Tbka zS9*n(mK|!%H|%xNWGmuNoTkI>TPSqMOXu5C zPFo>QrmRASxIkwYgZ_luMrUe0uC5qSPj%e}yWnri_}$LgNQ8U%0kmrLr*5A(owhFD zNoJ}9cLtCm?gjAZF1q0gMn+HaS`1BmkFsMe^ za+0*ss=D6x2vPVUNB25>yHja(<;sl_8^l4mp}Wf>IP(+8w>C+9D9g5K|O3rF8Xs>&1dtB2tz z%FejlV)u0ijY+i}PaS3i6B;u;f8h?X#9SeG-XhB1bpadXvZ}h*b(UFzJ$ke#2PGd1 z3wUA%rbZu-m)S&?@FW~6=qqD3x1%)VB~_u>3ugqZ2Ran?*XkYq=5I$Gg^rctz?tD) zO4|D-rbP^)xNv%fy_bY&(Ug(XLzzXoi%vg(9nJ;ZFE3Z15lp|(Z5cF!OQHq}K-Od5iD=Qm@%r`fGu6x?U|DR1Z6djhmRvOF+^JO7SJv;{W379HT4wqBosRI_}u^jcwbu&5mt!Y}*~%cE`4D+fF9kzxjWd zwPxm1)m^u4Ro$cg?&s`t_V~*Zxc9jMs}i#hXELD_dZb81i|TThHFKG&&6aHj2AzY` zwj`7L9y3pNN9ZiMNd_gCRP^@kQfo7u@(8wqJTPH;0d3Ij%ZSie@%Q4r5^g1Z*IsP7Fp`JXs`#P_o&Zk(0>Mlh#c&kLO>j_!W4`5_A&O<8>r!%p^D4gS^5=!m1&Y?#SYWic@f zsn&HgdIJ5Y*QTyZ`>jmA(|Tj!H?|#4Wi8SWV*_HSQRtH!mQ%{7gMHEITR--)xcfno ze#7L+z?cGMm@klML8s8z?>IIp8{;a~y4V=oL;-0lt~5MQ?}&_tuDqkxn0Q*e)ke`Z zh*EB7c(yE^y8yN&_E(f`QAntRP&_r83QAxDyzy2am&|#=De9kpMQ4v2OdplJd7QJL zZG;i=eA{5ta7Zp~-aUp@PgQ5#oFvQQTDw4)TR&aSROl&f?`s{Iq49u+z+C3=6 zMcFK5irZtk+mRXio$%L60J0_^$o0EU0P>K>ZV28t2%2R3(uD8TYqq4o>VFml7QH1p z2_C!c?;0yu&j)LWDIS0YT9{Y*QNzksBaA(gWpft2YJ`9i_rFr!J45kNS1UYEdis!G z^$kvuK=ZsKFyse|{FS?LwYEfY1HCYybQpEZ%!S<4cvj*?b{<1hhlFyr_l`u|EE|YU zTTg6)g^`AH3G-u@cwt~u3+95SVH7F5AyJGV9JnWsM2e=B`s1e3qcKbik*NNK3=RiB zL|otm!}rZLyJt-pc3)YG%lf07mcbqR3;?vz{pGIH`ZKz3AH8BC-i+bQgM`bRt)Le` zG%y&oH*9FKms?HZy4S;lg06MK_!NF?b)xKP+mo0t+sozPb!x0r_YMuJLqNwds zdKili1M!OJ9J^d1*d>qM(e1j6-*%@#$CfjFJ_z>T{gThRr{qzf?BAeYxiOQ#D-0J4pqwlRJ>W!xyd=S zw6tvP>;|q2k)n{1g@u)sM_+h9AA9^oMaLIAjYslJJYO}&1wJ`B{IWeWS+ijWwGE#+ zx3;b8D_s+DdJ(%F6DX$!X?~U%K&I7w-G{zaguZ*1JUw za!uBG$<{|4y~5!y4as<#i7{glX^s(Ja~8PyqbBQ9Y-58PBXJnGKcOy0D z{N56h%@WxDzIw4)Ap?~Jfh>*CwFwRTO}Qm@N_G6T!&V_1=(obgB|jewl{|gm&5&x3?>Y9cIvnLwQCj&42>`U~>b5My7MVY>Lw5YkC2k zp$oPpKlAtxFp|6$vIDI|Z{!V;f~39;5RJWi!`R523fbOA-NAD7mD?P~a=tm4FXg80 zh&42JxVCBg_R4Ep+*nqnr64hvqrX58+Mg>Wcfi|GovsG4Dwy1AUjiJxs#vrUQf6hY zJ;GpBQ5fbdX)aczmEs%Q>8b;bGpTD^UM`hB9tY52QLbF}Eb*kf@Gu!KJ4^zYPQ^m# z;_WI)#y!z)L{k|GOll3sNZ&qoY}(?nNSs+b# z0nMt%bbgmm;Fk>?I?&Lf0BnnCXmP0vJI8`BgTMWW3C_Ux0yh+)0!$=T&IMd69fCbQ zm)zklCTxJ7d_1=CNUa%)%axS8AR=|1NIkznl3x`X)WlK;dL_?ow!rit}R zG%S*8XWs(Pav33jM#EN!zB+_MQa6pyleGVg8-Qfd&j+6X)ltS6vVDP1`5Zw6d=nA5 zW)HswW>TpGVs)pBlg02y07rhP^XJCK2k}i+>eNHcb4#q?5{A{7VH?NyCc6GuPn^a! zQ{1{d{lxa+*I}T0okkxG6~Uwub<6&igi7Inp$1tGvSO$%+y?@=)-FteWEM$++5auv z!IZbD7TgsBJ*?lY7L`qH%Zt$#F+AP`%XkvT5R3(Qy?RLrjmm49u}d#QEcN*QE0$^-ruMq;0Z^0DzR_vEGQRU3dZ$HK*Fl6Q zt=dXv>oA^NdQ$?U@&`bBJt@xBQdw93q)%uAS^dbPeAYEgrk1rwq=Pw~llt_2>bTUsIUMS3~VRsnGOB$gnKa z&0D%H9Qq#=PNN;7%21WfP`N)_l+Ty*Rnb3A? z!MU*;pZ2nh?286nZ4@zVR*+dKRBj25Erk$bH=sIYWsO!#T}sYl z#&j>a1=@y9!`3-S0>}t&_GNx|&##AO+>IH$D(;?yXV+!r_x<=Gj49%uM|5;w5E(La zN`ZqST4nDVCm;}}BQhlhm6J;ZFFPYfn3rV=w&3I+LiOqeled{(Z*wkwF2Z>sI;>kO0H-Nb=PN7d<0 zD9OozbC;H*HT)6CPrDe6qX5!*NWV9Ha?D!E60u#V{dasK*f<-PK2*Wp&+a~3^agY( zlje3jn*4R&Fk}cTFy%EqMVufPKZ0}pPk*df%D)0Nlp7MJ%1ctO2IWYc%cppXgFHF^ z=n2>-eK>?;V52Q=?$dg~n7go?Z2~8PvIOWF4Hm&Che_O|c4xwl>>`7Eo>GF~>s&r= zWFgTFp5FeV2<=$O8pQ{eGFtZ+s|Mnzvqszy{*II#7HPffSGE8$g(dby5PHuL0l1&hiYCU@j^eaSjmCe#Qu&SKeylmxUFox~djxq*Uygix7eq&ncAlseflHWsIvhgCHo{CMl<)C&F!F$5p@qk zBVrd&GvY-5YNXG5$-*?sapG_72oXN}48eRwq(90*hYA7LT{Hmyclp2XT9S(&HNO2t zBmZ6d@8CGIm&Xw(*X9YTbvY0^*qGalh`y8}+JmVu-kbU=-(S1Sr>Cb~+I)1;K=^ep z`_0^^xq@Ek%;HPZqH{m93@?bA$7bFK>$0e3kJqi;UH|B4R-HDRs&Vj6SHObas)yvUyS-bI-*Vmma`(6g?9^72*k9j-5fDc%2 z`}lt5e8{~Wz1&!pCdz!ch|7_+jYziR!uz zE+XHKS1_rXhr#XsV^b<^pgGxm4R3(z;&YK3MzmT1J}h9v<=NP9a0>O=APt5*jZ*9j)2W498|( zds-QOck^p1kb`EbsNT%#2nYKQ;O-VBOllgNP8F|sdZAPhwdN{OR&0*asYssCew9y) zZ)!$4_E>*Mg*&1`10v5u3S|Yq(;s=&mE>f&??Mzy&GApPJ$JLbUn_a(>*(sniV{ywg zv9`%|g}h%nWAXlLnhAWqVp`x?7d7ePk%Ev;+}@30nIJ9cdw^*DbG~9RVw;Z zzsG+-+x5;4BTXMdWk%{vJS8S~VfHM%0l}ekmHMa7+fwv zRs#MxyG;tHH6L(MBXiiBmS`)MI>Okyxo?w~=p}EC0mHmnZW=rJ z0}SAJu&QEJe0(mm5?FQ2?nJy=R|#!js4;sjb$Nq0?x{UZil=v+?e%=MxofnCwGfPI zWy-jF+^FOvcDNSmhHXpU4qq#BxY~642ut+b)InsypgG;-jsAk9%K*qhBGO>#<~Bi| z^yCbB7omJvFRd{b_EF~!`pX#5kUv>|yx+J9FarT{7bA@K@DNxJu$e=gdwKFKNJzCZ zwBRpg3qlcuUSI+5Haw)|X_Po_#gnc&BdOg}Zlq9|o)rXV&$JkOZE>H{o6$8hjP0M+ zte8qyJ^b{sC|(H-T~emYv3pnuJYi_}*{9tcaqmFiFJDqx;gt++~Tqurk8H#@4~LMJ}CaMh#RoxFfob_222p$lWi zls1yMj5ii_LqWd&&n>L^R8-_8V*na*v)7td! zK9MM7kIwBq@sfK+VsR8R{4)o6Cd1(O49qKC)~gj9HHOLP8_{y_`sqm1Q%+j_ z`W|v5inEBM?UTh_1s0#MF7?HQ_R;N=A;hgPs*ug7!wiJMW4lbRh3<`Pm$T>dz$sg% zM>eU8)9&Mg`ZF}R#Y;~b1IM8KoTn$EuI0%fR^H}b#D#E@_XiRY=A_=>1NwKUje9`d zaYL{gdmSWB^x%%B1##Eo@*mmdd$)T%U|`Ts0HfM6O-$-+54symeAs=L(RMoPr zVD0my_8LvZRx&rUbt}m1Vb5xN<3DZY=m{~{v>@^y`VapJ3K?ko^0^EYINC$k-oL8Re5e1q|Bt8u$-{hqW2J0Fsc`2>$ z(t8f*;6SPZJEzN_u8(`fd{;5_2WWR}k!0<4shGH^F#6131h9N9WamERyKp_Wf0} zC1qD6aUdGOWTHU>jI5c(JzVxRsMcnNZU9H3yF&*Ktm_l9qb`WfO_l@r)Y444O(M}S z`5MGb`>IqKeDYG1;VN1s;-wXO+y|>a=EJ-FeK(w`qB;y3P5NQ_t1TBqD^u&Q^Xz4E z45EK_lgZ?lxiTMrEp)SB<2IxgjSHN2PWo%M`?8QjR=$G%g#F@a5mX;-FIGSelxVq- zU~4FO{DeS$rD}O$mMp8I2k=$Nk}z{7GI_zW9@vZGT5zWX(?sq2YY&Ri@5kBxM9jV% zD}3F}ypjh(#?IijRZ$9{_qVJIEx+3>|9B<+m}ur%S657Sf_PW|VE6h3oyu-hfNOaF zV2tfjDGIWw*;GkJL;Ym1#h@dO)6FG}z5K$F>EjuDNpl>^$(2ya z>zu!v@TivIYWR&XA~k=14_^znh`$G3N8=V8JP)q!bj@yOY~dl0*_aBQ2ry;3FCk-s zNmpQPYQdE72wQC_s^Dq1S<87bIB?TH9d4BJ<$G8X8%D9sviGi;6K4$0wXdR+X6>cWJV! zNM8Q6u;c;ivL@zVn|1#*+H6%sf=9n!HZne@C!jm38yXoQm{(nh@6MnO zti$nVjfmgZG?FYw3!Y~q?dsfR5GZ&*$W|$FAq_9 zjxzy1Z*sjqDBtelgd_Ptqc<_3j6K>4;!;-18<~E$UGXAaa&jJ>tO!3pkIYgMXrfvG zW%KyquhUvmpJ7+$KLB7a(1+th>vN2IUS)|V{1tL}On>Rlt#SvtaF@K#tUni@US$Z& z4knk--bkrG2kE$V(m4yd5<8rl0#QG1p&;yQVeWJSTB60~n097hW!S+}U_i^cTDDoG zsO2O6WrMkU;N4Quqtl)^*>{i*MxTA)0|jnEs{ujIWh2nh!14?tkSXwr?NT z4VHQ7cmCJCxcLlw6T_TugSmA2@XP9k%7Nk8E~C-Cw(~D#OY-qWkUmlKiOA38&+`)} zGgG$uD2RSbq}Oah^zaHA6?XOLDh5q= zeVUFf$IJ9LUf$fh2)Z(C;pS^8erG4Vpu7x`{%?t?+tX6vZp)xb9Ty1pG8X&_8)}@mu=nbhZ7&7ZrD&)Q;~-K@l2ep zIX=Q}Vz9fnAx^Yc%uM$V*<5_wf9y(TI4_%TU#$nbUC1D-hM>ryv6$kLnl#bJeyO95 zmlZMqQ<7#87Ktn)j1 zqlq;-FPU2UIjAE_l9z4=e>{|L3SZ*!6k3JrHBv{{+hL7cHI8NoDM`U%4kDFnKhRi{k@yu3YtiV6|kC%Zc!Z&>Sq8t^*?!%VDf6Rgb;w(w!T@ z+D^3uFf0a371{l;X>TTV!9y3wv6mVXZsmWn9=Nte;{9_h%Sk^!mNHuXTY{rwQ(GgK zvA%M{-(pUr)70pnx=3-^KOBUPdxqYM<1#_m1XO|5e~sBk&fLI&@bOQoEsb^B=Sn*^ z8lY0V$h7tzq>IHO9~}KiUVXJZ4cC%iJ3@MaCUcG~@YyOy0Rf1owZINcs*1k_o>!7u zm3*>1-;6QMv}~zwGK@jcoI7Qb=datZ9wd0+ZtJNuERz%?I2xCp8*2(_%gGHv38Usc zSaKHd#(t0Bl94s5dL|Rxqb!_jNVfxxkv`oHfsU|xe{G4#{%c1jvQYP0XWoxq@?m<= z+PSe{)5fJ-f*4U;oqiB0eM@RTP1$zw*T7dc=nEDh$q-~hqFM;^g#ak|Zio?L{2$hQ;7W?$?g~ZHk`xv=cJ|FQVuF0Phes<^-&J9LA6=DV%T$Ml z&M9HpH!YQEAF)3?I?rG}Y~73c*?ymDR@{$GntQ14Cm|49YDn#saM1B=!T1&hRXp*1 z!6Phbdm=Cc%S1tO#t!z!fGBj>0>tNX{9WF$_@9Lkg?oC3ILTUOtVD z(6EBlsWj*1DYPalQ;X{436+Ez^K!pNPqv(_jqj=y!DV&e`xHS4s9OrZdC!@>|Dltb ztaN8DGp}9<8FrBX4BJwvVh<+D`0(aTX1x7Dl6bTC2WNw<2}0E}TW2p$jq){dmKZl@ z^mCtuE6GVB;`TWHs0shIvMvTfXHd;q#&6)*$h29W-e}ofR>bpLVc9HMK8;-yoSy^8 z8f*z#swFAW^lry+T(cf1s#rqsM94I6uOzy-Z*kGt7Xv96;o_af^C`pKFq5}_gC;5u zx_u3h+Ns#usOl&QNAre=+7VOVEip@Wf0g>YdXR)lsbY9nyyOn<%0;M8{Nv_NN z0sEzH%Bwt;zeBB^y_CspDb3wFTJmBM*h7_9YB&FADi=dml|uw^_ESd7VB6HXNM=Ph zjdP>%TLPO(#pypi6H+)5?#Wn)MX>nZ;WTi)>Y&HP6aG!ts{Q)iFidZ~-DwVoEe_-S z9^DCQnsV^!c!$wVKDkVwdwkndj#HJCw?w2zP}^=94)vRWshoWoFH} zjnBuPKv!1&c=Hs?q$|^(|1g4n2hTfvWi(W&W4<(G4@U~;bWHPoV>?)PqhD_!H$TH@ zcWw8Kxb4^NW?!n4Bs)_(8q#6=FCAre##gV-4^9kUe79Fez|({2A2i0}l=$!o`Y*=c zr9UunT7;%y+B>sqEU3HGJ+YvNmsvq(IIkACCK#U5oQowpZ#k03K|R4kp!7qorQVav z(1%yBvHE{pMU8~1tCgLd7Njd@44(4){Pt+xv@x+tlY=$Po^rOWbM0@B8D&By$Kh%j zF-FM0U|C6FFxXYHGp$tFT{0D3dqx-* zeqR&!twz@8lg)&kk%5XXLVYK*6EaDVi|)9q0cpJRNF6*WPya*~SQ8ujLQxlzNmS|r z(YV|rcP+VO;pKj08ffkBT=I;FVq%wprbc5!t^@Q`UXD+bhP*smHPqpAQrE{G0Z1-%pb3rsJ zB8ksPGdvW;{%@Fp`OAU-oyNX>_9aM zw0~bx9CB|mUPeE9HpdQMm=-~*?@YV@L9(R)N@?`b=%sP;XQ`s?0}Qdt1*6hMQ|b(_ zIx5rLiyll{R)+Mr3NNA^9?ntTZB?&fG_Qa^1x>yV`*7B{j{{Zr(0lnmd2_+QK}#`T zE*%+WZ6uVjy>~oHj7G0dp#yilGWAhUo-OGa7FHz-+~xB|?jtlTzUM8Zcn&(?Jrm(? z2u63CQnS|+#woptIX+v&qkl9*`R3xGM3Uc2Co=z$4%ewA>esyZ@~^P$ec}%De!u8R%sJyrm)$=4+TuW$vEn!4fUtQ#n0FX4cCk|4kyaiDku$#MGY%YE+DP)O?^}FUlcR0jPrMm!Nhw4nhO~(cqyjRG#(cze zBbIR>j50Vjv&fyxN=?x}TCEB;{aaotCErKsZ^^jCFzn^a zUwhmUq2fglvU6u96nl-Ge!^$5OvV-rO$RWVd3~o(Hl6Js&sIg#CIA3}iVZh!gtoai zO3~n8QU*7|?TAfHZ0|28&D`PzCX`N}i8LojW71M(X7+tjuT5~)ZZ;R?9IgOSZ&R6r z8@hispy`PVP2n5%9dK`JbM(W|aClsU!_s*U-=7z8n1I}KC$$OkwoXcrGaK%~s?Thh zQokaf80*A18q#Q2zONtAX`cP<+HtSY$uC^fW1+nk3mVu=KrfQzx(*P*1JToegufxL zQO*_U=ea9ADA`?AiS&v@n$1b0E+#p~p@hg30z(}%{Ps9hN6JaTg0=Yt3^rt_!0#UYi~6v~kIOeW7B6wipPdGV+Z ze7BI%bc4$~l_$w18?wJYsqobNYNmPE^Qzg!7n=vF5)Mvp=Sj6Vkym#m@0=Ar^;)ab zGqpK_a19WKHQ(mh=fZeMf0>*r^;8YAZR|E>5D3GZn5BQkI-A;M%X5oLr8n`2H{0R; zO;gP(TS3m>jMV^>vG6W0G@dlNgEtX_(S**cr)-Yq%~HaSp#HoqB9kkg{r2#7_Ha_M zj;|=G^lS5C^*}#|g2AI_6Il%vdzysP;h9S$_lEpw=-y}j1KP3AebRwDEAbdDi!XmY zbWa8WQ%4hsReR+{H-t!?vp=Tvg!=MlDJ-I9{A8%^W-(>EBWzFAvR)0%OKr-1d6Aqo z#VK(s?KtxEY!)JHpphD>C<<&PZqMweaSao-%Gj}jjH|ml6dWsQ&Tw)b!aWZ!mHoY) z!Dz}vZef#5v(gFqfv8akj2H0j9$8P@yQLk;dv(eZxl;47mQaCd7s~5wZl9mEx0~_J z-do^y7frj5?Pl0n)T0?olk`x1#fP$(;QGu8dYJJrapk6h!p%F?nNObD|SAq z*KHJp%9;c_wq;f1G#1+2i|ulTtvbMyfOi zyCC-#$s$}bIo5dVh>)JNGb@i_eI#5>EG{cEeQuf3SIEA%--o`h=)4p_OrrYtpXrK( zXE}8Q%Ip&QrF9T=X1%6I+Lzbm>>v}H&q1Pmv*y@7QkG}(cB@a}H2h_DW*P2PZjrn& zt={a~h>SOpN&dom4NpTu)3YqD`db!Z=O@TuJt3E`|1A!=VE#CP@}9HFAay4*)% zemSkxvISfC7VaRtUlKL^8IK7qinSxT=(cLMuln#LoDVJ27Uq~`B6y&vOcNU+49)CBrWkR`wkOZt^RO?A4stSv_Fhx5|L z=@0FUti(n<&&E!4aBQjaneOI5-aQ+UkjDQ=F=(p0Cr*HaBm<<{Bd~pk-!mkp0p!fZ zoFO`a2@R-!Jih{=#n}&qaSxnejIv$GM}s9+CRcCp{M9rqU2^N(*ZA>wSRQFW@ah;w zJ3(!2P$|u|`cS)r?|)8i!4w|fM_6|U=r7XQ&)NZDs9MBOxZ^9g0fPvr<10hUM%)8u zLKEj|-7iQO5J3HrBC^~Ow>c8ri12J{JAgXXz z#MFvbajF!YCUl7C%_b|H_6neWp{(<$Xa`24*lYa!o%t8U+}YJ`_FuTQA>^W@ZMMnM19pl zei&FKMtBU7{m?Ku{vf6lScHIZ8TI<3a_$6>h7acBQLkoZnga6@9&I#!sfFTF{s@Zi z*VDO5chGh|3vPhUPQGDMczV`TF&!Lpk%BV?~5z9hHF7 z2K}y83C0&lfey+nH(l4_G!JJmJF~stVfa@j0>>)uVYx-#`|4C->rOY%EtgxfLr!HV z`-7)Ove1Q5ImA{Mm1-(MX25{r3S@#$!-CM`b)`fg&x7$VCL*(jI!vu2z)Wv*Nv-ANABA70eC z-g7W{YI|}hdp!HDzH*l_hMehQcc=cU#gB z%qZ}G9U0KlC_#VA6|NdOPz?X?-_`=kqz4Qb(E@X0^G0dKq+O#Fxd5Jf9ma zH)Y23oP2%5OSS15hpahf$Tc~^J*;H20uR?8sv&(E*I`3?c;f}{^836$$BfD;^?a(U zU}Y+?waR zvM|qKeWWk8D^_U%+>2!x0fK)SO?-edqs{2xzBGr;A>#*71_TUTx8ui;Eh?{k|&H0-2j_J6W98IRiJ;UUxyuL`=Iwejj6Vg& zdFnpI2;a!ka%EODXYRK(P+ZwqiZm^HJanC~ELxc3eTpKA~j+r27T|TRIJZZrI;!u3HyMVC;pG z8PQk2`A0>bPWVpML&G{*`bt9BR6biF8)o&um>>A~yE~@aejMjlTYs4EoMXt#(0FuV z4G=~@s@Gl#W|f~sso3>#)|1#sKV>ypaHx?EV2{s#t(|IP(ZS@~os56iDfFEHRW;>Y z75yi{@p{QwDm%pbE4_{sW}!24d+Xf6U)Y9Ee*8}?01w_szY|u?r3?~(PvtnaO4{wt z=s1L}5}y;GdPA}P$0X;tNWQJ!pnSpr&QG+12tPlE#70NUAHj`ye`+NqVv;g|zbO~* zRDO%ZO4RI-2sUpVKr(|c`@q&t58NvKhK!?=*bC`QADfnzOa#t7_^~0Q(Tm&uAqF1> zgy~YsH50eeh({VqZh+Q?WPYVKsWLaYG+G%BYW;d?vML>m%?2Ora{EyKW6Ezw>MD=+ z>MXN5M+`s@=_yFDRSH~6>arhzj~-{d-!tPHF)Zl4l^R&q zyu$8>Ue^-#yLC?@c*K2bL}k(F|CKPERD}G za7E07+^a(k>(J_LMJ%!hi}_o(ceb5Ad9X4XfjaSMS2G{vcdy>0?9ayaW+-UpT?}c5 z;Auk)Y&$v9yLY;ZDJ{(GJ~|88T0@J9N-vKuLCZFWdQB0|)f8fLE%9V^y}gj#C!p>ExRNB8SgIwSP4Zbp!+u&g;P()Q)mIplu#w~I4d z9XW4v#J)z~?0jeqm$PskkEbH`dC!P>y`)1QY6{#C%F~;oT0uk^@;Y$yWC+~%(Rs8z z2U@YYSdKl@o%RihZ{CdWq8ErMsXK-s2xF53uOv4a+S*|&;(USoqfWDyid z)LhLjC$81Q{F{o=F{!wmjNjVxnu_67#Glx7cx4nx*VZyGGQ9SjbhU3FtlfrBKe|TT zzduSRpAff?xm4K@InQeF7g|C1 z#Jubqp2r~$uJHZ)_T$7^A!{5TL=HrVBLt2R6%#q?cW<&V2r_(Op*}x|Fu$WkFjVAz zKW!n)UtxZZ{O~0+u}Sa(_#KxMZy!r6Z9lOTLR>cwQ%Td;mp!agIrr-hGq7-QO2A(! zgfB^@-1UqSa52ni`e#-_SrO)Cv+uht-w%!qER{`G{R!`fwrU%O(|M2&__zs6TXBt? z=hWxl-^S4vc{d&!$ob%^Fe*gBw6$K_D;(UGfOAw&nFT-ZZqe>P%Zl7RO|;y#=ihrR zj^^J+H+b#i)7SO@tSu-F%w}_1T~8UN?zqFG(Q#Tc-bs!-(fP?O*xa;<%|s;y<@w;x zB^e(@5ylC?+_b6jgA!*sxn0@mViSMi~#{YSYSI3@e?MJ0pIR~qhyS|dAxoD^@W<5!)zmUc^DT2*F? zU&fb3eG=c(sOtwkGgx!MV?H(DKCx$5b7b_tz>kL+7r z^$S11cJu7@Zh<`k zk?zLjUl^F*^K+`09kAHj@`q&Z=)%YG5V{w1!4PiF9jQ^g%X zM!xdz-6Ud@)eGfHMsb&U`{S~c`kp1VA^)oQw<5Zd(i6|treo+vaL0@tzewN>Vj1~C z0%By9cF7F-&fSmPm@4!>u#Tzs2tS2sHerNUXl)mpX!*BlNT#$|qQQNbz3q1*>W_f1O+m zT_7_;Ac06qQeF-_z52ZYk~wUgXbAtT`Q7Ouvh6UENJ^q6IDkA%0Y6`I(JB2&|50Qn zz&hE>7D?lbUd>vELxg{1*=FG5dTFyF`wUlDrV72Ot8%Iq_|xK7bwdNhyv*Ns{U=Mm z4&n$YQh<0xHaYAZ4jCJmGlL+>zNxboX)eJ)rY1V#HZouN1hO#@VbMiorhb%4BV^At z`&EbxC(5U{OGI%k_MeuCv{VJbing^SP)91;xJSlhn+sr|!t(OUuZ3h}rIPgsV6UJ8 zv1hm($mALrBM2A!Z2c(uPJy)S{RpaUnNHL!KB1DX6ppNdxOhc+%3_rS$#RFqW$(>= zy#?b(Ixif5HGh7o-c5K8zZ~vh{6ws3Pp5>MEI@ZN$m>*h`w+UnB1Ez8@!fouNz3+a ztOOBhK50JbUxgqJcYQ4nKHEi>LGF*$?L(2-y{epMg3+Fxg4z=%7ijNZHxzPpjb!7N zJ2U3sX8w^IEm$rOh42&VHp=cmj6BAFfPiNykl4eExorMJ*<&cmHV~a%7+08ISy>8z z1c)CdnJcC!HAk}f4tCwcSW}f}Ll>eLmKW8tpP5JD%q`_G%58Y+;ajay1LZpv;Zi3O zJCN7K4YqQmzMGmp_|ypxti5Qtdwz^#gqC5My*wz;3z#y4N}ICu=4s`Yk}hTXswD*m z!jLk|O|jVw={H+0-&)#Vp7g~`rwx9a>FuJk$vTHdkcXGQD1#U)Y27{ubijPSi%1>g6ei462O4atSXRJCKF0Qn!LT!7R-f!UVfL zKvR$2kF}Z%vn>JUJ8-drk0il%0Oe5M&vjmB4G7Mn2A;EK3*|Fi~@ ziRBX8_x&}92MlN2yx+V*uSRVeYAtgm5K~`EyYzQi1Z7KYS;vK2&=2t#Czvu5E%uRl zpEMJvsHQ>-Ga1ds8pKvUG=+k{&DaK_*p6~GXu{C^wl&}COf+*BlEl46i^*Kogo_QS zvn|v#`XITaI3km%gm*dn>?{I`cUHBK`fp);by&&BVWS6rWfZAWgWDf=VNA_E2~*SO zaEB_av{1pKVe;+tdlmEt!FKL5WR122{k`!6N5Xk6-wANGN0xIf9;zuGV+dFSdKE>t zTHYE3xxU)gxOsQ3+ECMyS73&|Qram%j?9^VAYj;X;B0pn*<1=NM+70topZ(JD&*QS znCNVDfgV&CvyH6)LkKZlEHAfIrCbAHlmUw?kxR9z!#9=Q&(uO;z>fGH7nv~?;CX5C#enYR4=0QH`TQ>FN?Y?Q&{ z#~FpXjTGpYLBSo9(ITa<=t5x92ujKo(W9X=^>r(AGg*}LAhv4FB+@%utZ+&Y}jaB2vgJ=El*4_1TDlG9z~0ixWsxVdWU>Qjk#Pv z5TcCRCOoVZf{%atj1rmsJEZ@X{w-JyG-ViF2ovzx`Aj)xnnAaLS^T_tL$K0MXIl6+ z*07GwsA@nmonem=i+*F5a~M-lK~)%}ibgvFKbC(HFqN zZo?-z5w5SnN<8-Hm~%M+c1!b4=746*{Y)~g;(RiuLG-8VJEDp+@6ePfu1TI|UmP#% zgGcz!bC4mZ_2Jq;-PrvnG0u@yo39~da-7Qj!k7WI$>7G9%aJ-+3a$vr;XPD}gDc1* zt0Nid!(aXBsEuoR8|S55mJhrhI=sc>fx$B&Euz_n(d9K2YLAccRi)_Isp|TUb{C!A zlo)*6mlntqtV08AhQC%gdcZx@QtbmgaJ(>Qe~iPct)>@j+0g0W8zu zoud&24K&(FBgdmg1x4|v0tkeLbm&d1Bdo9T`2~?Uwo-pB=??-nzAU&>0sJJ2wy0u- zS_R7`X0U3caKoZFgkugHNLBm0=ZZbw&d|I|3Dram;6DF z;MJln$Pmrm((5$o*K*A2K+@I{!7mJftPH})dG5&wKy~Z>X?No2U?)_9bW1l1zOioY zni`t^r`A$3W%6GU!5-HY*n;Qt2M)>G$Zc0pYrZcV4sA?d(Kmr%x%|Tk6Pj9jxyhON z!kY!tt>x#Qdfm{Xlf8qY_&5#MZRGx5c*MsAXOtP=gN-E@Wr|t_fC5qtsGcfrwvMkj zUp=g{aer?#;`u>Slih&ZVprd>flO&a?=21DXk->CnSsiQgO$cJ+-tx&F_?NEs+F}X z45>||nm`bQ3GsnuHyT0L&LtjPy^(o+l;__rBtnEG*iu=eBAlR5XONEPI?NNnPXCLr zcZ`m#>)L<&PRHojsMxmC9h)7iW7|o^wr$(CQL&AVZTsYYp7W0Jo-xkOG(GDyBlM4|#8}I6q}D;+6~GQeRk2%oaJVgbD*8h~XH{aD)|e|# z;Wt=%lk+;g+YkkK??9!ITC%RjU@$NMKLyHVSm?Ma|8}p#V z`oLT(ADJ^|Xk6#xv6@kI$R)IPz72WAL3C?Uv|;A$s)BKPIb3|b#>8CgA&_xDc@mch zsmMS0XxhUVEXH`h^bo&0V44f`WuWDvueglV9DtMm%F|`e-s4esMdN-8)|!5()XB zkOGvKb^3X1K8t^|7{!^VnY&fF=bJRN1BTS$XZq~h=lhB4?)e33vOpcZJJh$UHPts% zwv1A%Q-dmKO?PHpPo@Zl{DWHK^;2GfE19nJJLueWa;8Vyz{-5G|K`H>Gbt4+a9|MW zFF7l`0vWjsVRX4w#9I0hOe8fI&@Oesvsg*HixK+pL56jra>%*up{y-1m~AY%l@5cX z!(qZ;XcH=`Vrnt{yThr#C3@;#_12& z#l?rA(4^$d33~;uuv1gzJe}I*kT@{y6n3{J*|%#o`{QG0lixii^Ag_%XSX$@MftsN zj)Tzgn5k$#-_1Ta9jdMhC#BAW{_m72Y|yZFFaI=522CD)m_wQ2*m(#gYmUpB(scdS=rd3r>p}s(+U+a0$d1N zGONWvTM5>-3wZAvv|zNa_#!hj3lElNJ*BXF(fFOR>58J5ui2C>U9G?Z@~efDBk@(!HeX1WbQ6qS53W3i^mYN`=MkEr0X#Y1iAD2 zyG?&=j|3vUu$oH^^wJf)%;zV$r^RC%sS~tU>^V5z^1(x^PnNxQksUYk^Lny2YE@J6 zD`c7hP$p7WRbx`pMC7HRSjWhlGNkU20y>WVXB1(F3_(+3^{^FTKb6sfb;DR#MU}Nf zO|@GBAdAI3h$qX0b+{ToX76ZX(OV#{373$EpsN2OwWxn=Umoxm3SaXU}8P zV5smi^P{$~D(VM)*5aRfXz^NE#23U-w)9+9y>Sd@g0VqGWonfwnxD{O&0C_&(COd(+HUozV>6K@lbJ%V6-1)d0Lz%`b zy?PU-%GyT5qngLvKo+w}`~Nl4q671#)oUHbc&+MM#38n{h}sF_yGTkIeGxPLVE%vg zpNbsZOq1yjN2P}f+M_PHPX3}9z*0?7fv>3MGp90^lK+lBc`IK{Msa~gz>wm9c5~Z1 zIEYD4m$J5|cXPv_RSAyCU0bbAV@w@SnJ|_%MkRl>Umr?L2(xFkZ|c8z-%Vmw;Bdul z?K~t{HN1sozKg#?`h%8ix~oFbOg8FdBA%v{VXw)((FERXAi`jfxv^&O6<+lly_Y=e z)o88Rc_FwZj;GIubH2h>+rJI-L_$FcAK{OQ-r7CVYVw5G*i>lsSSWpHl9g9DC8@=n znW+wL;mE`Z?v3U#P7jQS8JWZ(mY>+KqCw2(*B>3()*XuIGr{51l|%p86N-QIvzpGY zV_fV>bDl=*&1Ayp2MVoI!WMHxlH3haj<h{84O{~GNLRC zGq2TWQ`@iq=OG7w&!F zUeh83AAL6KZQ|^(SQjifBaYPLFu{r!5=RXh7zx`UpI+uQiy&aE;^nFm9%PRlBFjPF zn3A;{PR$-RM}(^9hT!oxi2FA>QAYw5nH441rJ4&+l2bHb?n}W+@Lhp7!@^!`kqX@a zBJJ;k6k+9g`d4H8eJi|EG>I;rl>c4r6%|rgCosYD5Mt=1|xjdpr+CIYf zlFZoZaXz?cPOkbe(&nC%6JVX_qnV+hFBlG*v+31o)UN8Tbx5DN+5Ts*r(aubSlAu_ zk+1#8zIxmA=ay1|)nGYkY;Ld>t@YX_S1@5FeB>$X;vjHm6Y$W&IJJ-#Fy zbWl*+EaFnNXfz@~(!&QFcYxQ{QMw@D3QFwGxQR$G{WsXzht|Fg!AW%;p5}VT15IFy z=W^W}8S8es4JP|aD-tA3{=2XL%_J=v`%_E|3j))&D1eIA5p+bQ*ny=Kz!56c!3YZu zsW4JY+EjI9&1JjZU%GAuH<&~-*aU3RE>WPP4AYnLv1~qtU#eY>zvB+1%8%I@&D^c4 z%(VcU5aPAlYo>B^vyUEX4>oOG)tyEyWjwoQ3DM}gu-Zei2yjrhQ*DW#;~9?STt2Eb zfVVu??&c42S*BD%W&d ztR8qHh9oL5jqE2RZv85)X;)ld)!31nK8#=+LSN3}SqleNiC-vS3VEz2QMhzC6JY;m zlKe)lFeA;7BiUA5`Y+@fS^zHHOSHi0?7CH0^{|E{xr zT`x8LKqrnwnTw~I;_r)eT9DF4|MzY!V(`r4N%6D~9(wJ4zY~D0VY^eJIFXTyx=NH7|x%I2bh$BFjW|`uCAQh+Y8We70WZ# z;|{;w*2XO7&>cs=oZhw)2*5`vxCNn4&%pB&Bg5b}j$H_9X|2Yq4 zv0B}YIKfjiT}`OlmFqu+nyGdQ$OZm#E$@Qw%0>QXNc-Jfyr^Aua8iz-nf!wl-XV(= zx@swiUe)8w!3lGf1G=;2**q-QX*ehVR;8^ulfHd=6Q@&*568RwFLfTZB&+9|viM=Cg%^LUTqv%KS)tk~y zN|^Z)}A7~q5E7Ek%3scbw&W#$&Ra0IwS{$Mj*zuVQ)nicm)WolwzaD$Ef_ye~gGut;^@> zKI4wAY@$X8?&=fM#+WwT(33SM?5oxrs;hRZS=@SSI_E_;$lkBakZUb}Bvr{sEVU;~ z^udum!$)M=<`#%L=NHa16}!xJLH>MZ>A2dZYF>~8S~&mdDM4PJ<&LXho4Q__Nl>gd z&J%VvULGLWJ71Q|+Z|hl_~2~I%>M>0h~rqGDV7htCv;z)w%zT8;f*gfs6id>m-FDC z>gs|~eaSs)9nDbA#FxtaE^9t)Wnj{g9xGK(Ge8%EG{^cJ2ddE?Fm z8FWMm&Jw%*Hd3EKI9209FL#6HwHJSXDJx4aXv$(pjNimNbZ4_O4HPORIFQmAX~!9m6ol;1@Dp<~+5 zhX3YjBf`#aRb8|I`i@tLDlJ~)Bi$cc{DNvaNy1Vbg6gB_UxfdCSVHxGSOCpfjHAwn zvKOfB_~c*CzQmP=1;z<^>gYbu`JZFiw=p-=8*98%x~V^Z(nh~G1T|-WrY7Yl#S>E$ z739s_fFibf{LIRqPdm_&fNQrfI@r!UnH(MCL-1?N+C=9U28#h|lbGr(4TQ`yQmTr$|kOw}!8WO+|2#JqZ+v!SAv-NeW4o$YTINs=S zK019$ZLCRzp!Q$0ct1v=FOF*bmzoS!uG66VkaZ%|{;dVe~2-!+1&EX=xbK_q3IauHa8&fP*b&cJG4XD>-Fn!-q< zauZ%!q0b_m8Xbi)-0-FVd}CC%AEB8y#-38}QN10*)zsPEPf1!(DLoKD=brMI5zv`X zvKi7&frc&@ZHz$@BsQOGaAJ$1e~!{ly$D3-cX6!XD#g=cPj^}T6iWK>)NImc5sy8+ z@A2>vr{%Q-!540TD`0#P)}K+c<=|a!K+7Q48BSnfQm6G{%0NZ&mA|9l2{L-u>lt{o z>CA!6g>{?G7SbKETM{~$S_lfEiPz|xv-vXcr^#+%uw7$57vQ}FE)n2Ou#`_OlSizE zNYJRTR5~H${>nYl&G@^>I~~WrU)7|fo{mTuLhy{PnROag_i6O9(V0#Jpf!|?c7GaR z_&XGYsvZv|`ECd7wkAIx|Ef_=o{B|dK41~N0nMJT9)T`P{|q^0k55r?gF72NFaTzb z*L>ISy0u;T54CBDD-cN_pK5^vxa_`=gccMw9Ss?!;g{P(!AZA9l3ZW#SD2vO;gwB! zgiVb79kfId`)@?_nB4E&$HCs)=h)e{l#_zPP-2)$IX&Gv9FUID+jnU;E3U-t5tz(@ zs~fM=v=^!vfa|*)nw2DX5f^th-l1xnOiIeDl#{tTYE&_d^TF%<5NADHO~lB(s72L0 z>lI~lIUBFoYwln;-oKQN2ciT;G^!|)KT0kC z@G)G|gg$tBmiG3;7XnoOMhAAU^wK8T6|7gGZ^nx03o?J)6JKM(&NN!YWySr9)KH)_ zROl3C`%;ciNMcV=w`~NS)Rx}0P4U%%9rQN`)Gvvt*pft+l;%l0e>&(h z-mF8&qB`3binq+mNV>q3^kSCEYx1wt-yk?{!oHS^T$9#^tK0EsqRCe%BV^IjU-{UZ z97!7gfLX{g7e7?&mq#bp?q*b?O5wp(uPHN=t=i`V%-D~xLf)Cn7RW(TuzOFYUsjS# znib={)MP|4HD1dbBQn>MbVBg0iRrC0CK!dDV=w0uec0);m@IPO6?VK#FIIt&yKg>P zAK)+N(o&$k>kz%hI+By>wmxhr&F>Cs)&0$Kz|=x@zd91Sc+~&4D3muE_LQ_Auay1R zKH#O#Sne&G;y-BT!#sP1^-EtL_au2m)uh0R_3{%)>KuD5kBU`Zg|i7YIMH4U&&KpK z=aAN$oE{2k0i?XXNVG)&`eo=yn6%XfGoUsNs(14=7ah=C*}6Mw_YugVOI?OVK^t0O zZVG{uw`BLxs<*6LV|E;3bNiF8E%ln(;~0s5850A#*=t{g@t(e?{+(C{@zf^*rm)&s z5|EVmv9Vh*?Q}KQ-^*lW*NJ4YKIK9V{ZtU+segWGeepdRM0>=X34bgg)4kRte<#z= zGmA{uDzSa2b%w+H!5}nk1gJ;9_jOQl%$13L`^9vQeeDU3NavN6E_eo^9)0tWgTZR{ z{*8uts&;Qvh1F~}ph!E*(EoNPH~#9Fuc38UX;$;zXz`U{&br^wy5-OSg}FWhRhFkU zrG0G}WvvMDHYxxW+wdkNblhl4ljSKPc07G0l5krrcv#28@6trtJO2$F=bKV}RKq>b z(B}Ka1vnSfgWY&F+j-hZ129g?x2M{euFqi#$-CFdBexj;@J|MZUpQjzcQKZn%Wp&Q z5p*%%2aNlYap>A+TzbB}Y#~WW+e!QZraKr!i#xwJMAbuN0VV79#l|+5P@8Ts*~xzW z$+u`_Y6?0fE!3>lfG9H`0p^;-#-)kLlT6rPD?48FO*=@A8=Zua%iS2_4)HAF1|yaU7tGW){<2>ssY!G>$EY`!ZcER1$`2 zW8DA+$xs=>d9mkPx z|2@lWzwU*-bjvIuHT4UzGiuqp$o}x`a{2mE)@!R~DN(a8O~igjeKLzrERBiDH?~3` z#bHaTAUb1eTw%`5Q;VKqgC|%(l8*u%M^;Bn!xL)r>c@i!QMcp4djw}0& z|DVP^QhKCc_t2@#3idFJ3x$`JwWO6AlTm{Gv695md^7^PO9^Dg{Tpd4BQ4qFzsag zWFRpw6Zo%n84C8=42Gv*yb4m=43P?w1uxp5*^JGnx{$;S@?<>vEB5lMT=XSmHNA5h z_{{NvdP9|P(mt*DRklex{X;YQ-_;^AN=|viN+ANf=B4-9m+DXZ>^sbrd*tW4KY0Vh z0=N!?vK)fGdqmr@lVqA2`v|9p8 zWS2IrYAn@9>-a!>n)RHw!!8wE=2v>CzZ%YGus^%I^g!7rpxOibi|Rt)ZE@%ijs}ut zkHjGV^yb*b7LwX1_|gx$qh;ds^YqWAOo7pfB-O~_J%CR%(oquL4Qu53ywcnAF2k7& z$J>y`j+*NXJpGApa@R2koz6($RaVGVxa4#-e?KlYP14*P zXC<)B0#=aw{_rJ68(3?K9vtUTYnXj_6=d^dmQ9kJms<30Q`DPlqtSHq*YgcJM%rwPGm={qsbEYGlPFJ_q|x04c>BPN?~(?!N$@} zB-FsXS$Vcrfp~K&oZ2A^s`{mP&+SAcGx6k$G@WQYKjn(4`mUnrY`rfou3BYvVbc7& zt(By&-S#t{q_q7JAuH<>_Zo9wU;FL7Y(k!JiT92G;C)5?xVY9I9L^C-nrTY9AEMc! zB=1apT)b3&ap$ikaK~vSYiTH}h|_8RZ={r?R6yJ9j}49r4tCA8n(_8sAmj!VJaDPfk&tAbHrwJ zau-t&zu&;#*1d^lSe73^Yphw!S(uwA!+}5n&bF}3;TxDX`Imx`xHA8rM9D^$CPb>J zAl1T2^nAs5@)Ftuwb-o+A-eH`tLR@xpdDHhdDEZZ@~Np)6`?R0EY11ukyz#sWm>0$0yvCJuB4hr+qyj@wm3;2H>vJ+Lj z)1h}C;G4xZW~%-;NN%r;n~_+1!wyg1n2K4knY$eLTI3qCwic}q3pBgB;)e4EoZZ5k zcdZ+7ge~D<>_Tm=5Qxz^WsM&6o)xS;Lk{0YNw$iBjlT+NEryUnZV*!BfzT%B@Xhm8cp+_=61 zg>qEF`*Mq#X7UxUZdSr?6TZlT+vk9VF!G;x6xvVG2tJ&z34 z`&q)9V;yH4a-Td5*67tO)fq!K0AjNxuYBtQ9?V{Ix_bR?&%BgWfDHW_E>7^RaYyEx z$9m^@$!@3*#80bkhuJt;jUdD?2|vQVqw86l3a>JV*RAZ{l+V+z0*g&eIjx+S2C)gs zn5&3FzS&kT1_*|V^=!r;5)<3aaDrEEXQe))(FT)Q8=*dnOQb_5v*hGLK&d2FLHNXk z)3XS6b5NY#09Z12_>0^rYWF?4d+(=FVf+-|(6*AFMq@r)i}EXbcWf5h1W!=D?gzG)9EiCJ%$fT@=3+GxJv46$LR=ks{* zrP1B1UQ{Ew9?I5%GXuC!Te5c;ntPjVFbIem3D?|G;<#7}gGm}Ij+>*ei`yh9C!46I?jiGds~W!rx^2s3-b;IZ{$*nq zi~e&kudwcP*vZR%jsfQN>SOb`mfb7K<iLYHv*}2jQqZ;Xv+={?|_*tbh%x596vjOU!-5u;K?3!TsG4>H@ z*~J)Wy(FA}#%<({Bwym@q>3|8sW*{Z1}sX@(s`0EeiY)}WC&+Cp#3w~%DlM&quXZz z9+R3P9r{C$IA!|Jh~$X9Hx@>&G zVR(09#jVMRV%dz{x-GZS$IzL;{;2Gy7c0thkT;en&t!rVe7Q#WK4)G zg}H$8yt0J${{X|WP=N#xNg zU6^;$Z)t z9p5(zNlw31R#oL=r6cf`g$G%aFVnb^dF5pmd5JGLxycb9NwoKud=em=uJp6!zTIW{ zW$rQ}>ygv5ZK%e2g^IO$I4{82O{jEeBP6s$gN@J*5UTQ%;gY|i!lT>%sNcRqN>B^T z(ekIKH>a*zOQ*AM3kz3C*Q;8qBkF!Dv8^xDecVkJrikLGLF34?@;}PuyrY~9)VI94 zI{ROzm2NNLES&Z zgCLK`068-8O}tMAFiQV4LUKg3hpRb!^Zm%W zX#BL@GlZ_)SVhr;R5Il|5lwKYq5V4tWDYl8*uD*|S95uTBJg&FxHqBQiB;RWVN2)? z;@Dgkdc_V;S!IJ27>vfzCfM0Xop^peR$Wp2(Y+~7O13Mtfm_UYjzP24bwi-3G!709 zj)e3#y4pO$AB+eMZSdpUKJeV_AEr6SC#jC-RWKc#&3O{D^3)XE3q8YXQSkDn&wOsg z0+WroiZ$DTbHVZf$6~p$+lps58>aGRGy^Gt;YmQZ-b$U(!?`9qV2bSVNwiDzI(gVX zGu2#GXY49E>T*=Ucrp6}c`gM~wWFZ~6XDntq?gv-6_&-Y4%7>c47u1 z-`_TXojb!xU7wIuC+Oeb$PzF*+wB5Dm;zIl!5YLc5+7PK4g9^-CI2jh!qX34dM$n> zafj+UJY?GzWJteo83J5Q?Pn zKJ#eCrP-cOUo=cU`ENyIPx9)l%t}bf`j))RBdYvD*a^FEahOvsl){0hR?vpD(=1*E zFY{qUbrvDf2dgM3TD^@*(5DTvO6ExDOVVg&Sz?0`g#e!3{avie&3N;qDQ<*4>aQa( zD9jo6RI@%JeeX)k%!6}TUkIht{U=O-P4hlSlf?V=nIf6bVljTv82V8QKa#-*NBX_I zeTKK;4$*lqsuVSHKEd(LVhG6^Oz&oR%Wa#k+HD&`2ftiryxN<^C=)Z$EsKGnqoTf4 zg#;%+g``Nw3G7{{M5mbuAwL4fSBk~u&2-+Id5++0IMK)*p7zB{8JzW?DVW?&a|{{? zP6$@I<$<&mM4me`dA6OU2=vD2`d{ct5eZd(~yJAR5IU(s14h>b6ZT zOS8i=8K~Yoz7c>wL+kJ)4X$5`$say2Y*;7ASeevYoI0j)Rbg+q#C($ORbtD%XW1ZM zt_6v0rT@Ca^lx>u!ub}u#oI^X@&;-T&-#04Em!-V7gvfQ%HOWrlKPJq;P&Dsp7#`y z9FR!@fX{!N4Trvr5vPZ2B;vDM%WD zR9SI9tV%nS`-;UP?7ow3QstLpXUrONw(&aGtDgHyrr~Rw2yZJ8*QDU519t2XzgKS` zQUu&QC2tREL~hY-CRqohTFQhHP=Y$AK0^*#A9{+>|FGt-{~sRQ<W6 zF7-(Sdw+-{zf12pOzQdt`)Hv|75%XWi0Z#_v9hAUD`Xy5*TkuJA2;~Us}r2 zc?xLC)f!0=f48xbTA!`vU4QMYEsSrwhf=xE>Pu)|jmxsyUQe1*Yi;D>NGKyFmnQZ( z=s#e5`iDmSxho)Lmh%fyU7B~?g{+zBH{#ci4S@H8?EBpE_9d=i&bHw9uKgQ-^*xsHbd-%P#YkpRHBn+Sg zrSjwB3Ei8IDeM`rE?bpnd({cz?Iy?GC}%I33BE`BNqF8Tzml^78YfS+d z!1k6KEQCKRN4c^T?Mtd3iWFuYN?l|(*jG4g>gqCnru*KRuEtu7lfU&_C~-(`XB&MQ zTr5F>7NwY6gugjyG!iw{0yM$A_C`bw;po2R<`n-#*1L+RY|8MTZoE3^9ttN+f${XI zXuBITdBvzN&_in&!}$1~99VwrgtIu;?-}^cS5JU;rux*ypX)xJ7RbI zO*^Q%T7$kRY$Kpsr&FcnLdlTttC38ELrE%{v?RqsHikeHVWS%Fj846-0JZ^JJLUU* zNXdzN%!-}1ij#o1!}e<$*xgmyjJsisIwq!(+|fafI3IIMz-9SA?!u9_8@hA`Yk4on z_53N7TX@>zPEDkmVbug0B$9{@o6TBh z-oMjZiLSNhWv6yRZIiQg;-g;gK03`{Job%Ma5wy;{yG%S1cASxTdl&PQBAO;d6l7N zecKmadvl^j=jQdE-dr~sKFT+x+Sd~Pth+w#o!$AyT z-v4`ND}c_7=GaSl#`*s&SIP z}TiZzi?7tiG{}NN%!Y*#UuLN^SNMEP#UCdE%=mhhV`_xqXc#aVpewpRA zhEsnjtcv^(u#gPZbv|_c;$NGg{Yu0DYzrSB+r#l_Z7OGu{UT+wAk`kO&&AeKxlfe& zu@XVgtk7Q^;pIGgQs)bkH^|l*N-zp&-~U8T&6UmX-LY;iPC|C#nx7$E*dBZK z>v;tf|NVAxWnwzysmX0W(0lh+t44(%a)s@m|4fkYYq3-P`*{-RJ17+(^8XS&|ASgw z3%k#L0m-3zvnX1KY4hO!o?|A&_GQOJHHV>tOlwarBKyTcK&R1B0>;&Z4uLlbR?I0re()W_` zpwHfhZFY;AV6B5Ed4j{N4z6_EIJ@o|bEZPKp?7(L@$=Z*8KH;!ScvxKv=M^kRKcRY z^g2sHEVm`3K!=wG%1PpdrlLFIvp}I>> zR`ryv)S0X*m<5#g3HYtX#<;%lwi>m>Dtxyi}A}&wPQLTAt+G5)Y=-ZIe&;_xxMi^1q^B5=2@J;`#F(p~>B!K6a7y z&lJ7F=c^Q{8&xz6cr{7p*=3?XU$>>x7qi(sFltW>>v(%5%LtA8PS|pf6IJE=DKh80 zKS8<_vs8DnLQlvW9c+#Dh;{F#lBLlmU*v~M;@Uz33S6rA|6~kr;rW3tEJL#Z&Cj)I8KpL!%csuxYyR}l}a_h_(YXRX0gMUCn zcyoFe7T7-~sEpf5Vk^fTV|`Av;MFHs;kRXq5HZ@iRT=vF^6uyP`wvvX~2(LD_8vH zjm4x+3e|cZs``X?8t6`Ayr~V>6zphE&~4-n!#mHTGmpdQb8OlAVY_^olDB{{P}W(&4w#@Ua-p!J8YR zNG&ED-kewq=Q)TZ4NZb%&VFr1y_0fxRcQZ>&R^p)|5OEU{IeVZa+Ka0x(uF!x!}*@ zfxI;8t#DaRQfyP-lgS***G^_DQgeOxa`hlSYYWNZYKOPhfqd%wTukoxT*bES1Q;47 zZZ3M``07eawK+!qx-S>0KiDof+mZq%C9))aKZ_jenn$P2s-UfFw5+QOOW8FyfaGJs zO5&Ybf*QN(mv&41zGtb~6QIDqXSDzO__W>2i;Y1}3nAs0dEZl1>jYMUR2dd^0xG)5 zV^Oha{guRoj&R&K0eA@>dy}neR6b=C4qb4c1J%+#WcMQj)XPAdwi?TI_Iizq;oa%) zp#1pg9$FwNA0SS`_+PZZjs*3m^tpM))+=W5S$C2}D;GicqFa45CMBEcYAw2%FBzSP zmSQIUekxNq@Ftv>OARDGah0d@1u#7CjqU@;5<`>ZjNc$Jq%|jW4=a8Cu4Tzxjn=Fk zcWfK68z1=)7HoKqry-3!>vsPsb%K8%)t_^Z$i^5q5z3v4fNgZ&5V5g>m^<+5H>1g^ zBC&gbTldup50Rh1~4bB*BhMoT~1DWu$ME|53Q z{|A&WDPV;eE<4&Z*B-Vr|mo{RFFV zfWtPb3$CF9fXZ;KgZw`3ZUP4)0yItdVO-j-7Ukd_=E-)Cd9O8l+{u8#TIIJERgBF@ z)$8*cGG5}Ci1^PK!%uv{sj2P&DRvJ{fX!4V`zW(cSXrK?m2&_nLVsddBV;B_K*cGp z*=yt$02m6B6wm*V2Hp`BoycA9sSZ;w+NlsfC+`x?J~=E*f7V{D@EreUh*@tDcs5^D zD+1PFrdtw)Sk7cd-4lmIDqb(OO*eZ}s_=z-d+CmCf!dN^U48%06vb)A3fY2s;c>q} z?Oqt78?O--Lv&}-%LnM}?Tsmj?}{qe!K#q*e)#&mEwzE=yBa5FHGofNS?MpSMc4(X zfkVeoy82-LW`f*nNfLvXDe?0i#|4G~Dee>AojcfD(+KFFkS`|A+?eZL;scLAEg|p* zXEXH_AY_NZ&Cl7E0tWGM-^P^V0bjfX92-I{M5sjqhlCI`XD*E@?_djrqrxGNFNW00 zptCvAN^^dPQaI^5z3fx|=Pv-R^wmk=PwXN6x1I*eDh9D%+0MHvDbaf|vgoSX}6`*B|&OI91R8gSN(&ls59QOZ!t)7%9>Qe|R}vzw|hLA~o}r zQ{`_k0wAybhru}h(lq7l*Gcd2a@`$1#-}rEx@9hs!>uE^*xTsq;VMgqoP|E|8;Jjj z?p8-*^h5fQ#Xl=`a88-|2J zDlzcsoGD>~i>;tlWG_R4r=VvBuL-aAS*+r0&jRxW7> zW}r-^;c4az79E{Ck7F-vl|EB0L!9sKY!tH;{;25!hZB#B$cOhz;gyqfI5zqRV-_tr z--#nm3EYz`5Cx_(06Wu2zuWa-9fH_J2i|+um*i%R-mt9Au|tphs1JCr2t4p7oyqQU zH7-TTqjgQZCqieIi(uP?5&2TWaPI&=p*ujMe(2YJQ!e9|ngB zf8fA-WvmanV&6k6Q6t0!dI4jb`|rxbw&y#Al_-?0+M5r#UqKVbLFpomFDvA-`1YlU zwfMb9_VIT8FZ^ce2h;1#7wRCADe}Kik%h&c$z3<=Kh$u=-hEp-_~Dy)!JAR4pmW%& zIX{|mC_9l7zvwUJx>d-W<5p6Fm-M;Rw#R!a%#KpT1mFeTATJ%{{~!1<@mQ2CwFI@f zC8Qz2(~*Jbbu=K=e3!fM2=~O&-0<+lq5cRWYk^pJ>F#B?bToKO_%RO+P2v1@*^v3k zVuKjbFg#v9pv99>`xu?)!H3#g3CVcy@!IM={!Jr@(;B*SWf-nP%*{g{F0H}K2R-#r z&EAG4jnzzWj$W>=*KPZ%X!6gGGTGgkDHeVg0uDCVV&li95v+y~xvvdK<7IxP*7(M` zL8#SSu$x$4C_MAFx+d~$rs|^C6s0G!5KizmV8yTZ)+OG0qAEdG`pb9)`ptZinD-I> z8dFOwH_JK0UuVDJipcq|DdZh~Pl|8t6Po$|6NB)vGgjm3G9s~ygtYtg`7saoKvFiB zc^A4qs#+f92Aw>(c2hLD*?cMx;*3-DBVa8%_{`ANm~q{iCc^c5vulkHBlI;(2N5AF z3YaAJyZeDhiv{#L(+}A|L7$Ua{~rSUFwxZkL!SMj*GG9m^MGs8e4bYOos>7hk_`(|A;IRW zgNE9Ol$w}+CmX##$u(e0LVTVymy>EEm!$;XOaITf1TvGPLe&2ofAPaR}6NOr%r$8i}J&TrD)F;S5Wf{&A&5lxFe-&K}5SjbALXA$K4J1s9IaC zLD5ntLg#1JDzsj{rH=>(Ps_t@4jzmgGA;4WA63j?+#CG`A6*sRUMM7&I76NB*(S#y zXgBujmobuch4W|q{n7UWFS~d{TYMQX3|8cZfBWI15o|AZD=Xvvot*c%98UDHZzN~j z1+!I}JQ>Yn#6FAKJbxWbF@tYA8o%b{ZFJphj9_||6%5O=`9dK+zu1Fq0UG@eGC=TG zfCQZ@Lfbggb-Fthi#VZoyW=0Nlw)?fI~Q30JU&t4cU1z|Om=Qg&FM2iAT3rPT-!Z(`yZ;=DMgGf9BRk^~yEnHp%tn`4&xVm4e9^gxKSB>sa&# z^o{7CrMGgAHWOHzGf|W4K>tP}CV(%QtrN|1=;PZ07``>F*10=*WwxM4XKgU=qimUn zE!=&7bog|V6BD#(-Qz$pwr80Wmzf}zgN9EnEhs9AONw={+9{SafpZHMFdlE93n<1B z=Wp@h5-6J4+m?O8EbyTU-+SWR*+CJfx$aSd$o4XL&Yr^9#D1poFsK`m=^p>EDXRZF zMapHCu#WA>cC=WG9jeK3vm}GB7dv(>Q~u<+WCOe%vkSxYj7=7`Y+#85m5HXf{LpWRJ1=PU~*fDG5eM*ACiS2^%($X@>jgYX)ROnM{gInxSW57?Xo+D z{T$&i!ul=lb%!CR3&vztUtgMPE3;eCSEcW5r0G_K z)*%dzj=OpAdIWNjAc|zzkZ^m1n;ma)=A<{N1>=}T2GjeX-UiX0tUrKjAC02gM-}}s zkA{1nkP)X2G|K!(Fvetrn1&|&EQ6N@lV!ep80{K0yOK-%cM*)xj)bh3zbbmP1$R1v z;nyojOA~d*rkOS0t>RoT>3LE;j;Tv41=!H7&srf2U1h7aeNfh_yBX>%_aW+mej^|h zwnZM&^_dd#@E~r_$7o#ZvUtD$BCgWfJ1ZSq>Hts)Ac)Aq7t3i8(vu*52|opUZ;2Hc z$e2H)1;|fxaqAURLL2;OHD(E#AH&;x!=;$51`s9ENy*tp>&)}F`|k4)gIKX81e!w| zdF;&|p@pW$cYNdotVzfm>_T?^DSV`X+dIs zB5S**srPDI>Fo52U`c;|lLl?2oBNa(8DQvqR@3SiOubW+piHNqpSBBq9pl~A z_Z6)>ei!6YXD*Z)z;E?$7DzN$98W%2m2$FK2#KHY7iCYhbEzBcd2F^a{wU}YSv#<{ z{A587j#*?K(qhf$UT;ucteg2 z${db5iS8IrX;J_(c_0+qEF}g7!DX~p$kR=4?IcxK$}|@f^wbzp;mVT7IzJN%Ft}x# z2^`vPp>CAYbgGkmJ0KNrLr51y$HZbo(DL$0_1+u|hSl`V6Uhf%K1w1Q!Wb}Pp3=}A zjwCK1d|<}auu4X>bNTjbJ|JIi#p+agR<33_iZK<(WjMz>$=elO$AF}9r+n>+NyH?d@=|@DA$bs&t~bHQW-O zdPFiOrk=6s#ww?x?NQbD##0D=SOK8{{Ca8V07@g7vm&P^iV|x=;v2BoGCD!gKDj`l zE%~(ezWv|jB*d}H&^%=>_+#lC^-sR0nz4zL53bMLvDn#KADbqF=q{=wVH0(h zT+^=GyAV`VW+F|5d&q$Jkd--H20AShr$*7Fy#uP-KY*V~cbs*j=VZAA5CpEMFUGyM z*?WMzcfgl_Q9$|h^b{0b=yN!9+G+hlhPZUc-;Xk{Wd&QcLIQzL>J)?g&~7q=edd=7 zp09ITBJt-_IDV}MSf!wD&?oceemF@fI;-*VRrvwjHAQH%KDs9KPb%&iC2JX{#l?J2 zC;{R{nf;}FRXUC#QwJ$+d6pv4n$Kafvri|6{=+U1$@%x?+K;-nbL#`rJbnA=)ojDL zHokq8U06^Zvrf}&tEIHok2N93=%xTj9Mf6GtQs2z-8s-u9dX}-os%MN+H=8NjRg6t z0Bo~6(QKN%n)%l(&}pvJDcO!{=rlSu7`us&RyGjl&~Uc&71|?@8r6nDkw`^bal{=S zvhfm{^T0C@+>n>IwPFfea;LG>sf~T&QBIad4iIabsStuIJNk8~6OW_ZpmJJa!Ruqf z{h}A_XK|a1wKN>sdCGyB-{#AT`n+-YR)*aJ1$fAhiU-qBJ>kE@lWVdo7yd2|>c;gj z=Y69_b^lV#nq+<`KC-j7?*oC5J*;LFOL8dxFV@~MIzvZM$RJwr$(`s_*-G#&_N^-t+JLt+A_W?XmW{_F8jZb52~g)VE>H?alsgrI}kr zf`;2~3eQK!`gi>uU)=1dCizs5)GPLVI~(4Qly>2GU4pfsq6X|fcoOC8ib6}`eC4AS z_=5yuf_}X}3QruABowHb)?dFM0b0On7^e7^3`1P6J6&wPfT6|_XM;9b3ZEi+4+_uz zMpR+zo;NBcgzyl${28GP`qvgof9L(GKh#bfiGg8j2i`@kynNFJot~$iuw6bd*KF#< z>FA!PZlS3%mBI1J4nOnUW2##LI)W7R5h3@LU;IWT-_C83IetplscbhaAOJ8$dIuLw z%8L}|Li^(C$X62{JqL%_qxwaYlBi9`mW0vjo-oaj#AXo8#3yT{9k8J<lZsxag7h@Q)I=z`r@w_<< z|7FencCnw-fd#4xY{pn<$HJJSZSOj5=<~@uZ3(%?5 zAQ)w|>)M)E+EUZ{Wn6b782jax=+k=gs zgcqLcp>}4};FHmsR|_!lXl#kj;T&UP;z;7vwP&b7#MDuQr<;h7^qSD~jv+vKG;@2J z9P5j^mJ_|e*3m~e8=X|Fyl?Cy$DR$I^!Frju`f)LRYpHtIWfIzekxPz0hpHa!dJU< zYgT`VrmjJeH{Y9`PBkmAwzadvXHBQ}d@B~`@I2s~YHLQ2e(tmjME>=5=6!cHX z=yEgR22j3>XdCP^0obsa49O@5e^}j5OUW3qMNhVO7b#c{aV%{cbqoxZnBzcU=J^{;~Nhv71h4Dy@xVq1ag;c1u||A<3}3o zmnCh@c7lTX(uEER1dJ1WWkvGIg7ytcydohj*Ma zj&a+zba&Mux&Wb4XEkXhyEo)J6UxVUS=&3{=SZN7T>2(cRu#A|U@5(TFBU*@d!*^| zwjSXUYydILD+q8&h;3urcl6lb%Wa(8TXL=afvTp+_-ke^!svD}&7ALrKk<%9e~wn{ zlPSt|A-(3}yAocTB;FlYZQ(s>b#^&NYWK;9K3`3I(#ED{d4^J?e^}N5(;@)1s`CNP z{BnF}arERdd`~C5|Fndg?i#ecHo7Sz^2rQfkA#e7%Hca0+Ze)pIVz0JhC4y$8I06O zPKum=@tIPgqqjdVPA@gd@w?g7# zT#vj^%15Ur+=Ma46j-)?L-tu~4Ys_4AFtLToF0b6Qac@?Q;KA!jsWuHjP{pz3%~8& zU4D7?^8BM}Ja#onBcNSuuve`?=gAPt*G@Vxv|b*~(>1IDu1^YFNCgZCql?RMVtlU; z>84lJFX``w69|#{`=ftIHsz9*8yPcw0yUBAFIs0BJrXL*BOHnuI<4YDEbgd(dg0uU zsui1iTB|6i8qWUFoM=L1d%AZ^+F!pJl9AsUIbNqrsSXEM|MM}cWloN&I#-EBQq9$+ z=XrNQ!bBz1blh~^%6muUazT=9!>`yoDr_bLr(8Or3EVDkk~qGaDT?Ny*?$xza20cF7&l2(JtsZa%|&wTYvvuyb+7Tw&=k%P4~pw8hF%AAtc}GI+gv2c@+a ziu~CFT;ejdHU9LtkV|USf)#5d{7iuymLy9U*9On zD1J!RR{HYAHp$_xMeec$XoJt?@eDJ0bCR>5=%#j$C1CTw8STsV(6hd%;I>5DD8P6> zksFZ}V>gX8Gy?}M>0M(NF~#fo#n;z%Thl}@dq>f!XL(h@jW;Rrupzd90r;X_4vC2} zqV`$uZ`Jc5_IOq&?o>$-O?Wnrbkk5~94Xan@Un4S^=uu90jfYG1sZ%U7Qdj#oO^uT z>1mF8^PHYBx-obVX!^M&pM^}leoE#FD+1FTC3&-%y|H_>BKiy+lf1QPUj4B{kDTu> z5SJsFy{{Jw;kXgR%IRx8v{G-0!)2Epbq6$Y|GgU8Ap;;JU=Mjv{NiT0tXSTT|4#-_ zLy=26vQ77a@jqJk@~yQ-rk&MQ;vslJ`4A%8K&rn|_r9e@uUG|L8;P}ts^se^wyD2- zqw$WQem=AP!|Vpz6IsWSzt_M16Mtjkk$SJIa9+<0S#I;4|E5&Y;C+IUBJ7a?DPMWo zum9zERiGSLhuda({xZDzVjpM{e|D zxLLh^@(q(pk|lBiCZrwY+6_@CZ`nkKNDh65TZn$M?!N?dK8(0TE1XTYj$2bW7VcLj z6G{}vXMD;SJ(BvMRp|_oER&ufLMz^m_=?!!wQ#^7{>hrc|SP`>(|L)KL~KzwD18Bo!oL?bjo%$iGEuMMVeyOLn#)0>_~~xkn2VRbJvJw z;=9gR@p-aHMsGWDKGmu#u#PaU90D;M4d#w#dK;~vckz0|b?mrLACBG2uAoX2uiWW= zxurSRVAynhg3xcl;6rk3NC#U5^KUriLs4rV5W@y%HGEC}@v%MQ?A=b#ek>z7al>CE z9e4g8V}~ss(x{LwpIMFP7PQvJtgpQj85Rhs%AB^)mq<80>EOqU?@YUcrZA;DQZ5z) z<4AOO=kBJnWXWrBkG(hLZ#4nRQbZEb7QuvF%~0cMZpglS*oxeKLkBTSSxY$fwIl1e zV@XLfoexS~{-B#W#=7Eq#ViQczx96e#w|yOdA~VPUisi1tQ-@MdG$!7_nRT@cGDX z09L1Z{J0OQ=Kap$i5p}3>C6FwhEsyG-=-^UThyDJrhWq=w)@TQlp>#-mNTb?__#>@ z_)ghJELDE{YNN6o+e?rrIH@zF%Gs%#fiv)I#}Nvs`h3x5aU4mLHLIx&Tw9yzF|T%= zi%YU@;=Y8ee<_w1#oJqCFR)QqBj+>(>fAlBvgDrvN!8{2dXRKB3@4NW%~~xvT3Lfy zm`cCfncqY7itK8ze~9`hFzwEbguCx%?uPdcrgSob8_;TyM1E;iXL?U^)E+_h>j~+U zV0x{mJZ|$w#oMLJYOeQ+cGa^OnBVL)&Ny~&*_6eVv0=njH^TtooS)c)H#vo8i+=cJ znQfF*A=7pIHi(6dXw|(Vu8gT^g+-+E(JE6qVzGyA^qSEiLL~%(2~c-ep?Y@+CrNy9 z>Wx&@re`h30k>#tzdK?wq+>_Hs54_uLJ|3=f();VF5~6M)x15N8i-X41T|?%Xr^Xk zEo0+|%F4nsGbb$zblAhLM&UP`UQxc`WJ+0>@JM4lR_~=qG@*M{R17BTk%l)n{p?`q zfPa2KWoBj;Blrx1zT=4g3imNtzN;h)Kf2zf^xs~9q{eoMUBAk4B5&b zesYU@x?#AeT%Uc`CsDF~c=99av+2UvL`bAfj?{z(6R{{gM?hlQ4JkV3T z%t~jRXOQp}hDi9?qkiT`;=kFz@mpwM)i~TMri5?|SFPf^O5=n%O}sa=ZxVwMOK}s) z=T6{dz44UN$bN;3X6I#jO6hhgJ|4Q?TtQhYrFNloMNZU*iW1Bqe4w~VPb{nmpm!ul z8%Es|%NXaJ+WREJll!d^D(`8XnB;T8iP?FJtE_%b$|ET+NyDzGxAgQi9S@GdrrLo= zY$AXZJiA(Ys%xp{YTbL$gR~!^BLfcbUJ<8$>eIwDNs*Y zCaqBP#tVk1+~!7CqZ!K$!t6^Md|R+FZ*0A3I~%kvc!=ThZ#IZzn4iI?JkhvaVP4$3 zern`Lvjj)+pB=D5Rfd;TNY))cP5K3FVcPs&TB$MU2-yHNd)I(xo~XS6lJ)-s0C z_L`;jDeQgB2r{r_^ql+nUx(%4irnzo4;fZP+QHkc4Vp-9&y+!Nj);?Tx{8F{vQDjjkC3Bx|#=$jt8lBsB z=L<=Puk5WpnWfhh)v5d?*Ra?igN5y8c#mk7G5r8_dgMz~t(dfU)i-*?kGgYjK|)_! z6W6i&$FRD{^zf0yvVK?aLI`X&ND*~W6CS=#Lbgl@0w2HJ^R`-AH%xj2+$$$&Q(7dT z?gwk71F7Pm+x-yLhf)7N>1qztYz;<$2*h)ttJ?clisvCeKTQAuGF%hLH^1f|e6}Lx zUX#rF1Y6K|pd|hXLJW&DgJ0ahmXKs27eEO7J^Js=L9zXxyBz2S5?s`d48bb(mrqpV zk%?(O*Da8rc^F1w24lJ?yOf$H=5GnEz+8k0!xZ{$v{0?P3rbQ=2mD4^6^BQc|#_Vv1dJ+0JAh;}`H` zU2_qxF?6#3vzH-j0B-aB2O7aAJD4-MGVnmERKX(6B_|hbA@!jcWZ^Ff2+-y zH%9!2W3l_ovA~1kq-_9JCO-ks)tPR#PZ1CX6r8M4+5k*<5&a=qa`yWLf(%***1sav zrcH&~Fv7Yg{VzDnsyjCXAi&@%7hBq=etQlEjlpn5h3WFb#H?t&TTc$cl<`~J zPoXB*sWYQjK_rND!l`zp0L|CzPH0==CX5wOYEJOTgGq*`Pw243DQ8+hs*1k=KAY zR(bMqFdH?s99h!~Ph$bbI+p?DYud=ev#-}SnS7r`D8Ev`2egsF{`~oRkBMs~*NmL? zB_c#g!ElmY$b+zH6C6v>-<&chh4XBpylpjO4)x2Me?L_Y2<(qGgYxd6{Wd&kVw@$= zCjj#T`dn|rj|rz|-p^0kABZ0akP{g0>hRUHpRS5O8m#6)AF?-yUBDuRhaiVQ&iskD zcxiVN2H68;2@|<6iy$%IC-**m5xFA!(+=XD@EV!#ZsrwpoGVhxtl?gqjBF{bvy6!dC;AUU$S1&pcvyRUU{ z*u9C`fVAxt3LwNC=v~gbk{aSYldA4%7uO1vKsamqbHH{S3|=EUkscw&NtR4ZPqs!q z2)8R({^Jgs8tmy50ENhSlQEGrYWRf$!ydif~u67@GrUt zZ=MhUZClBrITfSV6&xbkVfATg-JFzF7&DXruQ4+w{`2}D?*p46vrd|u&(7tG9(eqb zPhO}Y!B+&x$Y}}v>rCn{ysNIAG_5HnbWRU)Q2*!4?^+pEASplEoTBxar?9KBqpZTA zzTwMbzVd;Zjoe==`3FFOpK|w^u0?WgYIv*tOe|#+8U+R*zKouzh$_ILh{N-1^q~ZX ztf+vR{~JcT$FLuQ@a3R*d<>g2bue^n&nVEJZ+>LH#Xd7vxC}CG+G%|XXjvVYt8a-H zsG7VR9?odcta6=wE9rYPxF?bRvO|zbG?h^qh-Ie|%zPZOt-PR^2yZPup89xw^Qdml zcVdZ{@sw$5p|#zj2kKp32=?s!ZHGZIoM4{FJ%!mi@5%51SKc&D%mha4u{MCC+S9r{dVTAj*wwXa}#U*qcfzTtoPu=B#xA8piVy1%lJqn8cWi|yuj@?`J1^mD;{OgTQ$({@4KSk+6S0>|i?~OfDG0W&`F965s_D^mr zr|yn+P>Nqmy*=IAl>f>-7;|ve*Bu_wK%uZSy|Y@Jmj-yBKZuf;Z4&_%lHp-;B<%dH zB8x8v14P@eDWq=2(t;gK&VacssFT4WwPWROwmO>5ImMi&Zz}qd_mu$mT2IVP`S*B! zSaO8j`JiFRn!(XYwAYpkivplT)V{Sj2?-gP-AD}T3SrN7RklobBmX{v5Sp%5)u$#8L8N|pk;6y z5w*yun@R!`qkK4V$+1V)@KkUfFUPpc{6Ha zpR~sJFz5E9N^!?=-J5QFrZBy`w-*!_NBR)lNL&J|?Dv0Q-CKsC+NoTPj?hU2&VBg2 zl8IA0I4@L4^I?XA6OPZPjX->YYyNP$2H;j_k8y@BWKU6S>C4j+TI=ZR6nm5R7edEH zhyX+Xpf|lwz*HvDb_sf5r)%VCR`;iY)QRy+W`ij;c$PhcpM>{wA+I+u2^;t0`A7^j zKgTccY(KIQ>)!FtrQg2f?MBIFSVX*=-}^k*rb{RK&iZNVn+=LtmAvCE1BJOOCEONa z+Y=40RK13tfIyCam5b=#FQr1NXX@EiGW0m;{~}aR!m~K&kbEN-xh8|H%XUaeTRSR? zy6G4ZdMu7bC;7y?E9$Gc{rj`BES(rW`12Zsfr^TYDS3h_-6(YJgzRq}9@ddvPWoYT zwzEF1H|4ntX7W)TbI749zPbY*eUT;A8=&X%64}=H)pb=WpoNY^GdWzBIsL<5uf|J~ zZg-~E-cJ6Y(e;&OXSL~c&~yOc#bP6%qjo`NVujiK0B36Il=seS5bpw2dT$1UT@&tm z;5^VTZ}1UJSNM4+Sc;_SXpIcun+RAuxVb9LYMP<^4a}f=X583czrVaWD0@%lFhdwF z{fXhSL`}%Y<;WPPjw?NGKd>s0x&AHWrbnH*f1uF)EJZL_E)C0$`YU&mqgf5Odx1hN0X%}~w;K)uy}P-VA3=5h3z%ot3Mn-#~~C$C~0 zPaTq0tZ_r{AnA4i8l&(tT6{s{os^`5n`BW$4K9NukNB*^34r5V{jZf41*X?`0TtTx zUIl$)N~TPWi6!YyWXdm^`@PtZwr`+%?TCzrniPYz2fgvPetrmA27NGGn-DWm)js@I zO+`UiDf-=$)%Hw%_n;MEj0z!)%5-|1mtQ@3J<#NO^^*n7rV}U>mUA zCm8&q^m@=jWqzrKn?XC#-}nlXzY;8212l4e*s#qQHjli*a}v&YqC9~fLEUxz-nbSb zS+tEEfoRdcmZ<49T%)Egc?4Gg+pWP?-~jc-c;0}23+$Jw4NSYU82(&W%O*->n>Xa{ zYufVr$oqk)=>s*|%<1xjFDQC5pATe)YuFYW=SQD12HVdA`phDWZA!}&BwqCZMOF4F7I%kJWI51=REWrh`#xSeLvWPr0T;tekj;asM|6pTl{Z#d* z!C_wxX?6s)p*&o{z;&cr@x?W9R!z`(T}$V$#M7KoXr;Mb$4*6R9?6)dqQ+=r$#5XD z2m4jONq7uDGR9w1obi___u~D|%at0V0f4tpBv{nH)qHF3*$*l|bxG37i;9VKN5pW@TI(6GtFz$GtUCbXpSqXpqBqh%X-i7@MA`l99xjSt*&6|dI&oI?b z5VeA;aLdC5Cuiu!i}sfA^kEj!XM52R?He8C@E65~WcD3J2wI zI7Y#>3JL7r&0Ye{LzM1D`C9h1^YxqtQqg^Jfl@;K*3!7J@MjELX(z1FpA2Xc!HGAKv_on>5*!(RvH=+LzjdI|r2amu zlV1V@7;t#_Cfx+;UF}x4w3CgYjPnQ1sYK;HDTObWy4A(#7YQfVKN&1HRl>l^|L;!k zMv({`cz~T>;4P2*SB)}ky|eJIfJKbt+14I;RD%_RAA6sV35~{SKKDP@=!CHSqj3=< zsUg@u%T$SCoA|qN1bzdFq>=YONiY9?5~~81A6wXSWbZVUNF@6|@X&mCn-!@%*rT4Z z%g%{j_74ImxOP+ID7+_t?fxa3v!3B?Gdth5CvT+;1H~%)uV24TxHf&J|Nc!0nHfR* z)uVG+-)oYy8^m1K;`1i6?o(rq^Fx7zALR>g#ZV_mKhO#i`o6H_F+&HOo>PJSp~=Hz zdw)MTDT$JBqLJg#))dd!QX_#vf!3T?Ph&=sSHj)`}^7c)JcdB-+Z06<7ClP2@=`t4OYg~u7U_JGlF01d%rd+*bwL9ro8 z)%>RJcf)N0srOtS*Nal;{=3uZY-H%l{NQWiGG%?!yqU~vsK<2CYhVymGii|}s^t{= zJ8Si(lbR1l%3&7v*eqj5f7`vU<8>C1TYdd0g5cQGHBLb3++KM7FsHA7U)hF6IyFN~lmHbi@9WK-9=}HN(Qiz5xK-4~W<_@UV50WqP^%T* z^a5QA)KNw6Vb3Kb38qgZchHw;)H9qzcMhMK zi<+Mq?nbNF7j9=$kKqgYEwHcIUnA{by9yk%3nSRI;n8XQmgz=_)GQ2}Z(I9Rd~v!I zEA!~)Ywd)b?eIzC;2gV@16pkA)7;*}dGLB4C#ps_?wlSx&GWo;#XO!W)Y9)bj~8;T71KC2bZL;6!_W z|KY(u6a1PwznyuZx6NgUfVW|zv7=Ucb3d(rX{J{*FFAj#E$@TTp&<8Hcon`^$V`cf zWm_1bfWb!9w88tk`BuQeJ;O)opoI zpRB}e+I;lVwT|c9TGPM19oS5wFgp50D_1QsS6s`42`@hfVKiC7Qw~eKsAz3R%|6_C zkr8;7*|!s|r;E-N)r4>2G^kIcXDmf{9FK);I65tp*9~W zGdEEtMWlMKBG*C7lg{T2Q>+oXh50ypnbN^VZ(yVBu#S@Kpu}DK0j`_H7TTZXJr;lc zg?sB7*?ZM}sX-4}Lwl(`;COd)d)Juu*<@K#_GJ%5w>>NWmVk>;)WKD$!8epTanpzY zC-zb@vTN{rIGEg2nZ*rhW#|L3^+vJQ6zSYao3geX7Vdjwa_MeVKjMJPuE31%6a^+<9=>wm0 zlHEB<4TmQH8kKT$$}FmQre7jZS16or0+p-I? z8Ex;L4$mL`fJ?LQ4_}G-X{a4@0HJ12e0v9VF#eLMtdS@$u61jmi!&c%GS6SFENdiA z1ejINs<|-h;4B}=vO%M`Hj{%+$4^JD@{*w?4ZG7B^94Xsj_SrX6ZQwccU3XKxakTfY5kTs; z!%bLAvQ+5NWXWj{3%XkvnZq2h$HLw}GMPVc{D#POv-@0nJa)SfE&RYQFbq&bc122k zAexw6PgNY3?v9Te+uvP%w(0lykf|m!Ym%4@*v61!uZb-x@O!W)TNsAcvJMacOU zcj9*JDBW_0rKMi6HMlGNY=EJScem4Q>M>Q`LM!t^j#&Pg&e)(!JgUhV>~O21vo>0Z z1(%7`F*4VEubAs&@<3a|#&!K`i03BSET8<7)zp47eU{{KO>#%K^=-0FzGrq(;Yrn+X5exJopQsL!3jM` z(vO7ClMXFu<`eVQV-b_VgHo~fRdVOMX8e!kNi8a3A=0z-^`O?vxSiIzAJ@iuMB5h^ z3~So>K@Tmi3HMYzu1}b;_HX>tvX|r`A5Mzp+BbnxZ^p@23+tpCOGz|7xI14#aw>Qw ztP}(&JD&9b+c}MB8`cBi{uAbY=H>x${8^rRm z2pBJTMyug!v+`kOX_;vLsU%sn^L>%NM&XXNE4q((I;S{0vJ=wG^#$?;0x*w*YLR_= z6+2N4{ypMl81g{ocygLCOnyVg;H@lriH0SnoigE#Bms zNu@SMNM>QK)AzoM6){b!i#1BYN2Oi=kvloJZ|Egy3W}@f*hN?4F@I=yv!zDTr9pe< zy%IScsTLNl!w2_@-rudqW~QR`)O-}&E{TBhvZqqxHT;ca?mCq(qdm{g;gVQa=c5^W zc|?wjn;TXVx_#;g2B&yaBN1hyH@hml%^CHbCgt|#f$vW5ep!DxDEsYZ0n8yLV}wFy zCV3|8JsP5>=k}~u*puC}si70zqDaQ5?_KV6ZB|6p<_R5N!rLUe>9oxmmf`fRTyTbk zMk1Rwb3xh_gI7$^5s$ezO2$0DytN+YXor{9U5UClml<2nMth6d^7+^4AysO4Wgp$* z@T8U^FOBDY(|c6r8ee6_d1=OU%SUvyq)OCU=*8j7P3we^25P{{FVp_q1YpOKrUzUr z?tA~3>S*6;dq7YgfR@?g*owKSVIjmkIZI%Z4ka$jj2uj-PGomk5$IwUoV?V3tEn-+ z^u^TGb^Ah5`Oe)oIGLa7D5NHKXGw~W)wbXV*M)(7Oi}w3w;IYl4<8L7tUa|?OrZrQ z^Zw|w;TSl3P^}uJ_US#Ut#`xswyCw@2#*{Jg{p*lm#RwSECgF^E4r!y^G`Ly=FlT$|9OzT9SoaE}#|7nP+fC7nC%?V+ln0bsb;Fvv@nm;b{Y^Kq zC(|xv_v4&fpFT=}ZR^QE=750zFLSUP7Ws{szVWFFv_F!t7kaD3ok!WHLr6jwGl`>p zurBNTn6;d81I{mHZH!GxxpoK*Ow@#?q@*O@crU)zyOb&*{>!0siCWyrgR?odQ*54= zyhDMS_sP;?zWn`gXezH{6?Xl|(80H!Uv;#b{tK_V`#1!&?>18?EW?5#zU4siNv(V3sS~H1ndDyex{~ z^>S*Um~ko-S>~P-o15x2mD4jOH*>mxy?@hBK6vU_{10!U*l6~WePwJQ1e;b)2TlRu z9`YZ20iHx5pn8WWf8CS^cW`KDZptjER40LJ4S%BvG%y~TUG4P zX-TLZhWfdz&fRDU-kCYprNnaw@pvSDUW@mmgY>s-ThmcQs{KBRse%qgz0`nOTa=Re-`AgbvwJFIS=%2)hLcnA0`o&* z)t7iZ1=XYS>l{TiUsS~NJ*O5B+5ETmEW5@BCwTjzBqN=5#kQ5}p4G|kyM17VW3O>X zEH{l#4%{8)7@e0dQMNwZx(;c%T#lPd#^p~pfXoW_^s2L!F$5cye6ADv#@mxGq*ir-q^{JNT}W0ovy*PzFj@trkyEAl zQ3Og!g;H}??!qNE-?j!^c5sXxM6m<`l!YfnQlY{5YAOdfXAWe7gs=txNC&Qza`{LZnGa<%$Bh$Wi0Q2`M)5kjx{xqu-)A<@)@$Pg8;MB% z94$a&xGUPU&E;}z$`z7C8c7)D?d0S!Zs0Zzoy&@@8q~7oyS&{i~M=Q=1jOJyc2mL{s%D%ns{RV!#kngDB?m>Gq>J^-!rSOZ|FHG<0 zsrIz9(haZRk55Ugu_%o$hmG0-fz_e4v^!5E8*0_Tw&FMuN+#;m#K+3q6PhmN+#@VYA{Gghfa@Fj2-KQ-Ke2NK!MOE{X z*!W3=5M9SP6O*5KYVrYyRR*c9tvQpZicqqA{XQI1U?HtWM= zUw}sy6S${3mTad-eaST_dYNnTNOTFhLE$vy8&G}or;w(Kv% zs_NVtPDM&W%b8&^P(LKJK#>;92ja0Bk{T>^80?lCgQJ^z1=qz@+)g{5nI$?_uzgP9 zSy(=jV(mrl6?({Oo0}@rZ+~igD^VNYff$HGBgyZbu*ut83n_#*)V#3a2y%+~q%Mj1 ztBEiQ3ekNbh5uFa;Y3Se5rG*+(TPOO|BL+Ruy*+-il5ukyO5S2jsEiTf_K2qb{fp{~Jy&J!I=P1gl zs20af47gXOgN!OLjK5&6G^0DF`=1JuKa3$M%F^4WwJ@JM=7sjj(UcFR(D!)ref&PO>QVM$lqxyayC+S9L}c>kUQ${y4+L*v zKR6TgVsynpA~+i3hYcZLjc-*^D@8;FiR#D*qKJU|!owJ_SSW}6yw;!GZ+-dLuu<~nV2>W*+Q`8y{%y0+HOo%#*9G<7So4zsTu z;>d)@619l*#ktTKo^lo{HHNk2*QncA&TFeB?9-+_%wgy~lLd~pNexD!S))W;M%ROX z63Co9lehyX%K zarBUNB`Ax`&Z}MR`WtPaGj*C`6+8!Sj`1S7jFrk_GI_u^O+-3F+>Su{{Y%GJkk2wD zy|k5JGrkiZCLtAEzFxz>96(cSp89zAYN_#P#p<;TfV&mu5xGz`0R!3Xz0;RkeOQ@Y zU90{^rX`>$gVkK)o^Y0aQ*xqnCy851wmRXgl(exryKh8G4F2Er9@Vs%3-}6Fxuo?0 zfp+r&3&YU1>a+{*vPs-tT!@n{{6||w;Qg_FuTazPnIx7XB*DF{(K$g6xmpC0=;-)? zggNR|_{;9eGcJMgG%OB`uc&xa_I8x1Ciwc7!6XEAG#UYQd+1kRNQQt?7Ay;AT#-r7 zkq(esiT_<>;(HMWe8rfw&FKfzSw}UjZFXVM>+7|%datyiHM@2i)7AnXv|QscIGXej z4$)stk4PVCE7wnP)>pOaLc3D)6dA|gCz*Z6OtzjHGVJ|&BYN#r9UYO8mGTQvDODZSjWoj`i7nu-q zciqd=2wAw!#k+?pMwI}$b^C1H81>{U2iKDobuoATZUx`Ip69Ha>DlhZbEEI+C$~pS z%zP_N5yp-FJF?#wKb)M9ObUU2Gblt#qa*$+ZwLBhuxl($YdrIpBFI1%8zs|?~bXS)Mfw}e~88NNn9S8PpWWq zAYSuer@_uKf>q2Uombj+)~!mKbHGw6+yl@_;nM^A{Se))!mOCpitpVJ%YO49o`naT z-g)4H*jg5mnmLK9-V%dUa}v-iSQ6Yxtw+RrhK@v--kdZ{8o$6JiTX#WRiA#)ZZTQ zHB|V%!Gdh0dtw0|P*xMeyxnPD4^$kCvX5+UWwOr7C9&G1!P+KOl3}o&h6=#?hyUIA za>%`7FBRWRH~R2F73ZwmohRL~L07SQ*oOh`WjS}=O|>f8A?MiPsox?thbgtfUh)uM zOCo`9W3G+?w(!lwWU`Ji8Kc~Dx-NI~(K##k!#Z7JP=Ti4GK-GMW{2Agf`#y0%Vk9K zV_7V@)cPl@R%^#dS)}H#OlvZOO&X4Kq;26c^ywVsbQpIFnwTh#*aXv*pt{%>+E#YqXS;Jm60ASgiGETh-psseNn;ZKcD+7!lFanJhCJ|LkKn6CB;O}$U=S3>UQPz- z6)bzQ%L$3?HK{pk#=p6X#wK;GBAVeC<2Blb@P359mil_fgWnETvR(Am@Ljgh*Ehty?7&^d;EmAdh+=5 zEWE^ql*Z@qx} ztITGvEG~#b4EQ=bY=o!*33?Ol6a>5{hlGjdB!NDh0%e|Pxm8(gT!HtxIW5h z`X<+IlreTsBq|O^R4Z!=k!F)?fxz*O=i^~p(4?`iA|{I+fnyyyvnxxujQR)YkDQ*y zWi_3XJPTf4;}o}-g0w&#Yp2UH=~!K*_jq^UYie+cMeFFdqeHdT(UpMKi@?$G4w%Yp?nW-fJN)>j2BUe+OdFK8bWG=jGKns)kLAAoG+A|O4TVY8 z2o^UnQ1kTO&6DdE8)s^LI5Kw`mA3rwN&aKMomb&?Ki{`o#!7TffJNY$b?N7Vm15E; zz(1a@Z$&gj=veSP7s;6_dDwUVO%NbC&Qe>e#2lkIUVc`-FvuK{lBjOvGHGd`JuFIS zjSmiu#Of+MmK!QXkGHTu!8dMIT5$H-cUX^C8-DVesMzL~EXvyWG?lh%0BNjw-%CcX z-hqO5fH2O^8MbVdgDZRo^=Vn%)7*+y>r~c&vgeiS zmp2hZD&i%KBq1@vBwaY+N%!N-KakdC^qp`6VD+Isr*JQhN5GqVAYnIj_?2i?!3&tS2*N zkaqu*@rv|>KlZWMzTWKDPbXt_uG&s#-U94(=CwRw%C;TpXTBRfS%<*f2awprZ&kX_ zLAV^scto(fre{aaRtnOe(zx&=>uUdU#Wm7+ z?yxbs+yV4Ne1rU-jp;4%-jRPim$gpkW2J~>$UH$;WOTi^l`FX~g+DxW2@+O`i-;o& zX9~vLufzou7`}WbIc5v<4-8GQRI6HBcaeIqn`yl-NYkANDIJ&-N0nabW0MQghSVil z&U1g>PXdyoC-UBv4M1bNSCh93z~(QpB!@Vv%oRQ&Q|;KNZ#S=Z31BYuhLKHM?VlIs?uQs zpQHkhE_0R#({vWCp7FmLo~$Xci8`)ow)_NvAbBuUB>LiC%VBl4zw=3d_8^U{YS0+` z>OPosEy#bEX5&81QZ6b?1=jyuLRGnLla@*mVy=(7kwrgGIl+hw2z~SzM?`&Esb8#7 z6;?$@R~1%NK^Lz<^55ZJG>2|P(B2fcW&QCl5I4#fu&;*wSx=KI2hEH3Z)P@VJ^Xse8Df&(A|qgQnTyY1 zYl*rkX_opTq-R9g$oL3Xs!5?UG~AqMRZl)0VJ~iCCOA&ig@WQ zE5Wc3RN2Wig-j?7)ms>3nzgIkgXiBWmjL?7e*UY)6~k-|@FtNm(L#VoBKy!>w?2H7 z3d{1Yqa_plBdCy^JUj_mEv|-v$Jk;iKwp>ZT>j)dyx0K7QY0$;{_}|{tbvr~O|0DQ6H}1eB`oT@8R-cbLvCLS zVdXjfawvi7ju?O~@@F@Sre;K9SXJI(?ZoA7u7s87`Vgk=`#Q`olUU%5cMD-}SaN^F zG^Eka=`Sw#k;;ofGKu>M)lAewl1es#LyCvrd!J4-(9bRNZ(gU6j~#{hPeDTY8R)~T zdDNB9aGeA0wfZs5!2E^Aa-hH<&cZOZ!^`c1aAnJVtgvl9nPkC5XQ!M9bQbBShw9e_ z7E!(gR|k>E!c)FbP(+Mb<@hGMblnW9`RGV%)^$5#SL&4ASdbtl41ef`xWercCF|uV$5?`*k zXd?}5|_~CXs<#ila-t84OwDrIL$b1apC*IASxH0Fjno1@G_K)?|3a3#>DX8 z!0BMw+zG0K)C>zUf8gcp?@N)z!=?{odl-H=mhY2pOGd>C2c%*bb|I^I)7A6<0$H!4 z!zaF<6(d~M=YdI&-LDp#{`51#RclG!Mo}v#8utQ-`}MsH4y)E)V1V^ngI}7v@WdGQ zg^^${Jolhjq`xfn$g0HG1S6|2Ay@ipDk*3x2;Y)HxkJMD3N1PZRjx+*ac5SGQI0Kf zg3Li2SDh*{8vJs5PEdmGrXdfw0ztB;Uia{t0ZX254-L&KPIuqZhYqT5 zO!H|^g$7iPKjqLlVUg)dN&><+`rp5O03jn*0F%Uv8>FVW%J6`@o30vI zmn1QQ{@hL-OzzDTb*zc!N=UxgDOgtZS${ot6Wfp~)Dg@;^I%uh&HMgKue;OsiN7j= zyrcFaqGp!x?~JWtng>Q3j>L;TmG0@Nsm%2o$vLgD7C6qfAU)cEb?f6sketOMD~JUU z$bmMI1SNbJ6!{(1gc^t~7G78OZfj?c4;QHuBNN&W){zRP4`H*W%FL{!vTd--7=EJa zFp}Y-A2^j|JJ9@tif;~lR zL_jVNNfc!SxkPde#k~BBl+${2B8hZr5sbJve62g2ve!!t{dX~O+!*9tIGX5eGz``q z87IoA%J18A;`PC}amCqNVp3Ez7F`j2Fc2#0eGa5i+S`3*6kx(CViwl_A7O796ld3U z4H5zg8r(HF!QI{6-QC??g1ZHm;1CGz?rx1X?(W(^Bg6gN&pS0!^?ozIsHU%~?sNK_ zEo-m6_9rt9;s_lO*@i}6eVlYm0RDHG$SBrY&L>_%l+gwf4Ohxc(Iuu3M-^|~MDhC) zM6E>8sEDdORE$5oc0bTH)}oI;_mTbq7AVQ`SpJAY6>?Ff3n3@w0+MLkE=83Ta@ovs z0$787lg@|m3dibUE!fNGkU2r|$kmgYB2)xO%pdjj|0(s8o#tli)0Y@}wpy`L*=V{J z{pB?af!Kcq!&*XfhOJ!u;i>)>Aj1TfobNbrhe``FS+-*b;VLq+c21^I3^KRGQsE0R zq6^wY6>aU0#|NP1B?HX`9&h6XP=S>pk}7O{)ai!BB^QB;N}+KZK?Y+q`L2-Kq;hnr z8E{0;=N08gCe-*?=XUVqX&d0XLVh~6+OEi#WW%$fwOr9ho9L!h*4UR|TKAO7;=VW3 z*%(pxUCi0Hb19$(T5OKqyuMv5D9-PKtnYVi7ZbUL^z!R+tGVde+QnB^L;;V=>o8SVnY^GQ?-Gt(1wh)epki+G=OR4Di;mMibBaO{2P9|Ds(4` zG*=RfZFyhq1UDrP<-YaEk(n7*nKA%6 zABEl+Dn#WxHjN#HC?j&aP69_#e{2NP1U3)Br%?)tv4Dg%sRA^;Vx z$WmmPybgDP^6k{gS?1o39?xhbM7DXa$Mx`jyWJ#Sltm68QlvCEeNv8&y zI9L>anU7*tTMD++sm|sqb5vhlb-tUcb;9<(a-PP0$ljiU zcv-twVqqq^eYrhy)yFHd-iMa_)98`)u${-qbzN^@yo=asj-#I{N23DL!;ZKvZ0UJ# zVZLCMpp=&#q2V6y8ILdP~OKW*Q7Sl8bU&kwaoYe=OM4Ysop$LAjkP!LTA&iduHCl6q zX0VVWRd=)<-q3&p+e39|Awh~?VV7Kg7}8>q&dL!<)l61DUTTeN-w@EDx(`am8w@mJU6HIF=6Ob+_$oI6p!g$|juijaHWXJDxg*7Mu30g z^%|5#gFglT*(quKu>RJ^jEkbc89+7_sz<)=C42(QcaZp0M7mvePRg&xzY&w^ZoI~G zB0VTpm14Gfi9@l55ps6UP&wv_tR+GA5-*&PK}H__;AHH$*NbghQM{OvaZWVn>1S5y zY)KPuQ5=cvIubW+&8&eQeXxY5K1@NMifdu)cHPU(@E%+jkn2^hvN5Lde6cVk;K9+OGcL==~z#suK(nMEKPT+R^E~c zWJY1YAV%`6=j#;B<0aBU`tl$?qq{&RZgN%})U3;q-%G2k-z_y^_cbj0mtm&`KP<1yKzKORw?y{ z{Y$oGw+;%{R~GSkU73z&kG9pSA6|>lAd->h;Pwbn1}1{n-+6%`MnPj$_qlN2Li*3e zbop(5o=?dLXP{NH{&ZOqmY3tu*Gj9izCfj298(~?3iu2!((mXpP=6C}uFLBY=0enxf#+)>s||zA_ifAfhbFZNxAm^GR&`mPff7|N zKc)Le1*Q3e)qvr6at=4zH$`o!xev(`AEGZh((E(yonO~6LhXNsC*=2T`GE_5gx($5 z*VMY!^OP?vbxl$QxwU^yJOCh_LW;`fRL`Ckn@MbP6rpKY7^zQHjttO^$rnx7uldzonk zio_F2H9^2IyUv<^@gFfE=J+2j00?e+njv3)3E1^ar=PnykiIJc7<~Iel8`7R2_ySA zk)oeiS7)rQ^?kSEeDo{!>FHa4WdN;N|2Kj{a|{{|L4P6ojn12uhkT+eKKl{;zUiuF zgA$@_iSk`jgEy&uC9Y@7xO0T2lwlFm5();}!4~$4u7fe)9u)a$i*7=Vp+5 zC)V=b_8G25vOkc*nz&2#U&^OmrS{!Oub_noQ0;tN^h0xKB&PlEF8-J}DKcFJKR-Ui z*u5op5&3A6^*z_ec{7)AF# zyY^6!!wkiL+E-n&`k*niZQgro}jZ5_0ZgCKkJD}j4dK+JQ zc&+D#{Wm{p0bYOm{1?v}!e;|N(nO?W&x5Ad+?ig#e#%djkl&x@Sdf9j%R52}9ZJJSJ&#;q#gX-rAxEyUgKc@75LRp}mNALpRT1mod z`V`j;zd?mj@`|NDxb^VKmI_}7pwzm7=(A7H-a<$*)7772tlpd55c{Jbg$%LPrPE#nVLRN)%-oF%afr&*D zawW)e4x8oUi+vG-_IAmoU~cQa0Xy%(O2f=Gv(6#feXr&C8%onbF|C5!WA z3!mS!JN3>e+)41Yl=sRGRj1)k=h2c*&3h{ zn#mCh!*^X)7oxbV*#*C4@$KO1|x?TjsAqP(5A{%K|d2doXc<-X;ahc=4nTDMv z%KVNJ^F++WHN4B+DzA8b8!iPth7;1o;AD({y*11VQ+oc1(0dn)B&7FgjdTX%Sowm~CXnUbjgTHy@ z-w)(+C#b$e)>kL}T5ij!8!vcQ(wZ=zPJbdIiU}Yx;2Xv{0c3YO#XVP10yQ9C7kHCa zryL|w<&I>hLr3pIrKOkLHUuTb%vCT16%FX%4I2 zAonmj6UfPKU1A~w#KtzY-+$_Lc#W6A|BCt6X3FD7sXMfeq}vY=lde}zCO-z38DxEm z`AZAFG#>Mbv$!!@k3^Kd+Rjt`UWe^JMbxry;Rn2W6G*@B(ObK|TCI!^Fj`uwbu{7< z8HyoO7&RT(?l056L$O&WXGW+&AU0&Q>F-05m{>PPL^dJn}prUYcA<$MvCh z4%h!=-{Y((b8*PWg3!Q@PsLq~$R-}iud$siZGe&6(C93u+kw?b0r(j#-ouC(^8JkJ zi;XZ1BVe-3k+^65dPtiS$m7)r^|@ai7GY4334;S}$TEcnpOqPyAzSGgF@6K5u%q` z6KshrdL9YK1b}UCcWbTRZ>CJ@TW`XlU6q@Q8>n6BZv|1K@hqvTIji=ny!8qBT=Ii0Vi0pUQ)7J+mR@pt4c05sOog; zQ1&Hi7sANcP^`$4`(wa2lkWEkMg$KDX1V%vW(0)w^7O+?6vXuzCfCpFGXN44lFYD@ zQ+J?H7zj&EzbJ}RX3P3QP!L4--Fy}TQ7Z3MKyIY$%N3L6fmYpoqj$CHt0bW&rTxzx zL@)C+2@^o8_D*GG&^mwDiipm1ik??M9I4rPReh^13qE_oCmcnsP4)b9{PFg!jCRU< zM6o4Sn@ z^jIou>EAdXYRzq?U7z_|7&>j$hKYx<4Ll`mvkQ;=fHt%m7V*wec}AQ0i2x*3c@2^w zlAcms_n} zAeKmA;#+cEnOi;{UoBDRIR&`i&sVp$Sb&7*bllIl1*5EqrsA~i;nFB$X~YAgnlhS+ zL`mdCGczr%tx52|%>jXuA|jvf5fSH?I5b-m5bs%8S!ZT|k|rjk+a-*0a`N5rO-8ou zClB|H=}bE>JWZR+DBSV}GbZ5Q?K~gaX3JnE8!Ec;(!(Q>W0j`V*^<~4TYA&?{^#Vb zaY zQdiM}hVBF>6hlbUf6aCzd;M%x6mG1*#{NMRNb1G+*${nT0H=rIb>`44w>5uQT*tT- z20RSOQy1FOnDJs$04nC+wL5HK+I&o!G*I(qfW8O{iBspP5c4`@D*769+r@ z4rd*Y>nA1oSB1M!6(k<%K})tiVPf=le*53?!|(PuxprN0)sK6&lXgo06aiVAS1ggG zzjTYARBCPgP~&W)Xc^=3yE_?53^?(dRB!KU z+6~N)Vv3TW@aT=#$t|%?e-u?%Q!h|t8!T7Tr>bP!-yc%rZ#`J7OV=4X-5h5(3gk}a z7fwF+MYi48_S$9Uu<@&tB$9>qhTUgT@jG#1ynE#mn4_5$t0AP%<~5G9QxH*9rqy{m zah1NlAdi{9+fepRx%qqUqv%t0h20W*X4d1nouqb8yfAz-i}8t*x(}~!={Dp}+jvG# z#>;{!S*YDH0Nw2MX@mHQ-k!-X{B@0x`(a@hZ`1berN(kNMr1?WqdA9eBVb4bZyDf?h<8lo!`0UJo!hLb-RQ{{{bkZVl^z`Q>KS%UKzljtcmAlJoMGxA|<4&inoCg08%FSYiFOQOtTs z9@P$T0F)wtJ^eNDS~~e4z3zGWcqqSWm;a43+2@isc0>Mc_7ms|G$#Z;v7&$7<+Y(4 z(yVTNl$DYrl1W7JYiif(45Yjm+j^0qKgd6%CpCsbW;0;9e5%%z6uEn)=+%@z>nFRw zv#UGoOooW)6$#IZDgd@0c`klef{JPv4}%Ru!LT$Y;VU>6W;)H$OpDi1UEs-&uR1mo z^GL4(qUk}TS>ne0F}8v#Sa!>~DbR7>tqMn#lZq5~)(nUe*K2`P%4Q7gp<7+a`O#)r z(+=H?c8$`UD=FKil8wBN;n?9Wp)-i~a423kP(tOU`fZ7D@ZM*Q%@GQ=;$kX*r!SCH z78&FvHc*AK8-WMVixbV3+;fudg+UzJie3B#|4DDli+*t~a$m~0PX!AB);op}V>VvC^fyIHkN;3NolBW!m z))dgt$MYKMfSF8UGDuSl^aOySn31i@uwcfZ0*s>xEDpq!~Uknxov|?@&6-@wdkD7Dhz)9g`M z?O`5#w;nHtyzK7vD)-Pz^+N-$FPrtfX72Z)6q!XOLIGMik{@s*0&#jaJ64=sL4U8 zDZDPAY>%W52FLejq?IM~zGZ1(MhH)IT`-=Mi6_O#MoK24(+aoZz+foU@TkU~7ZL ztljE%WvfYx*^q~j0=mr0r4H}rwM#al(@sov=dT68`1JB%b}lDk7bkpAYGgc2Pz*7DFx|`RpDQ);5 zn@QlYDI7-8tzxK8cn09qx;Kesmn|d(SWbR9Rb}K`a$-2u3!vmzn|P`WGNytdy?EY; zAT33lEEC)=iCVTRt@!cDhX8kuu~IKvxBc7_wO?9d)|Ik&%8^trdxTVn$7yx&8A*XV z)2fYHL0I$*h5}Kp9vXL0MSwDCNbhze(($Gtw$y%dfT#3w0M=#_>#}wmp#1wkvK68v#}iN_a_V173q(WFIp83(JMvq~l3R{o zC7=RR#c+`}?ln3R>FKJ*cOuhKu1SP+8l49t${u$bmt2fvT4rt<&m$Wo$fD$d1Ym7c z$?Q75L>dY@;0*g@^Q--`fcV@uvS zHq@GGn&zketS%IW2~SE;ZSp(RfS8MC0G8eog}1k>9#SVX%6BK6+F-eHv@6k73i$6m z5ps>zuNF4!XCc+bXVE1O4QF}m)ib&=QLXMaqAF9`X!vK*tkW8PWkzY+MDX!ME)2009nYv~Yv3~`Z zAAUgYVSq?fRm8p4>DS)5gtY#j#1La7p#JJt;U2BR?E|Z21bn4Hl(-pHZMi9>pGT;`5L^y|&?rmhwvBZcXnW2SLLEFpbc zQ+g6=TZt~{xn4B_uW@)Id@jPY%b5~W3J-&2@rv(eIQIq)BrH%IuTBPUV6bbHv$77> ze|Lv^R+5`2|6}Im6N0(!J-qnxkJu5y(Twh#-WVErBTGw;)ielznL%8xGb`9V(K_j4 z3w^9~pP|xnIda0W78*OK+POpK0s>e{k(4~@poE43(OPaU+FN$!6YoxIk#_x^K7%%& zXu>WBUhjvJp|K{hCCfPDp=H=|Up3Nw@)~Y3MZE*hkC!LtsEO_F_{&;xV)$H$-Y%n% zdvn+f{{3-_po=6)k6j~TWM>;EZ7T_T{C`$f%YOd+Q0y=$-h_=~YRo>UgCk8m^CmQB1~Z)NPAk&Z01`%xG_4kdO~kx&u~346TH#bEgPNa@=!U&?>l$OW ztaK<^5T*Q20i9HU4&VmWt5_52(-OI%Io|AYv9+Q@xW!OUxVQL0>qzd{<#LnNR|fEa zUBhh{&cuJuv@sFYRFC!W!wtvQ=Q0Vad0sUVb4;Li+_nAMC7Xqe$(v2HGD)VA{C6>A zpPc1zrAkFiO%r;*bUm3Pi#(kEd|u}EKIciWt58SbFUTXGolh5x$laYf=wpdDbTqqs=Gu8Dek{*F;AC;cl3yNkUizBq#I)ItyK?KBf8EwH=G`+9}s_IjGL$<(ou z<)}Jeo7gGd(PAjP{M23s^u1Kde}`qyaw``lx^jzpQyhzvZdU3}8cCeXXfy`NR5Dd|zL!4vMb(WyM`E_`LVPs&5qy~l5!6o{!%KtgNUy!u6m*7E$k zTjuRgF^6RH*323z~NN79QMxiu_(IbWwu_^ zXMfk%`jQY`aylP$eaL)2S?c*RLx?*+pWMv>GnhOq z8No@&?T;Og9&V9!Ej`hUSEf%Fa?pM!zIhx~^I+uXqfY_=C*CjGqn?fnTOJdwSl^y- zb9Ks%a8del8^c*7-lJ$Jim#t@G_E#@{xH=?a~Yobv$8eeXQ7>OrI%ix+jy5vq600q3VgbWqI6BVQs@27{W&6p$ad^_HMG3`6KzSF-gKq zJeE(Ik`VuL+&1*!&{a}2?T>I#hXlr~-pz72u_XB!gCOX-onV4QxqGYO>9vfBd(WsN zkT*#)4vFu5Cs}un^RqnE$}+;x{6gXV2FufQN_o*BWSa;@Ag)v zY>us|+CMt&kb~*qQE9}??1Q{KWz25BlU2P~Vj{&+-=iNph1P3V=bZ7|`n>5)sn)&k zi&aQKH-`C6bu$y_XNfr^qTp|th@FnTv5(T+KR(I|5l8C7+)?3V6mcxfDL%0oXygvO zNu;p+-oZPtb=c~Wan0brj3K1p~G*xjhV885NDRhmqI zWM216ELWU~__;{@3qCVT+pNrAlh6pYfXRrkgN@`V z@O>AOP76=%0XoU3$DG6o-G~`LF6jtlwy#AzfrJH>Q zX||=8A>%FJARErMvZsn@rLJI)LqF+dc^KB^;~8?1?;TCSBSUuSJggH0xZY-;5e*zl z{KasN7aDcn$0715)?eFwHXax;c$5_l{_Cer$zqGVplL&LdLw}_{(F&~fBHi~t#1_Xe%feNfQ@>d}i(jr~fuCWm z+-hAVGFovA)Xix5oispEVFp| zc#%^O&r>8VV#i1~srS8!tt*!IjNK?9k6L&6U$~Y37#|#9m1==c{$D|2Z{i*Yne*5QEn8M7m%;o0?iKF52`=p+l>wZW|g!Ee0h-SthglP5GLDL`?Qm{BFeu4am zL||nJq4NS-V(hx^6BrG;K;Kut<&ISkJ&+*^PMY@sNpVt71I!CF=yzLJFL%1FN}cXo zNk`S3al9@Xvvc1BF1lX1Qq?X7mbyRWif@hoS3EJPB~E{=&P?Zs+Z+D-RsMuRR{d@m#o}gd z<1s)7%$?ejVrjV^O9oQ@)uM(4AbNTz5mHONiAc zs70E5Emvc`nFgKC@KCk<8)1xY#K(O2J!Yt59312(QCyPMkaGB{fJ3g)sfqcuPxLFtFw_cc}A|nb-xFg@Ur|qnW}H~B>ZwUM%tn1 zJMr6o>VLQZh=+VvtQKr1^K=0jPX_p7>11-!zSXxHsoYz#7oE4P-aJ|S1dd1n0t5}! zxd*cXQ-v$#Y5INtEATWc8W=cY3S4I4+I7x(mc-=>vh%&2+U30$a?>5tpz!;~n~owf ztCh`_cG*8FLGC`O&WMK)yzY!F1g-iBS~1Y3p1g*^{hl3sYGl#mej{Vz4v21-LQsF5F3#D)1$V{Ic-4xNAZm|RU2*y(f@ z4l>zGtVrDX<3HxNoLT<^!$6`;kO&AQJE}qRkWlYRI|prH0s|~kXQ|Utm4DW>`4UbE0}rtfvZid=hbZU zt2&pMtTh`ON?sp6rsc@T|I;EA4XP@bYf-VTllrGGh??mKpp!n->{E%l!otDg1>iqT zKp&$yPuy%Bh9|wYt$*IK+mR~HKo`qEFss7A$+GC9GXt`4A8*37OS+M_qi1s%RQ?<5 zlYhke|H(`DBqqBBeo_;uG8#36t&m5QNIdSG5=7L9cNf5^LXX)X^6RV)oaW(l+>={6 z6a1U8k+nm7BsBl=qEAY5MZFmT04`H|ay=HKeR1H?7v2(L)c=0Y0Odz;_@5_l3WpiD z=iJ&?;r=zX0`7a5YybG+f8YM+j}OHEJp9kAB>7NjDymZF4}L%d{_;Oh9;3s2JH(zc zEAw@g@^^(Ap}CL&(Ykry%^F3#Y-hjR<~b{FZ!m2vR9@WCT}an7qNT9bwI3wsk9ec8H%xrAI}`ABU#EpXk&deLQ<6{DaF6$$0HLnQOnO z5QmL8Eyw?bf_9ujaY85@kwk<4kcZ0(-I+Jsk!s!8pC=RLjuhR)JtEH;@{lErzTrW7 z#S7UK341iX)&z&g93h;*7Xo}Yvhq}0kCn7x)3TqJZi3Js&GHcZpn#e>Tfx03Ar zZmGXE`-NaIg}T&vVyiR16T>^tm5zmhGkfu1uHY0!_i;F5BHN1+#D8OP!DZ8}M`cZQ z$Nwb$#_EUf)xOqBptWdUA~I~n{WY66QGb)(^oiXCsb=`APbS|-gWALfgS8Bq51KVU z>C_8d8sV?0OpHw>dU5AI2LI8a?#(Ga7a71y?rwXBeN5|-IX$pCSb=|4^g&^2%fcql z?7#rCR_&`OCRm{}h-L`B>-7w8Y&o7~nlqgG{T0ol;{_4%ZwJSjsuq1Pec{wroy&=r z>l0Gtj86M+(`m}^{IyBdzDh$>1(n!HVfHkp*w&ZCdseThZ?`;eg4`%4QYAL$8C2-; z0%^tat9Ji-L+W$^*L=TyEnvC* zqlTuzH(u@0ER@8D3$WuYjkDQWUrnof9e!&`kbDsS=Qg=g#K5@WVOx+%=srlp2lvu-)RKpqwU%NsUxbL1y*(3hM+Mr&H9~2b@7{Vb4?eZs#f3+f9polmVX*s&6X!X2tt^yxw z@08|96GLQ%0T!Xf0)H2v0d&eUP)Xz#U?f&LqL4mPbBMpxvn5D=%WEVQY%&HrLB+rC zxN_PrxU&`Vd;$ahZVo0pZN4eh{8{YtlBVwj9ZUFW@6-f^yrKN~H2AyN*mrl8@L&GD z$Z#mCH05u$+4;)2FR}NrcD{_E{tp(GQA6K|RaNr~OShE$t=6Y!%mbSE&k8TzE?{BD zZz8+d@0HXGd`j}1wq3F*=8#tHtC2DU!mCqd=8z=K!`=#7u~{6-W_P5&;fQ~kX_dXkZ$ApzcpgrlX2}_o z=Ok-y6vR=-9OQK@^x5QY9qJAY-a!jmz^7eNRb+LJP~%TU>P~V#d`}a%*6Img8|Fz_ zJuCs_R6Idb0`d@8yAD|FO}=f`Qy%#p#1UzBqKF@u`^@tE?{xAn4mGj=M@BPt;L4?_ zxvo1d>GK{6D)&8}#?_)reR^ld%0r9r^uGgXsf31zb{w7c>ht{?U7e%9v;pRUFqL zJ6)x#A4`+Z=#!-Z|0n)>#_v1Ro!Dl^VuO$DDI=XFcS{j*;vC*{uMJD~EpJUM*kzDMw!XLY_5pv%xHnXeEr`end8B^tG5MavW_!Od z!in%`c3)lczD@X!k#sR^nPkJYJzudvkR%78(eIu}eVuD3&S`Vd-`@6lpZu{Z)OB)k znw!u<8}LC@C)%B-0PxK%wOp~uqb+BP`fzESamT3);dKOnNUV+Z>y?cZ4nZI8gM(NF zk~~R1mbg=b;if+esJXDR1Z$UrW;|ep<#;ln(t6c^Qfm`P6?%8^OWf{By-#pRD4EN5 z{IsK^AX~9|qoE7~y6M zh9IhmAo;etg^9Jm>J^K{`Ln$x9W+`j1HZ`tBksZ-@5Jfi8OAhW2&ZsMVAS_uDx5Gn zsXO&O#fgH0F|J)xp2*yZ!po;86ANznXPmk2`(Ik^yG|OxAAA&!_xHp3(>bGeE+tOK ze(& zwxD5w$%8$aX=79I`CvisZ>@T>yt`5f|6w?nb^i}Yvc0b1c)>7z@PgyP;-iKr7Yn$) z7(R&mF6?DSx(>!jrXyy&v%Sc;O%mf!DR7W&@|~6tQ*e><1a#d zmhy-}2inpSCWH(vQ}4}6QA~-K2ORyE)1TR!mom9NZzex7`8Zb%Qb+J6Bg64%pEsGC zvOb0ZQ@E@g<=%)dmZLyfZqaOr?Yo9GdCHUCtJdZGQSVyDa^1!kzh!c`LRy@Sm@;3y zX1y(MH2C3yGXz_7&FWAa#2Bl3jdg&sBN#_wleSQ82PrbW-D~ka=ECq6Tk61*dsPvC zGn+P*$s-gdpE=QUslWa7L_U9vyQDmP=hHo#%<*JwNrW;+u#wX;4wDaQZqGIinz1&Wy&7B;AyZPpY!#70Jo z9TecZPan1Qas2uWWk$bbwt;@P7tGjQAv3dLcQ_cR7AFv{BeJL3iw}1P+_}9QEefR8 z8HL9w)kpopBkm?%>iU{tx&7tjwErkwwC{d?X=!c-`&!iR=_>wcs0HntxDIa?mw`GO z;B@(J)TRR@QfGh#u95iv!s`4QHBkK*`&d~;8CgK30Z;I}%ofx(9F@E6YQ^*1;yC{| zB{^D6J~8jrn~B_HSQz}~mYZ9jJ$a9EviOpwfu%lYGEenfbQ;Q!^Wv6|&HPlmc; z4pyUbFDo{|AmNZ-1#Rh{Q)ZwQdA(U;7q3DDk`>tPTjIR8sNZ~mL~bqQx=$yD*9S+8 zrBh|9F3+{Tk|v(fjkr=q7S)rqF`i+iF()>+g6`QGu<3HH@RJEPr}oPobe?O1>;pey zCtqfh=C08Rxi8SY$bB;=DsmIvm;z4FH2SorS4`bIJ3QtI{5O4WFJ)MSM(lf*P^r_kJcLJ zmnC61E-x1p`gF&RiH}Ydh|-*t7}>aWe}M{~D6va%Snf-WCHHN=b9;NaSwhcoWDYln zMTU!l45L<5Ti|POBVw}HSegeW z+(N8A%o%nteZJFqJWV>k%W=f<5O744z*_!1lQu~ZuB9bxWy0b8a5VgzU+2mH8p-y8 zDdBBB>WF@fijHGtKd*@N{Pj_yY>`V`f>TD*>{wh@4}rARX?uKHKSujc^LgKM@gJfR z=d-hKE4vomZVIjnk{IWfb3mt)-P~gf-AA0#(Ut7qouX+k zs{w&9<`vqQJt@gf&h@lV{u>Hg+~IfIpbNWF+^D5DnkyEZd|?8K>>@c(?N1>ifE2eR zDUJl9Fse>2w#Y#4Y5h(b@RM1Q&%b@%xj)y6Vjm7ld`Tnm|+G2J# zpHCcef}WHI!HdDr4J(bbUguJ&9i0*Igl$2vyHOberRe=1TOQFAP!|lnqY*pN$ ztSUNi?FzN5FngAsZ)gNb}p1;&r3;Z5bYs%O5 z$_T|m95G)Il;`UqOm4+@8h@!@?;fVi$qRtL@B8)31QzJ8=lhcHOX)IZIIYkpRr*wb zLR6QN>wKc)eM+jGwoS52?)=MUPJwyOl25w!&vl_M?gF4#RX&vm$#=~%uFsr%J&fWI0i+&$*r$9B-F?Xtu7alDjK&o#RTOe`x&i6cq~NKdEpOESNCGz=_!_eQ=qoN(nKK zoRN`Xx_@taH4^z^e$vb8`}}>}BV90^VH2ES_f=tE!^9`)%WrcQ!?Q$$@z@ePRa);f zh5_uUmwn5qStu%;JI*W3IQ(p*h4spkIyg9Q(07?nDZ1Y{wwOmc zjGgc|cqR00IJ}{JvCqc*KM);+N53~M4A|z3|kRd zSO^gc=F8_YX=eDSdBqRug=s+eY^g0>u^b-tvhK)R_&uecN~GK7)qWlsQb1Uu6w%s2*SX%^H3QVf`c$D9wC$Ue@STUgem7x47)+FQj_=^0vI*u1gv$ebLv(SopN%VR3EiF9%zS zzKFlEC(@%SW%+jJrH*Fx6yzN-r^e{KolD}J@@=xYnTRrV96@?rXD=5XJJNUx^2vwX zjYxqz{eKHfuh!BJy&)SpO_En&ANi<7kG&YZY_`RJ}e8aVu&eEkfsx_dQG zohZIFbP5JJ#sN|hGsHy8Lsxp)ts;E8IDn8~gWJ8&v_P3Wv|Aiu_G zdfD#LKDQqv#p2Ohfgg<)zg9-R4f8L+-K*gWtKOSW$YaDYJ zgOvNtWQae4T-enc<eN+&>5Vov9f!Yeip2 z(*UbnDe9c5Vq+8s@+`NHDhkY}b4uty1gi3It@b-&osnMa*quYO3M;M$F@F0r(Wt=3 zdm7DJI|$IjMbDU)&IoF7gPlN46PR;o(nd}^E_C4UxK<}!(&}3(=Fc7Ry?&KrffZ@@ zph(;>*SM;z{yu@XH?nHNjPpX|c^~FWBzslgd}WIj=8OKZQHG)GF;Fdc-pp!?BYo-& zPge3>zcRBJKj4r-x!HkW>MV*{7h=(jD90G9vH75$jg^Qm6uR@K1{=e;%O_f+n_Ik# z9<0gLXw9A%%0D~R-9iM`D@2C2@hG{&qG`X4%@E&Bb$G+zA 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 diff --git a/config/config.go b/config/config.go deleted file mode 100644 index b3de936..0000000 --- a/config/config.go +++ /dev/null @@ -1,401 +0,0 @@ -// 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"` - - 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") -} diff --git a/config/doc.go b/config/doc.go deleted file mode 100644 index e570e0d..0000000 --- a/config/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package config contains the wrappers for gomuks configurations and sessions. -package config diff --git a/config/keybindings.yaml b/config/keybindings.yaml deleted file mode 100644 index 3e6cf32..0000000 --- a/config/keybindings.yaml +++ /dev/null @@ -1,42 +0,0 @@ -main: - '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 diff --git a/deb/DEBIAN/control b/deb/DEBIAN/control deleted file mode 100644 index 2bfcbc2..0000000 --- a/deb/DEBIAN/control +++ /dev/null @@ -1,7 +0,0 @@ -Package: gomuks -Version: 0.3.1-1 -Section: net -Priority: optional -Architecture: amd64 -Maintainer: Tulir Asokan -Description: A terminal based Matrix client written in Go. diff --git a/debug/debug.go b/debug/debug.go deleted file mode 100644 index f350124..0000000 --- a/debug/debug.go +++ /dev/null @@ -1,184 +0,0 @@ -// 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" - "os/user" - "path/filepath" - "runtime" - "runtime/debug" - "time" - - "github.com/sasha-s/go-deadlock" -) - -var writer io.Writer -var RecoverPrettyPanic bool = true -var DeadlockDetection bool -var WriteLogs bool -var OnRecover func() -var LogDirectory = GetUserDebugDir() - -func GetUserDebugDir() string { - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { - return filepath.Join(os.TempDir(), "gomuks-"+getUname()) - } - // See https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html - if xdgStateHome := os.Getenv("XDG_STATE_HOME"); xdgStateHome != "" { - return filepath.Join(xdgStateHome, "gomuks") - } - home := os.Getenv("HOME") - if home == "" { - fmt.Println("XDG_STATE_HOME and HOME are both unset") - os.Exit(1) - } - return filepath.Join(home, ".local", "state", "gomuks") -} - -func getUname() string { - currUser, err := user.Current() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - return currUser.Username -} - -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) -} diff --git a/debug/doc.go b/debug/doc.go deleted file mode 100644 index 253441c..0000000 --- a/debug/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package debug contains utilities to log debug messages and display panics nicely. -package debug diff --git a/go.mod b/go.mod index df2b20c..3d0c041 100644 --- a/go.mod +++ b/go.mod @@ -1,51 +1,5 @@ -module maunium.net/go/gomuks +module go.mau.fi/gomuks -go 1.21 +go 1.23.0 -require ( - github.com/alecthomas/chroma v0.10.0 - github.com/disintegration/imaging v1.6.2 - github.com/gabriel-vasile/mimetype v1.4.4 - github.com/kyokomi/emoji/v2 v2.2.13 - github.com/lithammer/fuzzysearch v1.1.8 - github.com/lucasb-eyer/go-colorful v1.2.0 - github.com/mattn/go-runewidth v0.0.15 - github.com/mattn/go-sqlite3 v1.14.22 - github.com/rivo/uniseg v0.4.7 - github.com/sasha-s/go-deadlock v0.3.1 - github.com/yuin/goldmark v1.7.4 - github.com/zyedidia/clipboard v1.0.4 - go.etcd.io/bbolt v1.3.10 - 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.18.0 - golang.org/x/net v0.27.0 - gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 - gopkg.in/vansante/go-ffprobe.v2 v2.2.0 - gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mauflag v1.0.0 - maunium.net/go/mautrix v0.11.2-0.20240620211416-fa19263891f5 - mvdan.cc/xurls/v2 v2.5.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.17.1 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect - golang.org/x/crypto v0.25.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/term v0.22.0 // indirect - golang.org/x/text v0.16.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 +toolchain go1.23.2 diff --git a/go.sum b/go.sum index c63c45d..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,129 +0,0 @@ -github.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.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= -github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= -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/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= -github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= -github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= -github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= -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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -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/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.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -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.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= -github.com/tidwall/gjson v1.17.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/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -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.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= -github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -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.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= -go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= -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.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= -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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -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/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -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.2.0 h1:iuOqTsbfYuqIz4tAU9NWh22CmBGxlGHdgj4iqP+NUmY= -gopkg.in/vansante/go-ffprobe.v2 v2.2.0/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/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= -maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -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.2-0.20240620211416-fa19263891f5 h1:zAELWR3594qziixinqE+CgKZzgQwpiubArNZXXTmfIs= -maunium.net/go/mautrix v0.11.2-0.20240620211416-fa19263891f5/go.mod h1:K29EcHwsNg6r7fMfwvi0GHQ9o5wSjqB9+Q8RjCIQEjA= -mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= -mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= diff --git a/gomuks.go b/gomuks.go deleted file mode 100644 index 392944f..0000000 --- a/gomuks.go +++ /dev/null @@ -1,192 +0,0 @@ -// 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/signal" - "path/filepath" - "runtime" - "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.1" - // 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 with %s)", Version, BuildTime, runtime.Version()) -} - -// 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() { - err := gmx.matrix.InitClient(true) - if err != nil { - if errors.Is(err, matrix.ErrServerOutdated) { - _, _ = fmt.Fprintln(os.Stderr, strings.Replace(err.Error(), "homeserver", gmx.config.HS, 1)) - _, _ = fmt.Fprintln(os.Stderr) - _, _ = fmt.Fprintf(os.Stderr, "See `%s --help` if you want to skip this check or clear all data.\n", os.Args[0]) - os.Exit(4) - } else if strings.HasPrefix(err.Error(), "failed to check server versions") { - _, _ = fmt.Fprintln(os.Stderr, "Failed to check versions supported by server:", errors.Unwrap(err)) - _, _ = fmt.Fprintln(os.Stderr) - _, _ = fmt.Fprintf(os.Stderr, "Modify %s if the server has moved.\n", filepath.Join(gmx.config.Dir, "config.yaml")) - _, _ = fmt.Fprintf(os.Stderr, "See `%s --help` if you want to skip this check or clear all data.\n", os.Args[0]) - os.Exit(5) - } else { - panic(err) - } - } - - 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 -} diff --git a/interface/doc.go b/interface/doc.go deleted file mode 100644 index cc09d44..0000000 --- a/interface/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package ifc contains interfaces to allow circular function calls without circular imports. -package ifc diff --git a/interface/gomuks.go b/interface/gomuks.go deleted file mode 100644 index e269071..0000000 --- a/interface/gomuks.go +++ /dev/null @@ -1,32 +0,0 @@ -// 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) -} diff --git a/interface/matrix.go b/interface/matrix.go deleted file mode 100644 index 58c428f..0000000 --- a/interface/matrix.go +++ /dev/null @@ -1,92 +0,0 @@ -// 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(isStartup bool) 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, file *attachment.EncryptedFile) 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 -} diff --git a/interface/ui.go b/interface/ui.go deleted file mode 100644 index 460da0e..0000000 --- a/interface/ui.go +++ /dev/null @@ -1,89 +0,0 @@ -// 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) -} diff --git a/lib/ansimage/LICENSE b/lib/ansimage/LICENSE deleted file mode 100644 index a612ad9..0000000 --- a/lib/ansimage/LICENSE +++ /dev/null @@ -1,373 +0,0 @@ -Mozilla 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. diff --git a/lib/ansimage/ansimage.go b/lib/ansimage/ansimage.go deleted file mode 100644 index 1001fe9..0000000 --- a/lib/ansimage/ansimage.go +++ /dev/null @@ -1,297 +0,0 @@ -// ___ _____ ____ -// / _ \/ _/ |/_/ /____ ______ _ -// / ___// /_> =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 -} diff --git a/lib/ansimage/doc.go b/lib/ansimage/doc.go deleted file mode 100644 index dfc0c43..0000000 --- a/lib/ansimage/doc.go +++ /dev/null @@ -1,11 +0,0 @@ -// 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 -} diff --git a/lib/notification/doc.go b/lib/notification/doc.go deleted file mode 100644 index 05295c6..0000000 --- a/lib/notification/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package notification contains a simple cross-platform desktop notification sending function. -package notification diff --git a/lib/notification/notify_darwin.go b/lib/notification/notify_darwin.go deleted file mode 100644 index 317641c..0000000 --- a/lib/notification/notify_darwin.go +++ /dev/null @@ -1,65 +0,0 @@ -// 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 - } -} diff --git a/lib/notification/notify_windows.go b/lib/notification/notify_windows.go deleted file mode 100644 index 954788b..0000000 --- a/lib/notification/notify_windows.go +++ /dev/null @@ -1,39 +0,0 @@ -// 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() -} diff --git a/lib/notification/notify_xdg.go b/lib/notification/notify_xdg.go deleted file mode 100644 index 5320ee8..0000000 --- a/lib/notification/notify_xdg.go +++ /dev/null @@ -1,84 +0,0 @@ -//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() -} diff --git a/lib/open/doc.go b/lib/open/doc.go deleted file mode 100644 index 367ffb7..0000000 --- a/lib/open/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// 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 diff --git a/lib/open/open.go b/lib/open/open.go deleted file mode 100644 index c128494..0000000 --- a/lib/open/open.go +++ /dev/null @@ -1,39 +0,0 @@ -// 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 -} diff --git a/lib/open/open_darwin.go b/lib/open/open_darwin.go deleted file mode 100644 index 8947413..0000000 --- a/lib/open/open_darwin.go +++ /dev/null @@ -1,5 +0,0 @@ -package open - -const Command = "open" - -var Args []string diff --git a/lib/open/open_windows.go b/lib/open/open_windows.go deleted file mode 100644 index 1586e2b..0000000 --- a/lib/open/open_windows.go +++ /dev/null @@ -1,27 +0,0 @@ -// 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} diff --git a/lib/open/open_xdg.go b/lib/open/open_xdg.go deleted file mode 100644 index bf0796e..0000000 --- a/lib/open/open_xdg.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows && !darwin - -package open - -const Command = "xdg-open" - -var Args []string diff --git a/lib/util/doc.go b/lib/util/doc.go deleted file mode 100644 index 8db1325..0000000 --- a/lib/util/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package util contains miscellaneous utilities -package util diff --git a/lib/util/lcp.go b/lib/util/lcp.go deleted file mode 100644 index 2e2e690..0000000 --- a/lib/util/lcp.go +++ /dev/null @@ -1,38 +0,0 @@ -// 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 -} diff --git a/main.go b/main.go deleted file mode 100644 index 58401b5..0000000 --- a/main.go +++ /dev/null @@ -1,203 +0,0 @@ -// 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" - - flag "maunium.net/go/mauflag" - - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/matrix" - "maunium.net/go/gomuks/ui" -) - -var MainUIProvider ifc.UIProvider = ui.NewGomuksUI - -var wantVersion = flag.MakeFull("v", "version", "Show the version of gomuks", "false").Bool() -var clearCache = flag.MakeFull("c", "clear-cache", "Clear the cache directory instead of starting", "false").Bool() -var clearData = flag.Make().LongKey("clear-all-data").Usage("Clear all data instead of starting").Default("false").Bool() -var skipVersionCheck = flag.MakeFull("s", "skip-version-check", "Skip the homeserver version checks at startup and login", "false").Bool() -var wantHelp, _ = flag.MakeHelpFlag() - -func main() { - flag.SetHelpTitles( - "gomuks - A terminal Matrix client written in Go.", - "gomuks [-vch] [--clear-all-data]", - ) - err := flag.Parse() - if err != nil { - fmt.Println(err) - os.Exit(1) - } else if *wantHelp { - flag.PrintHelp() - return - } else if *wantVersion { - fmt.Println(VersionString) - return - } - debugDir := os.Getenv("DEBUG_DIR") - if len(debugDir) > 0 { - debug.LogDirectory = debugDir - } - debugLevel := strings.ToLower(os.Getenv("DEBUG")) - if debugLevel == "1" || debugLevel == "t" || debugLevel == "true" { - debug.RecoverPrettyPanic = false - debug.DeadlockDetection = true - debug.WriteLogs = true - } - debug.Initialize() - defer debug.Recover() - - var configDir, dataDir, cacheDir, downloadDir string - - 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) - - matrix.SkipVersionCheck = *skipVersionCheck - gmx := NewGomuks(MainUIProvider, configDir, dataDir, cacheDir, downloadDir) - - if *clearCache { - debug.Print("Clearing cache as requested by CLI flag") - gmx.config.Clear() - fmt.Printf("Cleared cache at %s\n", gmx.config.CacheDir) - return - } else if *clearData { - debug.Print("Clearing all data as requested by CLI flag") - gmx.config.Clear() - gmx.config.ClearData() - _ = os.RemoveAll(gmx.config.Dir) - fmt.Printf("Cleared cache at %s, data at %s and config at %s\n", gmx.config.CacheDir, gmx.config.DataDir, gmx.config.Dir) - return - } - - 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 -} diff --git a/matrix/crypto.go b/matrix/crypto.go deleted file mode 100644 index 87406e0..0000000 --- a/matrix/crypto.go +++ /dev/null @@ -1,100 +0,0 @@ -// 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) -} diff --git a/matrix/doc.go b/matrix/doc.go deleted file mode 100644 index f789895..0000000 --- a/matrix/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package matrix contains wrappers for mautrix for use by the UI of gomuks. -package matrix diff --git a/matrix/history.go b/matrix/history.go deleted file mode 100644 index b1e5842..0000000 --- a/matrix/history.go +++ /dev/null @@ -1,316 +0,0 @@ -// 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 -} diff --git a/matrix/matrix.go b/matrix/matrix.go deleted file mode 100644 index 8ecf145..0000000 --- a/matrix/matrix.go +++ /dev/null @@ -1,1418 +0,0 @@ -// 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" - "net/http/httptest" - "net/url" - "os" - "path" - "path/filepath" - "reflect" - "runtime" - dbg "runtime/debug" - "strconv" - "strings" - "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 - - mediaProxyURL string - - 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 -} - -var ( - ErrNoHomeserver = errors.New("no homeserver entered") - ErrServerOutdated = errors.New("homeserver is outdated") -) - -var MinSpecVersion = mautrix.SpecV11 -var SkipVersionCheck = false - -// InitClient initializes the mautrix client and connects to the homeserver specified in the config. -func (c *Container) InitClient(isStartup bool) error { - if len(c.config.HS) == 0 { - if isStartup { - return nil - } - return ErrNoHomeserver - } - - 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}}, - } - } - - if !SkipVersionCheck && (!isStartup || len(c.client.AccessToken) > 0) { - debug.Printf("Checking versions that %s supports", c.client.HomeserverURL) - resp, err := c.client.Versions() - if err != nil { - debug.Print("Error checking supported versions:", err) - return fmt.Errorf("failed to check server versions: %w", err) - } else if !resp.ContainsGreaterOrEqual(MinSpecVersion) { - debug.Print("Server doesn't support modern spec versions") - bestVersionStr := "nothing" - bestVersion := mautrix.MustParseSpecVersion("r0.0.0") - for _, ver := range resp.Versions { - if ver.GreaterThan(bestVersion) { - bestVersion = ver - bestVersionStr = ver.String() - } - } - return fmt.Errorf("%w (it only supports %s, while gomuks requires %s)", ErrServerOutdated, bestVersionStr, MinSpecVersion.String()) - } else { - debug.Print("Server supports modern spec versions") - } - } - - if c.mediaProxyURL == "" { - server := httptest.NewServer(http.HandlerFunc(c.doMediaProxy)) - c.mediaProxyURL = server.URL - debug.Print("Started media proxy server at", c.mediaProxyURL) - } - - c.stop = make(chan bool, 1) - - if len(accessToken) > 0 { - go c.Start() - } - return nil -} - -func (c *Container) doMediaProxy(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - if len(parts) != 2 { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte("Invalid path\n")) - return - } - uri := id.ContentURI{ - Homeserver: parts[0], - FileID: parts[1], - } - key := r.URL.Query().Get("k") - iv := r.URL.Query().Get("iv") - hash := r.URL.Query().Get("hash") - var file *attachment.EncryptedFile - if key != "" && iv != "" && hash != "" { - file = &attachment.EncryptedFile{ - Key: attachment.JSONWebKey{ - Key: key, - Algorithm: "A256CTR", - Extractable: true, - KeyType: "oct", - KeyOps: []string{"encrypt", "decrypt"}, - }, - InitVector: iv, - Hashes: attachment.EncryptedFileHashes{ - SHA256: hash, - }, - Version: "v2", - } - } - data, err := c.Download(uri, file) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(fmt.Sprintf("Failed to download media: %v\n", err))) - return - } - mime := http.DetectContentType(data) - w.Header().Add("Content-Length", strconv.Itoa(len(data))) - w.Header().Add("Content-Type", mime) - switch mime { - case "text/css", "text/plain", "text/csv", - "application/json", "application/ld+json", - "image/jpeg", "image/gif", "image/png", "image/apng", "image/webp", "image/avif", - "video/mp4", "video/webm", "video/ogg", "video/quicktime", - "audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", - "audio/wav", "audio/x-wav", "audio/x-pn-wav", "audio/flac", "audio/x-flac": - w.Header().Add("Content-Disposition", "inline") - default: - w.Header().Add("Content-Disposition", "attachment") - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write(data) -} - -// 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() - if !pushRules.Notify { - room.LastReceivedMessage = time.Unix(evt.Timestamp/1000, evt.Timestamp%1000*1000) - room.AddUnread(evt.ID, pushRules.Notify, 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, file *attachment.EncryptedFile) string { - addr, _ := url.Parse(c.mediaProxyURL) - addr.Path = path.Join(addr.Path, uri.Homeserver, uri.FileID) - if file != nil { - addr.RawQuery = (&url.Values{ - "k": {file.Key.Key}, - "iv": {file.InitVector}, - "hash": {file.Hashes.SHA256}, - }).Encode() - } - return addr.String() -} - -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) -} diff --git a/matrix/mediainfo.go b/matrix/mediainfo.go deleted file mode 100644 index 5de4171..0000000 --- a/matrix/mediainfo.go +++ /dev/null @@ -1,106 +0,0 @@ -// 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 -} diff --git a/matrix/muksevt/content.go b/matrix/muksevt/content.go deleted file mode 100644 index fb78323..0000000 --- a/matrix/muksevt/content.go +++ /dev/null @@ -1,44 +0,0 @@ -// 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{}) -} diff --git a/matrix/muksevt/event.go b/matrix/muksevt/event.go deleted file mode 100644 index 0d3303d..0000000 --- a/matrix/muksevt/event.go +++ /dev/null @@ -1,53 +0,0 @@ -// 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 -} diff --git a/matrix/nocrypto.go b/matrix/nocrypto.go deleted file mode 100644 index 90b3dd9..0000000 --- a/matrix/nocrypto.go +++ /dev/null @@ -1,15 +0,0 @@ -// 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() {} diff --git a/matrix/rooms/doc.go b/matrix/rooms/doc.go deleted file mode 100644 index 2a4ff4b..0000000 --- a/matrix/rooms/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package rooms contains a representation for Matrix rooms and utilities to parse state events. -package rooms diff --git a/matrix/rooms/room.go b/matrix/rooms/room.go deleted file mode 100644 index da39e3e..0000000 --- a/matrix/rooms/room.go +++ /dev/null @@ -1,715 +0,0 @@ -// 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(), - } -} diff --git a/matrix/rooms/roomcache.go b/matrix/rooms/roomcache.go deleted file mode 100644 index cafa262..0000000 --- a/matrix/rooms/roomcache.go +++ /dev/null @@ -1,376 +0,0 @@ -// 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 -} diff --git a/matrix/sync.go b/matrix/sync.go deleted file mode 100644 index 7c1d879..0000000 --- a/matrix/sync.go +++ /dev/null @@ -1,267 +0,0 @@ -// 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("*")}, - }, - } -} diff --git a/matrix/uia-fallback.go b/matrix/uia-fallback.go deleted file mode 100644 index f958ba1..0000000 --- a/matrix/uia-fallback.go +++ /dev/null @@ -1,115 +0,0 @@ -// 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 -} diff --git a/ui/autocomplete.go b/ui/autocomplete.go deleted file mode 100644 index 5cacace..0000000 --- a/ui/autocomplete.go +++ /dev/null @@ -1,88 +0,0 @@ -// 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 -} - -var staticPowerLevelKeys = []string{"ban", "kick", "redact", "invite", "state_default", "events_default", "users_default"} - -func autocompletePowerLevel(cmd *CommandAutocomplete) (completions []string, newText string) { - if len(cmd.Args) > 1 { - return - } - for _, staticKey := range staticPowerLevelKeys { - if strings.HasPrefix(staticKey, cmd.RawArgs) { - completions = append(completions, staticKey) - } - } - for _, cpl := range cmd.Room.AutocompleteUser(cmd.RawArgs) { - completions = append(completions, cpl.id) - } - return -} diff --git a/ui/command-processor.go b/ui/command-processor.go deleted file mode 100644 index f0591f7..0000000 --- a/ui/command-processor.go +++ /dev/null @@ -1,290 +0,0 @@ -// 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{}) { - if len(args) > 0 { - message = fmt.Sprintf(message, args...) - } - cmd.Room.AddServiceMessage(message) - 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"}, - "power": {"powerlevel"}, - "pl": {"powerlevel"}, - }, - 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, - "powerlevel": autocompletePowerLevel, - }, - 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, - "powerlevel": cmdPowerLevel, - "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) -} diff --git a/ui/commands.go b/ui/commands.go deleted file mode 100644 index 37eb4e3..0000000 --- a/ui/commands.go +++ /dev/null @@ -1,1046 +0,0 @@ -// 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 formatPowerLevels(pl *event.PowerLevelsEventContent) string { - var buf strings.Builder - buf.WriteString("Membership actions:\n") - _, _ = fmt.Fprintf(&buf, " Invite: %d\n", pl.Invite()) - _, _ = fmt.Fprintf(&buf, " Kick: %d\n", pl.Kick()) - _, _ = fmt.Fprintf(&buf, " Ban: %d\n", pl.Ban()) - buf.WriteString("Events:\n") - _, _ = fmt.Fprintf(&buf, " Redact: %d\n", pl.Redact()) - _, _ = fmt.Fprintf(&buf, " State default: %d\n", pl.StateDefault()) - _, _ = fmt.Fprintf(&buf, " Event default: %d\n", pl.EventsDefault) - for evtType, level := range pl.Events { - _, _ = fmt.Fprintf(&buf, " %s: %d\n", evtType, level) - } - buf.WriteString("Users:\n") - _, _ = fmt.Fprintf(&buf, " Default: %d\n", pl.UsersDefault) - for userID, level := range pl.Users { - _, _ = fmt.Fprintf(&buf, " %s: %d\n", userID, level) - } - return strings.TrimSpace(buf.String()) -} - -func copyPtr(ptr *int) *int { - if ptr == nil { - return nil - } - val := *ptr - return &val -} - -func copyMap[Key comparable](m map[Key]int) map[Key]int { - if m == nil { - return nil - } - copied := make(map[Key]int, len(m)) - for k, v := range m { - copied[k] = v - } - return copied -} - -func copyPowerLevels(pl *event.PowerLevelsEventContent) *event.PowerLevelsEventContent { - return &event.PowerLevelsEventContent{ - Users: copyMap(pl.Users), - Events: copyMap(pl.Events), - InvitePtr: copyPtr(pl.InvitePtr), - KickPtr: copyPtr(pl.KickPtr), - BanPtr: copyPtr(pl.BanPtr), - RedactPtr: copyPtr(pl.RedactPtr), - StateDefaultPtr: copyPtr(pl.StateDefaultPtr), - EventsDefault: pl.EventsDefault, - UsersDefault: pl.UsersDefault, - } -} - -var things = ` -[thing] can be one of the following - -Literals: -* invite, kick, ban, redact - special moderation action levels -* state_default, events_default - default level for state and non-state events -* users_default - default level for users - -Patterns: -* user ID - specific user level -* event type - specific event type level - -The default levels are 0 for users, 50 for moderators and 100 for admins.` - -func cmdPowerLevel(cmd *Command) { - evt := cmd.Room.MxRoom().GetStateEvent(event.StatePowerLevels, "") - pl := copyPowerLevels(evt.Content.AsPowerLevels()) - if len(cmd.Args) == 0 { - // TODO open in modal? - cmd.Reply(formatPowerLevels(pl)) - return - } else if len(cmd.Args) < 2 { - cmd.Reply("Usage: /%s [thing] [level]\n%s", cmd.Command, things) - return - } - - value, err := strconv.Atoi(cmd.Args[1]) - if err != nil { - cmd.Reply("Invalid power level %q: %v", cmd.Args[1], err) - return - } - - ownLevel := pl.GetUserLevel(cmd.Matrix.Client().UserID) - plChangeLevel := pl.GetEventLevel(event.StatePowerLevels) - if ownLevel < plChangeLevel { - cmd.Reply("Can't modify power levels (own level is %d, modifying requires %d)", ownLevel, plChangeLevel) - return - } else if value > ownLevel { - cmd.Reply("Can't set level to be higher than own level (%d > %d)", value, ownLevel) - return - } - - var oldValue int - var thing string - switch cmd.Args[0] { - case "invite": - oldValue = pl.Invite() - pl.InvitePtr = &value - thing = "invite level" - case "kick": - oldValue = pl.Kick() - pl.KickPtr = &value - thing = "kick level" - case "ban": - oldValue = pl.Ban() - pl.BanPtr = &value - thing = "ban level" - case "redact": - oldValue = pl.Redact() - pl.RedactPtr = &value - thing = "level for redacting other users' events" - case "state_default": - oldValue = pl.StateDefault() - pl.StateDefaultPtr = &value - thing = "default level for state events" - case "events_default": - oldValue = pl.EventsDefault - pl.EventsDefault = value - thing = "default level for normal events" - case "users_default": - oldValue = pl.UsersDefault - pl.UsersDefault = value - thing = "default level for users" - default: - userID := id.UserID(cmd.Args[0]) - if _, _, err = userID.Parse(); err == nil { - if pl.Users == nil { - pl.Users = make(map[id.UserID]int) - } - oldValue = pl.Users[userID] - if oldValue == ownLevel && userID != cmd.Matrix.Client().UserID { - cmd.Reply("Can't change level of another user which is equal to own level (%d)", ownLevel) - return - } - pl.Users[userID] = value - thing = fmt.Sprintf("level of user %s", userID) - } else { - if pl.Events == nil { - pl.Events = make(map[string]int) - } - oldValue = pl.Events[cmd.Args[0]] - pl.Events[cmd.Args[0]] = value - thing = fmt.Sprintf("level for event %s", cmd.Args[0]) - } - } - - if oldValue == value { - cmd.Reply("%s is already %d", strings.ToUpper(thing[0:1])+thing[1:], value) - } else if oldValue > ownLevel { - cmd.Reply("Can't change level which is higher than own level (%d > %d)", oldValue, ownLevel) - } else if resp, err := cmd.Matrix.Client().SendStateEvent(cmd.Room.MxRoom().ID, event.StatePowerLevels, "", pl); err != nil { - if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.RespError != nil { - err = httpErr.RespError - } - cmd.Reply("Failed to set %s to %d: %v", thing, value, err) - } else { - cmd.Reply("Successfully set %s to %d\n(event ID: %s)", thing, value, resp.EventID) - } -} - -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:]) -} - -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"), -} - -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 - 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() -} diff --git a/ui/crypto-commands.go b/ui/crypto-commands.go deleted file mode 100644 index 9722dcc..0000000 --- a/ui/crypto-commands.go +++ /dev/null @@ -1,698 +0,0 @@ -// 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") - } -} diff --git a/ui/doc.go b/ui/doc.go deleted file mode 100644 index 804b334..0000000 --- a/ui/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package ui contains the main gomuks UI. -package ui diff --git a/ui/fuzzy-search-modal.go b/ui/fuzzy-search-modal.go deleted file mode 100644 index 07f510f..0000000 --- a/ui/fuzzy-search-modal.go +++ /dev/null @@ -1,165 +0,0 @@ -// 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) -} diff --git a/ui/help-modal.go b/ui/help-modal.go deleted file mode 100644 index 8aec753..0000000 --- a/ui/help-modal.go +++ /dev/null @@ -1,117 +0,0 @@ -package 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) -} diff --git a/ui/member-list.go b/ui/member-list.go deleted file mode 100644 index 089c156..0000000 --- a/ui/member-list.go +++ /dev/null @@ -1,128 +0,0 @@ -// 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) - } - } -} diff --git a/ui/message-view.go b/ui/message-view.go deleted file mode 100644 index 6d05546..0000000 --- a/ui/message-view.go +++ /dev/null @@ -1,682 +0,0 @@ -// 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() -} diff --git a/ui/messages/base.go b/ui/messages/base.go deleted file mode 100644 index 85b1219..0000000 --- a/ui/messages/base.go +++ /dev/null @@ -1,396 +0,0 @@ -// 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() -} diff --git a/ui/messages/doc.go b/ui/messages/doc.go deleted file mode 100644 index 289c308..0000000 --- a/ui/messages/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package messages contains different message types and code to generate and render them. -package messages diff --git a/ui/messages/expandedtextmessage.go b/ui/messages/expandedtextmessage.go deleted file mode 100644 index ea001ab..0000000 --- a/ui/messages/expandedtextmessage.go +++ /dev/null @@ -1,102 +0,0 @@ -// 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) - } -} diff --git a/ui/messages/filemessage.go b/ui/messages/filemessage.go deleted file mode 100644 index abc0b52..0000000 --- a/ui/messages/filemessage.go +++ /dev/null @@ -1,190 +0,0 @@ -// 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, msg.File)) -} - -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, msg.File) - 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) - } -} diff --git a/ui/messages/html/base.go b/ui/messages/html/base.go deleted file mode 100644 index 16eb9fb..0000000 --- a/ui/messages/html/base.go +++ /dev/null @@ -1,101 +0,0 @@ -// 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") -} diff --git a/ui/messages/html/blockquote.go b/ui/messages/html/blockquote.go deleted file mode 100644 index 25123da..0000000 --- a/ui/messages/html/blockquote.go +++ /dev/null @@ -1,88 +0,0 @@ -// 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) -} diff --git a/ui/messages/html/break.go b/ui/messages/html/break.go deleted file mode 100644 index f702a76..0000000 --- a/ui/messages/html/break.go +++ /dev/null @@ -1,54 +0,0 @@ -// 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 -} diff --git a/ui/messages/html/codeblock.go b/ui/messages/html/codeblock.go deleted file mode 100644 index 42d4433..0000000 --- a/ui/messages/html/codeblock.go +++ /dev/null @@ -1,59 +0,0 @@ -// 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 -} diff --git a/ui/messages/html/colormap.go b/ui/messages/html/colormap.go deleted file mode 100644 index 305309c..0000000 --- a/ui/messages/html/colormap.go +++ /dev/null @@ -1,156 +0,0 @@ -// 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) -} diff --git a/ui/messages/html/container.go b/ui/messages/html/container.go deleted file mode 100644 index 17e7aa9..0000000 --- a/ui/messages/html/container.go +++ /dev/null @@ -1,148 +0,0 @@ -// 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 -} diff --git a/ui/messages/html/entity.go b/ui/messages/html/entity.go deleted file mode 100644 index 094fa10..0000000 --- a/ui/messages/html/entity.go +++ /dev/null @@ -1,63 +0,0 @@ -// 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 -} diff --git a/ui/messages/html/horizontalline.go b/ui/messages/html/horizontalline.go deleted file mode 100644 index ff12709..0000000 --- a/ui/messages/html/horizontalline.go +++ /dev/null @@ -1,61 +0,0 @@ -// 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" -} diff --git a/ui/messages/html/list.go b/ui/messages/html/list.go deleted file mode 100644 index b01c2d6..0000000 --- a/ui/messages/html/list.go +++ /dev/null @@ -1,123 +0,0 @@ -// 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" - - "maunium.net/go/gomuks/ui/widget" - "maunium.net/go/mautrix/format" -) - -type ListEntity struct { - *ContainerEntity - Ordered bool - Start int -} - -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 += format.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) paddingFor(number int) string { - padding := le.Indent - 2 - format.Digits(number) - if padding <= 0 { - return "" - } - return strings.Repeat(" ", padding) -} - -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, le.paddingFor(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, le.paddingFor(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) -} diff --git a/ui/messages/html/parser.go b/ui/messages/html/parser.go deleted file mode 100644 index fd10c92..0000000 --- a/ui/messages/html/parser.go +++ /dev/null @@ -1,552 +0,0 @@ -// 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 -} diff --git a/ui/messages/html/spoiler.go b/ui/messages/html/spoiler.go deleted file mode 100644 index f28e8b8..0000000 --- a/ui/messages/html/spoiler.go +++ /dev/null @@ -1,120 +0,0 @@ -// 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() -} diff --git a/ui/messages/html/text.go b/ui/messages/html/text.go deleted file mode 100644 index 1b8860c..0000000 --- a/ui/messages/html/text.go +++ /dev/null @@ -1,156 +0,0 @@ -// 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] == ' ' -} diff --git a/ui/messages/htmlmessage.go b/ui/messages/htmlmessage.go deleted file mode 100644 index d3593a6..0000000 --- a/ui/messages/htmlmessage.go +++ /dev/null @@ -1,99 +0,0 @@ -// 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() -} diff --git a/ui/messages/parser.go b/ui/messages/parser.go deleted file mode 100644 index dc9733e..0000000 --- a/ui/messages/parser.go +++ /dev/null @@ -1,324 +0,0 @@ -// 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) -} diff --git a/ui/messages/redactedmessage.go b/ui/messages/redactedmessage.go deleted file mode 100644 index e3e1718..0000000 --- a/ui/messages/redactedmessage.go +++ /dev/null @@ -1,67 +0,0 @@ -// 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) - } -} diff --git a/ui/messages/textbase.go b/ui/messages/textbase.go deleted file mode 100644 index 1e8b376..0000000 --- a/ui/messages/textbase.go +++ /dev/null @@ -1,96 +0,0 @@ -// 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 -} diff --git a/ui/messages/tstring/cell.go b/ui/messages/tstring/cell.go deleted file mode 100644 index 4ed3735..0000000 --- a/ui/messages/tstring/cell.go +++ /dev/null @@ -1,53 +0,0 @@ -// 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 -} diff --git a/ui/messages/tstring/doc.go b/ui/messages/tstring/doc.go deleted file mode 100644 index d03a1da..0000000 --- a/ui/messages/tstring/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// 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 diff --git a/ui/messages/tstring/string.go b/ui/messages/tstring/string.go deleted file mode 100644 index df51d5d..0000000 --- a/ui/messages/tstring/string.go +++ /dev/null @@ -1,270 +0,0 @@ -// 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] -} diff --git a/ui/no-crypto-commands.go b/ui/no-crypto-commands.go deleted file mode 100644 index b98d277..0000000 --- a/ui/no-crypto-commands.go +++ /dev/null @@ -1,46 +0,0 @@ -// 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 -) diff --git a/ui/password-modal.go b/ui/password-modal.go deleted file mode 100644 index ceb2f7c..0000000 --- a/ui/password-modal.go +++ /dev/null @@ -1,143 +0,0 @@ -// 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 - } -} diff --git a/ui/rainbow.go b/ui/rainbow.go deleted file mode 100644 index 83f2800..0000000 --- a/ui/rainbow.go +++ /dev/null @@ -1,135 +0,0 @@ -// 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 -} diff --git a/ui/room-list.go b/ui/room-list.go deleted file mode 100644 index 6425225..0000000 --- a/ui/room-list.go +++ /dev/null @@ -1,592 +0,0 @@ -// 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() -} diff --git a/ui/room-view.go b/ui/room-view.go deleted file mode 100644 index 73fe967..0000000 --- a/ui/room-view.go +++ /dev/null @@ -1,937 +0,0 @@ -// 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 -} diff --git a/ui/syncing-modal.go b/ui/syncing-modal.go deleted file mode 100644 index 646d479..0000000 --- a/ui/syncing-modal.go +++ /dev/null @@ -1,71 +0,0 @@ -// 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() -} diff --git a/ui/tag-room-list.go b/ui/tag-room-list.go deleted file mode 100644 index 1a01d5d..0000000 --- a/ui/tag-room-list.go +++ /dev/null @@ -1,331 +0,0 @@ -// 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++ - } -} diff --git a/ui/ui.go b/ui/ui.go deleted file mode 100644 index d823e64..0000000 --- a/ui/ui.go +++ /dev/null @@ -1,128 +0,0 @@ -// 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 -} diff --git a/ui/verification-modal.go b/ui/verification-modal.go deleted file mode 100644 index 0959474..0000000 --- a/ui/verification-modal.go +++ /dev/null @@ -1,253 +0,0 @@ -// 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() -} diff --git a/ui/view-login.go b/ui/view-login.go deleted file mode 100644 index 100a56c..0000000 --- a/ui/view-login.go +++ /dev/null @@ -1,196 +0,0 @@ -// 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)) / 41)) - 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(false); 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 && len(httpErr.RespError.Err) > 0 { - view.Error(httpErr.RespError.Err) - } else if len(httpErr.Message) > 0 { - view.Error(httpErr.Message) - } else { - view.Error(err.Error()) - } - } 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) -} diff --git a/ui/view-main.go b/ui/view-main.go deleted file mode 100644 index ed92e3a..0000000 --- a/ui/view-main.go +++ /dev/null @@ -1,463 +0,0 @@ -// 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) - - if !isCurrent || !isFocused { - // The message is not in the current room, show new message status in room list. - room.AddUnread(message.ID(), should.Notify, should.Highlight) - } else { - view.matrix.MarkRead(room.ID, message.ID()) - } - - if should.Notify && !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() -} diff --git a/ui/widget/border.go b/ui/widget/border.go deleted file mode 100644 index eab22dd..0000000 --- a/ui/widget/border.go +++ /dev/null @@ -1,63 +0,0 @@ -// 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 -} diff --git a/ui/widget/color.go b/ui/widget/color.go deleted file mode 100644 index 398e43f..0000000 --- a/ui/widget/color.go +++ /dev/null @@ -1,224 +0,0 @@ -// 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) -} diff --git a/ui/widget/doc.go b/ui/widget/doc.go deleted file mode 100644 index 03d2060..0000000 --- a/ui/widget/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package widget contains additional tview widgets. -package widget diff --git a/ui/widget/util.go b/ui/widget/util.go deleted file mode 100644 index e26a23d..0000000 --- a/ui/widget/util.go +++ /dev/null @@ -1,73 +0,0 @@ -// 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) -}