1
0
Fork 0
forked from Mirrors/gomuks

Compare commits

..

16 commits

Author SHA1 Message Date
Tulir Asokan
7bc4075d86 Merge branch 'main' into terminal 2025-03-05 17:04:09 +02:00
Tulir Asokan
054d0e7c11 Revert "dependencies: update"
This reverts commit 7aaf59f6ca.
2025-03-05 16:41:47 +02:00
Tulir Asokan
836869a692 Revert "dependencies: update"
This reverts commit 13f0e1b0f7.
2025-03-05 16:41:39 +02:00
Tulir Asokan
bef2e4e582 Revert "hicli/sync: don't fail sync if database is locked"
This reverts commit 3990427c97.
2025-03-05 16:41:39 +02:00
Tulir Asokan
16a1ef30b1 Revert "all: add FCM push support"
This reverts commit 18c5a8b231.
2025-03-05 16:41:38 +02:00
Tulir Asokan
18c5a8b231 all: add FCM push support 2025-03-05 16:38:22 +02:00
Tulir Asokan
3990427c97 hicli/sync: don't fail sync if database is locked 2025-03-05 16:37:51 +02:00
Tulir Asokan
13f0e1b0f7 dependencies: update 2025-03-05 16:34:36 +02:00
Tulir Asokan
7aaf59f6ca dependencies: update 2025-03-05 16:31:09 +02:00
jaakko.jokinen
aa7ca67976 added 'some' files 2025-02-24 18:06:53 +02:00
jaakko.jokinen
dd3245eb90 copied files to start working on them 2025-02-10 15:02:35 +02:00
jaakko.jokinen
b7d9a914e1 Prepare for mainview 2025-01-22 15:19:27 +02:00
jaakko.jokinen
e5cdd95733 login works 2025-01-22 13:51:26 +02:00
jaakko.jokinen
cb7d053d9e Start work on tui 2025-01-20 18:36:22 +02:00
Tulir Asokan
6a96f3f800 Merge branch 'main' into terminal 2025-01-13 14:23:38 +02:00
Tulir Asokan
57b9847637 tui: add stub terminal UI 2024-12-09 17:11:40 +02:00
94 changed files with 10692 additions and 873 deletions

View file

@ -1,11 +0,0 @@
on: [push]
jobs:
test:
runs-on: docker
steps:
- uses: actions/cascading-pr@v1.0.1
with:
GOPATH="$CI_PROJECT_DIR/.cache"
GOCACHE="$CI_PROJECT_DIR/.cache/build"
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
GO_LDFLAGS="-s -w -linkmode external -extldflags -static -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"

View file

@ -1,7 +1,18 @@
# nyxmuks # gomuks
![Languages](https://img.shields.io/github/languages/top/tulir/gomuks.svg)
[![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases)
[![GitLab CI](https://mau.dev/tulir/gomuks/badges/main/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines)
Soft fork of Tulir's Gomuks. A Matrix client written in Go using [mautrix](https://github.com/mautrix/go).
# why? This branch contains gomuks web. For legacy gomuks terminal, see the
[master branch](https://github.com/tulir/gomuks/tree/master). The new
version will get a terminal frontend in the future. See also:
<https://github.com/tulir/gomuks/issues/476>.
Gomuks is adding unneccesary features, and the developer is acting maliciously. This fork aims to remove a few features that seem to be made with a malicious intent, like the "un-redact" button. ## Docs
For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/).
## Discussion
Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net)

View file

@ -25,12 +25,14 @@ import (
"go.mau.fi/gomuks/pkg/gomuks" "go.mau.fi/gomuks/pkg/gomuks"
"go.mau.fi/gomuks/pkg/hicli" "go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/tui"
"go.mau.fi/gomuks/version" "go.mau.fi/gomuks/version"
"go.mau.fi/gomuks/web" "go.mau.fi/gomuks/web"
) )
var wantHelp, _ = flag.MakeHelpFlag() var wantHelp, _ = flag.MakeHelpFlag()
var wantVersion = flag.MakeFull("v", "version", "View gomuks version and quit.", "false").Bool() var wantVersion = flag.MakeFull("v", "version", "View gomuks version and quit.", "false").Bool()
var wantTUI = flag.MakeFull("t", "tui", "Open gomuks terminal", "false").Bool()
func main() { func main() {
hicli.InitialDeviceDisplayName = "gomuks web" hicli.InitialDeviceDisplayName = "gomuks web"
@ -59,5 +61,8 @@ func main() {
gmx.LinkifiedVersion = version.LinkifiedVersion gmx.LinkifiedVersion = version.LinkifiedVersion
gmx.BuildTime = version.ParsedBuildTime gmx.BuildTime = version.ParsedBuildTime
gmx.FrontendFS = web.Frontend gmx.FrontendFS = web.Frontend
if *wantTUI {
gmx.TUI = tui.New(gmx)
}
gmx.Run() gmx.Run()
} }

View file

@ -8,7 +8,7 @@ require github.com/wailsapp/wails/v3 v3.0.0-alpha.9
require ( require (
go.mau.fi/gomuks v0.4.0 go.mau.fi/gomuks v0.4.0
go.mau.fi/util v0.8.6 go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95
) )
require ( require (
@ -22,7 +22,7 @@ require (
github.com/buckket/go-blurhash v1.1.0 // indirect github.com/buckket/go-blurhash v1.1.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect github.com/chzyer/readline v1.5.1 // indirect
github.com/cloudflare/circl v1.3.8 // indirect github.com/cloudflare/circl v1.3.8 // indirect
github.com/coder/websocket v1.8.13 // indirect github.com/coder/websocket v1.8.12 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.5 // indirect github.com/cyphar/filepath-securejoin v0.2.5 // indirect
github.com/disintegration/imaging v1.6.2 // indirect github.com/disintegration/imaging v1.6.2 // indirect
@ -47,7 +47,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // indirect github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
@ -66,20 +66,20 @@ require (
github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark v1.7.8 // indirect
go.mau.fi/webp v0.2.0 // indirect go.mau.fi/webp v0.2.0 // indirect
go.mau.fi/zeroconfig v0.1.3 // indirect go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.36.0 // indirect golang.org/x/crypto v0.34.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/exp v0.0.0-20250215185904-eff6e970281f // indirect
golang.org/x/image v0.25.0 // indirect golang.org/x/image v0.24.0 // indirect
golang.org/x/mod v0.24.0 // indirect golang.org/x/mod v0.23.0 // indirect
golang.org/x/net v0.37.0 // indirect golang.org/x/net v0.35.0 // indirect
golang.org/x/sync v0.12.0 // indirect golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.31.0 // indirect golang.org/x/tools v0.30.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mautrix v0.23.2 // indirect maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7 // indirect
mvdan.cc/xurls/v2 v2.6.0 // indirect mvdan.cc/xurls/v2 v2.6.0 // indirect
) )

View file

@ -35,8 +35,8 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI= github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
@ -113,8 +113,8 @@ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBW
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs= github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a h1:ckxP/kGzsxvxXo8jO6E/0QJ8MMmwI7IRj4Fys9QbAZA=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
@ -166,8 +166,8 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95 h1:5EfVWWjU2Hte9uE6B/hBgvjnVfBx/7SYDZBnsuo+EBs=
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg= go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q= go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
@ -177,17 +177,17 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.34.0 h1:+/C6tk6rf/+t5DhUketUbD1aNGqiSX3j15Z6xuIDlBA=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.34.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250215185904-eff6e970281f h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/exp v0.0.0-20250215185904-eff6e970281f/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@ -196,13 +196,13 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.11.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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -222,15 +222,15 @@ golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.30.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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -238,14 +238,14 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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.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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -261,7 +261,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.23.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg= maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7 h1:AeNHqITptzOpmfMxnqmQRw6xN7DUDCgsN00BaPyRd4k=
maunium.net/go/mautrix v0.23.2/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE= maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7/go.mod h1:IHMaSJh7YIxMrZSDVefS+nLdr3RbeLowsCSa6ibONZ0=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=

28
go.mod
View file

@ -2,15 +2,16 @@ module go.mau.fi/gomuks
go 1.23.0 go 1.23.0
toolchain go1.24.1 toolchain go1.24.0
require ( require (
github.com/alecthomas/chroma/v2 v2.15.0 github.com/alecthomas/chroma/v2 v2.15.0
github.com/buckket/go-blurhash v1.1.0 github.com/buckket/go-blurhash v1.1.0
github.com/chzyer/readline v1.5.1 github.com/chzyer/readline v1.5.1
github.com/coder/websocket v1.8.13 github.com/coder/websocket v1.8.12
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/gabriel-vasile/mimetype v1.4.8 github.com/gabriel-vasile/mimetype v1.4.8
github.com/gdamore/tcell/v2 v2.7.4
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-sqlite3 v1.14.24
github.com/rivo/uniseg v0.4.7 github.com/rivo/uniseg v0.4.7
@ -18,16 +19,17 @@ require (
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5 github.com/tidwall/sjson v1.2.5
github.com/yuin/goldmark v1.7.8 github.com/yuin/goldmark v1.7.8
go.mau.fi/util v0.8.6 go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95
go.mau.fi/webp v0.2.0 go.mau.fi/webp v0.2.0
go.mau.fi/zeroconfig v0.1.3 go.mau.fi/zeroconfig v0.1.3
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.34.0
golang.org/x/image v0.25.0 golang.org/x/image v0.24.0
golang.org/x/net v0.37.0 golang.org/x/net v0.35.0
golang.org/x/text v0.23.0 golang.org/x/text v0.22.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.23.2 maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7
mvdan.cc/xurls/v2 v2.6.0 mvdan.cc/xurls/v2 v2.6.0
) )
@ -35,13 +37,17 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect github.com/zyedidia/clipboard v1.0.4 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/exp v0.0.0-20250215185904-eff6e970281f // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
) )

87
go.sum
View file

@ -16,8 +16,8 @@ github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -28,6 +28,10 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
@ -40,13 +44,18 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs= github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a h1:ckxP/kGzsxvxXo8jO6E/0QJ8MMmwI7IRj4Fys9QbAZA=
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@ -66,32 +75,70 @@ 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/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 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= github.com/zyedidia/clipboard v1.0.4 h1:r6GUQOyPtIaApRLeD56/U+2uJbXis6ANGbKWCljULEo=
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA=
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5 h1:apKftqeRRyj/Vpd5s81fNhS8UErwgfs07KG3NSHB/4Q=
go.mau.fi/mauview v0.2.2-0.20241209125653-292e0a6914c5/go.mod h1:G0Qkfwt84f+5tagHsaRdiTuUFeTlIZu61MN/JL9D8Qo=
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95 h1:5EfVWWjU2Hte9uE6B/hBgvjnVfBx/7SYDZBnsuo+EBs=
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg= go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q= go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/crypto v0.34.0 h1:+/C6tk6rf/+t5DhUketUbD1aNGqiSX3j15Z6xuIDlBA=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/crypto v0.34.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg=
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
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/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-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
@ -100,7 +147,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.23.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg= maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7 h1:AeNHqITptzOpmfMxnqmQRw6xN7DUDCgsN00BaPyRd4k=
maunium.net/go/mautrix v0.23.2/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE= maunium.net/go/mautrix v0.23.2-0.20250304004736-7d3791ace3b7/go.mod h1:IHMaSJh7YIxMrZSDVefS+nLdr3RbeLowsCSa6ibONZ0=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=

View file

@ -111,7 +111,7 @@ func (gmx *Gomuks) LoadConfig() error {
gmx.Config.Web.TokenKey = random.String(64) gmx.Config.Web.TokenKey = random.String(64)
changed = true changed = true
} }
if !gmx.DisableAuth && (gmx.Config.Web.Username == "" || gmx.Config.Web.PasswordHash == "") { if !gmx.DisableAuth && gmx.TUI == nil && (gmx.Config.Web.Username == "" || gmx.Config.Web.PasswordHash == "") {
fmt.Println("Please create a username and password for authenticating the web app") fmt.Println("Please create a username and password for authenticating the web app")
gmx.Config.Web.Username, err = readline.Line("Username: ") gmx.Config.Web.Username, err = readline.Line("Username: ")
if err != nil { if err != nil {

View file

@ -25,6 +25,7 @@ import (
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@ -35,6 +36,7 @@ import (
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog" "go.mau.fi/util/exzerolog"
"go.mau.fi/util/ptr" "go.mau.fi/util/ptr"
"go.mau.fi/zeroconfig"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"go.mau.fi/gomuks/pkg/hicli" "go.mau.fi/gomuks/pkg/hicli"
@ -67,6 +69,11 @@ type Gomuks struct {
stopChan chan struct{} stopChan chan struct{}
EventBuffer *EventBuffer EventBuffer *EventBuffer
TUI tui
}
type tui interface {
Run()
} }
func NewGomuks() *Gomuks { func NewGomuks() *Gomuks {
@ -148,6 +155,12 @@ func (gmx *Gomuks) InitDirectories() {
} }
func (gmx *Gomuks) SetupLog() { func (gmx *Gomuks) SetupLog() {
if gmx.TUI != nil {
// Remove stdout and stderr writers if TUI is enabled
gmx.Config.Logging.Writers = slices.DeleteFunc(gmx.Config.Logging.Writers, func(config zeroconfig.WriterConfig) bool {
return config.Type == zeroconfig.WriterTypeStdout || config.Type == zeroconfig.WriterTypeStderr
})
}
gmx.Log = exerrors.Must(gmx.Config.Logging.Compile()) gmx.Log = exerrors.Must(gmx.Config.Logging.Compile())
exzerolog.SetupDefaults(gmx.Log) exzerolog.SetupDefaults(gmx.Log)
} }
@ -250,7 +263,11 @@ func (gmx *Gomuks) Run() {
gmx.StartServer() gmx.StartServer()
gmx.StartClient() gmx.StartClient()
gmx.Log.Info().Msg("Initialization complete") gmx.Log.Info().Msg("Initialization complete")
gmx.WaitForInterrupt() if gmx.TUI != nil {
gmx.TUI.Run()
} else {
gmx.WaitForInterrupt()
}
gmx.Log.Info().Msg("Shutting down...") gmx.Log.Info().Msg("Shutting down...")
gmx.DirectStop() gmx.DirectStop()
gmx.Log.Info().Msg("Shutdown complete") gmx.Log.Info().Msg("Shutdown complete")

View file

@ -157,18 +157,12 @@ func (pn *PushNotification) Split(yield func(*PushNotification) bool) {
} }
func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*database.PushRegistration, notif *PushNotification) { func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*database.PushRegistration, notif *PushNotification) {
log := zerolog.Ctx(ctx).With().
Bool("important", notif.HasImportant).
Int("message_count", len(notif.RawMessages)).
Int("dismiss_count", len(notif.Dismiss)).
Logger()
ctx = log.WithContext(ctx)
rawPayload, err := json.Marshal(notif) rawPayload, err := json.Marshal(notif)
if err != nil { if err != nil {
log.Err(err).Msg("Failed to marshal push notification") zerolog.Ctx(ctx).Err(err).Msg("Failed to marshal push notification")
return return
} else if base64.StdEncoding.EncodedLen(len(rawPayload)) >= 4000 { } else if base64.StdEncoding.EncodedLen(len(rawPayload)) >= 4000 {
log.Error().Msg("Generated push payload too long") zerolog.Ctx(ctx).Error().Msg("Generated push payload too long")
return return
} }
for _, reg := range pushRegs { for _, reg := range pushRegs {
@ -178,7 +172,7 @@ func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*databas
var err error var err error
devicePayload, err = encryptPush(rawPayload, reg.Encryption.Key) devicePayload, err = encryptPush(rawPayload, reg.Encryption.Key)
if err != nil { if err != nil {
log.Err(err).Str("device_id", reg.DeviceID).Msg("Failed to encrypt push payload") zerolog.Ctx(ctx).Err(err).Str("device_id", reg.DeviceID).Msg("Failed to encrypt push payload")
continue continue
} }
encrypted = true encrypted = true
@ -186,7 +180,7 @@ func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*databas
switch reg.Type { switch reg.Type {
case database.PushTypeFCM: case database.PushTypeFCM:
if !encrypted { if !encrypted {
log.Warn(). zerolog.Ctx(ctx).Warn().
Str("device_id", reg.DeviceID). Str("device_id", reg.DeviceID).
Msg("FCM push registration doesn't have encryption key") Msg("FCM push registration doesn't have encryption key")
continue continue
@ -194,7 +188,7 @@ func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*databas
var token string var token string
err = json.Unmarshal(reg.Data, &token) err = json.Unmarshal(reg.Data, &token)
if err != nil { if err != nil {
log.Err(err).Str("device_id", reg.DeviceID).Msg("Failed to unmarshal FCM token") zerolog.Ctx(ctx).Err(err).Str("device_id", reg.DeviceID).Msg("Failed to unmarshal FCM token")
continue continue
} }
gmx.SendFCMPush(ctx, token, devicePayload, notif.HasImportant) gmx.SendFCMPush(ctx, token, devicePayload, notif.HasImportant)

View file

@ -235,6 +235,9 @@ func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
if gmx.DisableAuth { if gmx.DisableAuth {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} else if gmx.Config.Web.Username == "" || gmx.Config.Web.PasswordHash == "" {
w.WriteHeader(http.StatusForbidden)
return
} }
jsonOutput := r.URL.Query().Get("output") == "json" jsonOutput := r.URL.Query().Get("output") == "json"
allowPrompt := r.URL.Query().Get("no_prompt") != "true" allowPrompt := r.URL.Query().Get("no_prompt") != "true"

View file

@ -80,7 +80,7 @@ const (
AND (type IN ('m.room.message', 'm.sticker') AND (type IN ('m.room.message', 'm.sticker')
OR (type = 'm.room.encrypted' OR (type = 'm.room.encrypted'
AND decrypted_type IN ('m.room.message', 'm.sticker'))) AND decrypted_type IN ('m.room.message', 'm.sticker')))
AND (relation_type IS NULL OR relation_type <> 'm.replace') AND relation_type <> 'm.replace'
AND redacted_by IS NULL AND redacted_by IS NULL
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT 1 LIMIT 1
@ -132,9 +132,6 @@ func (rq *RoomQuery) UpdatePreviewIfLaterOnTimeline(ctx context.Context, roomID
func (rq *RoomQuery) RecalculatePreview(ctx context.Context, roomID id.RoomID) (rowID EventRowID, err error) { func (rq *RoomQuery) RecalculatePreview(ctx context.Context, roomID id.RoomID) (rowID EventRowID, err error) {
err = rq.GetDB().QueryRow(ctx, recalculateRoomPreviewEventQuery, roomID).Scan(&rowID) err = rq.GetDB().QueryRow(ctx, recalculateRoomPreviewEventQuery, roomID).Scan(&rowID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
return return
} }
@ -218,7 +215,7 @@ func (r *Room) CheckChangesAndCopyInto(other *Room) (hasChanges bool) {
hasChanges = true hasChanges = true
other.HasMemberList = true other.HasMemberList = true
} }
if r.PreviewEventRowID != 0 { if r.PreviewEventRowID > other.PreviewEventRowID {
other.PreviewEventRowID = r.PreviewEventRowID other.PreviewEventRowID = r.PreviewEventRowID
hasChanges = true hasChanges = true
} }

View file

@ -414,42 +414,24 @@ var HTMLSanitizerImgSrcTemplate = "mxc://%s/%s"
func writeImg(w *strings.Builder, attr []html.Attribute) id.ContentURI { func writeImg(w *strings.Builder, attr []html.Attribute) id.ContentURI {
src, alt, title, isCustomEmoji, width, height := parseImgAttributes(attr) src, alt, title, isCustomEmoji, width, height := parseImgAttributes(attr)
mxc := id.ContentURIString(src).ParseOrIgnore()
if !mxc.IsValid() {
w.WriteString("<span")
writeAttribute(w, "class", "hicli-inline-img-fallback hicli-invalid-inline-img")
w.WriteString(">")
writeEscapedString(w, alt)
w.WriteString("</span>")
return id.ContentURI{}
}
url := fmt.Sprintf(HTMLSanitizerImgSrcTemplate, mxc.Homeserver, mxc.FileID)
w.WriteString("<a")
writeAttribute(w, "class", "hicli-inline-img-fallback hicli-mxc-url")
writeAttribute(w, "title", title)
writeAttribute(w, "style", "display: none;")
writeAttribute(w, "target", "_blank")
writeAttribute(w, "data-mxc", mxc.String())
writeAttribute(w, "href", url)
w.WriteString(">")
writeEscapedString(w, alt)
w.WriteString("</a>")
w.WriteString("<img") w.WriteString("<img")
writeAttribute(w, "alt", alt) writeAttribute(w, "alt", alt)
if title != "" { if title != "" {
writeAttribute(w, "title", title) writeAttribute(w, "title", title)
} }
writeAttribute(w, "src", url) mxc := id.ContentURIString(src).ParseOrIgnore()
if !mxc.IsValid() {
return id.ContentURI{}
}
writeAttribute(w, "src", fmt.Sprintf(HTMLSanitizerImgSrcTemplate, mxc.Homeserver, mxc.FileID))
writeAttribute(w, "loading", "lazy") writeAttribute(w, "loading", "lazy")
if isCustomEmoji { if isCustomEmoji {
writeAttribute(w, "class", "hicli-inline-img hicli-custom-emoji") writeAttribute(w, "class", "hicli-custom-emoji")
} else if cWidth, cHeight, sizeOK := calculateMediaSize(width, height); sizeOK { } else if cWidth, cHeight, sizeOK := calculateMediaSize(width, height); sizeOK {
writeAttribute(w, "class", "hicli-inline-img hicli-sized-inline-img") writeAttribute(w, "class", "hicli-sized-inline-img")
writeAttribute(w, "style", fmt.Sprintf("width: %.2fpx; height: %.2fpx;", cWidth, cHeight)) writeAttribute(w, "style", fmt.Sprintf("width: %.2fpx; height: %.2fpx;", cWidth, cHeight))
} else { } else {
writeAttribute(w, "class", "hicli-inline-img hicli-sizeless-inline-img") writeAttribute(w, "class", "hicli-sizeless-inline-img")
} }
return mxc return mxc
} }

View file

@ -65,16 +65,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
}) })
case "set_state": case "set_state":
return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) { return unmarshalAndCall(req.Data, func(params *sendStateEventParams) (id.EventID, error) {
return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content, mautrix.ReqSendEvent{ return h.SetState(ctx, params.RoomID, params.EventType, params.StateKey, params.Content)
UnstableDelay: time.Duration(params.DelayMS) * time.Millisecond,
})
})
case "update_delayed_event":
return unmarshalAndCall(req.Data, func(params *updateDelayedEventParams) (*mautrix.RespUpdateDelayedEvent, error) {
return h.Client.UpdateDelayedEvent(ctx, &mautrix.ReqUpdateDelayedEvent{
DelayID: params.DelayID,
Action: params.Action,
})
}) })
case "set_membership": case "set_membership":
return unmarshalAndCall(req.Data, func(params *setMembershipParams) (any, error) { return unmarshalAndCall(req.Data, func(params *setMembershipParams) (any, error) {
@ -317,12 +308,6 @@ type sendStateEventParams struct {
EventType event.Type `json:"type"` EventType event.Type `json:"type"`
StateKey string `json:"state_key"` StateKey string `json:"state_key"`
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
DelayMS int `json:"delay_ms"`
}
type updateDelayedEventParams struct {
DelayID string `json:"delay_id"`
Action string `json:"action"`
} }
type setMembershipParams struct { type setMembershipParams struct {

View file

@ -296,12 +296,12 @@ func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit i
if resp.End == "" { if resp.End == "" {
resp.End = database.PrevBatchPaginationComplete resp.End = database.PrevBatchPaginationComplete
} }
if len(resp.Chunk) == 0 { if resp.End == database.PrevBatchPaginationComplete || len(resp.Chunk) == 0 {
err = h.DB.Room.SetPrevBatch(ctx, room.ID, resp.End) err = h.DB.Room.SetPrevBatch(ctx, room.ID, resp.End)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to set prev_batch: %w", err) return nil, fmt.Errorf("failed to set prev_batch: %w", err)
} }
return &PaginationResponse{Events: events, HasMore: resp.End != database.PrevBatchPaginationComplete}, nil return &PaginationResponse{Events: events, HasMore: resp.End != ""}, nil
} }
wakeupSessionRequests := false wakeupSessionRequests := false
err = h.DB.DoTxn(ctx, nil, func(ctx context.Context) error { err = h.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
@ -366,5 +366,5 @@ func (h *HiClient) PaginateServer(ctx context.Context, roomID id.RoomID, limit i
if err == nil && wakeupSessionRequests { if err == nil && wakeupSessionRequests {
h.WakeupRequestQueue() h.WakeupRequestQueue()
} }
return &PaginationResponse{Events: events, HasMore: resp.End != database.PrevBatchPaginationComplete}, err return &PaginationResponse{Events: events, HasMore: true}, err
} }

View file

@ -226,7 +226,6 @@ func (h *HiClient) SetState(
evtType event.Type, evtType event.Type,
stateKey string, stateKey string,
content any, content any,
extra ...mautrix.ReqSendEvent,
) (id.EventID, error) { ) (id.EventID, error) {
room, err := h.DB.Room.Get(ctx, roomID) room, err := h.DB.Room.Get(ctx, roomID)
if err != nil { if err != nil {
@ -234,14 +233,10 @@ func (h *HiClient) SetState(
} else if room == nil { } else if room == nil {
return "", fmt.Errorf("unknown room") return "", fmt.Errorf("unknown room")
} }
resp, err := h.Client.SendStateEvent(ctx, room.ID, evtType, stateKey, content, extra...) resp, err := h.Client.SendStateEvent(ctx, room.ID, evtType, stateKey, content)
if err != nil { if err != nil {
return "", err return "", err
} }
if resp.UnstableDelayID != "" {
// Mildly hacky, but it's fine'
return id.EventID(resp.UnstableDelayID), nil
}
return resp.EventID, nil return resp.EventID, nil
} }
@ -516,9 +511,6 @@ func (h *HiClient) shouldShareKeysToInvitedUsers(ctx context.Context, roomID id.
if err != nil { if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get history visibility event") zerolog.Ctx(ctx).Err(err).Msg("Failed to get history visibility event")
return false return false
} else if historyVisibility == nil {
zerolog.Ctx(ctx).Warn().Msg("History visibility event not found")
return false
} }
mautrixEvt := historyVisibility.AsRawMautrix() mautrixEvt := historyVisibility.AsRawMautrix()
err = mautrixEvt.Content.ParseRaw(mautrixEvt.Type) err = mautrixEvt.Content.ParseRaw(mautrixEvt.Type)

View file

@ -137,7 +137,7 @@ func (h *HiClient) maybeDiscardOutboundSession(ctx context.Context, newMembershi
prevMembership = event.Membership(gjson.GetBytes(cs.Content, "membership").Str) prevMembership = event.Membership(gjson.GetBytes(cs.Content, "membership").Str)
} }
if prevMembership == newMembership || if prevMembership == newMembership ||
(prevMembership == event.MembershipInvite && newMembership == event.MembershipJoin && h.shouldShareKeysToInvitedUsers(ctx, evt.RoomID)) || (prevMembership == event.MembershipInvite && newMembership == event.MembershipJoin) ||
(prevMembership == event.MembershipJoin && newMembership == event.MembershipInvite) || (prevMembership == event.MembershipJoin && newMembership == event.MembershipInvite) ||
(prevMembership == event.MembershipBan && newMembership == event.MembershipLeave) || (prevMembership == event.MembershipBan && newMembership == event.MembershipLeave) ||
(prevMembership == event.MembershipLeave && newMembership == event.MembershipBan) { (prevMembership == event.MembershipLeave && newMembership == event.MembershipBan) {
@ -598,7 +598,7 @@ func (h *HiClient) calculateLocalContent(ctx context.Context, dbEvt *database.Ev
return nil, nil return nil, nil
} }
const CurrentHTMLSanitizerVersion = 10 const CurrentHTMLSanitizerVersion = 8
func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) { func (h *HiClient) ReprocessExistingEvent(ctx context.Context, evt *database.Event) {
if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) || if (evt.Type != event.EventMessage.Type && evt.DecryptedType != event.EventMessage.Type) ||
@ -785,7 +785,7 @@ func (h *HiClient) processStateAndTimeline(
return fmt.Errorf("failed to get relation target of redaction target: %w", err) return fmt.Errorf("failed to get relation target of redaction target: %w", err)
} }
} }
if updatedRoom.PreviewEventRowID == dbEvt.RowID || (updatedRoom.PreviewEventRowID == 0 && room.PreviewEventRowID == dbEvt.RowID) { if updatedRoom.PreviewEventRowID == dbEvt.RowID {
updatedRoom.PreviewEventRowID = 0 updatedRoom.PreviewEventRowID = 0
recalculatePreviewEvent = true recalculatePreviewEvent = true
} }
@ -969,11 +969,10 @@ func (h *HiClient) processStateAndTimeline(
updatedRoom.PreviewEventRowID, err = h.DB.Room.RecalculatePreview(ctx, room.ID) updatedRoom.PreviewEventRowID, err = h.DB.Room.RecalculatePreview(ctx, room.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to recalculate preview event: %w", err) return fmt.Errorf("failed to recalculate preview event: %w", err)
} else if updatedRoom.PreviewEventRowID != 0 { }
_, err = addOldEvent(updatedRoom.PreviewEventRowID, "") _, err = addOldEvent(updatedRoom.PreviewEventRowID, "")
if err != nil { if err != nil {
return fmt.Errorf("failed to get preview event: %w", err) return fmt.Errorf("failed to get preview event: %w", err)
}
} }
} }
// Calculate name from participants if participants changed and current name was generated from participants, or if the room name was unset // Calculate name from participants if participants changed and current name was generated from participants, or if the room name was unset

89
tui/autocomplete.go Normal file
View file

@ -0,0 +1,89 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"fmt"
"os"
"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 := os.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
}

281
tui/command-processor.go Normal file
View file

@ -0,0 +1,281 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"fmt"
"strings"
"github.com/mattn/go-runewidth"
)
type gomuksPointerContainer struct {
MainView *MainView
TUI *GomuksTUI
}
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.TUI.App.Redraw()
}
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,
TUI: parent.parent,
},
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)
}

1058
tui/commands.go Normal file

File diff suppressed because it is too large Load diff

699
tui/crypto-commands.go Normal file
View file

@ -0,0 +1,699 @@
// 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 <https://www.gnu.org/licenses/>.
//go:build cgo
package tui
import (
"context"
"errors"
"fmt"
"os"
"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"
)
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 <user id> <device id> [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 <user id>")
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 <user ID> [--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 := os.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 <subcommand> [...]
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 <key ID> - 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 <key ID>", 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(context.TODO)
} else {
keyData, err = mach.SSSS.GetKeyData(context.TODO, 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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 <subcommand> [...]
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(context.TODO())
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(context.TODO(), 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")
}
// korjaa
func cmdCrossSigningGenerate(cmd *Command, container ifc.MatrixContainer, mach *crypto.OlmMachine, client *mautrix.Client, force bool) {
if !force {
existingKeys := mach.GetOwnCrossSigningPublicKeys(context.TODO())
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(context.TODO(), 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(context.TODO())
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(context.TODO())
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(context.TODO(), 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(context.TODO(), 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")
}
}

2
tui/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package tui contains the main gomuks TUI.
package tui

161
tui/fuzzy-search-modal.go Normal file
View file

@ -0,0 +1,161 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"fmt"
"sort"
"strconv"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/debug"
)
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)
}

115
tui/help-modal.go Normal file
View file

@ -0,0 +1,115 @@
package tui
import (
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
)
const helpText = `# General
/help - Show this help dialog.
/quit - Quit gomuks.
/clearcache - Clear cache and quit gomuks.
/logout - Log out of Matrix.
/toggle <thing> - 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 <path> - Upload the file at the given path to the current room.
# Sending special messages
/me <message> - Send an emote message.
/notice <message> - Send a notice (generally used for bot messages).
/rainbow <message> - Send rainbow text.
/rainbowme <message> - Send rainbow text in an emote.
/reply [text] - Reply to the selected message.
/react <reaction> - 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 <user id> - View the device list of a user.
/device <user id> <device id> - Show info about a specific device.
/unverify <user id> <device id> - Un-verify a device.
/blacklist <user id> <device id> - Blacklist a device.
/verify <user id> - Verify a user with in-room verification. Probably broken.
/verify-device <user id> <device id> [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 <file> - Import encryption keys
/export <file> - Export encryption keys
/export-room <file> - Export encryption keys for the current room.
/cross-signing <subcommand> [...]
- Cross-signing commands. Somewhat experimental.
Run without arguments for help. (alias: /cs)
/ssss <subcommand> [...]
- Secure Secret Storage (and Sharing) commands. Very experimental.
Run without arguments for help.
# Rooms
/pm <user id> <...> - Create a private chat with the given user(s).
/create [room name] - Create a room.
/join <room> [server] - Join a room.
/accept - Accept the invite.
/reject - Reject the invite.
/invite <user id> - Invite the given user to the room.
/roomnick <name> - Change your per-room displayname.
/tag <tag> <priority> - Add the room to <tag>.
/untag <tag> - Remove the room from <tag>.
/tags - List the tags the room is in.
/alias <act> <name> - Add or remove local addresses.
/leave - Leave the current room.
/kick <user id> [reason] - Kick a user.
/ban <user id> [reason] - Ban a user.
/unban <user id> - 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)
}

125
tui/member-list.go Normal file
View file

@ -0,0 +1,125 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"math"
"sort"
"strings"
"github.com/mattn/go-runewidth"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
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)
}
}
}

675
tui/message-view.go Normal file
View file

@ -0,0 +1,675 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"fmt"
"math"
"strings"
"sync/atomic"
"github.com/mattn/go-runewidth"
"go.mau.fi/mauview"
"github.com/gdamore/tcell/v2"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/debug"
)
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++
}
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()
}

397
tui/messages/base.go Normal file
View file

@ -0,0 +1,397 @@
// 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 <https://www.gnu.org/licenses/>.
package messages
import (
"fmt"
"sort"
"time"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
/*
"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()
}

2
tui/messages/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package messages contains different message types and code to generate and render them.
package messages

View file

@ -0,0 +1,102 @@
// 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 <https://www.gnu.org/licenses/>.
package messages
import (
"fmt"
"time"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
/*
"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)
}
}

190
tui/messages/filemessage.go Normal file
View file

@ -0,0 +1,190 @@
// 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 <https://www.gnu.org/licenses/>.
package messages
import (
"bytes"
"fmt"
"image"
"image/color"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
/*
"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)
}
}

101
tui/messages/html/base.go Normal file
View file

@ -0,0 +1,101 @@
// 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 <https://www.gnu.org/licenses/>.
package html
import (
"fmt"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
)
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")
}

View file

@ -0,0 +1,88 @@
// 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 <https://www.gnu.org/licenses/>.
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)
}

View file

@ -0,0 +1,54 @@
// 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 <https://www.gnu.org/licenses/>.
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
}

View file

@ -0,0 +1,59 @@
// 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 <https://www.gnu.org/licenses/>.
package html
import (
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
)
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
}

View file

@ -0,0 +1,156 @@
// 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)
}

View file

@ -0,0 +1,148 @@
// 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 <https://www.gnu.org/licenses/>.
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
}

View file

@ -0,0 +1,63 @@
// 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 <https://www.gnu.org/licenses/>.
package html
import (
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
)
// 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
}

View file

@ -0,0 +1,61 @@
// 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 <https://www.gnu.org/licenses/>.
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"
}

123
tui/messages/html/list.go Normal file
View file

@ -0,0 +1,123 @@
// 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 <https://www.gnu.org/licenses/>.
package html
import (
"fmt"
"strings"
"go.mau.fi/mauview"
"maunium.net/go/gomuks/tui/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)
}

555
tui/messages/html/parser.go Normal file
View file

@ -0,0 +1,555 @@
// 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 <https://www.gnu.org/licenses/>.
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"
"github.com/gdamore/tcell/v2"
"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", "<br/>", -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
}

View file

@ -0,0 +1,120 @@
// 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 <https://www.gnu.org/licenses/>.
package html
import (
"fmt"
"strings"
"go.mau.fi/mauview"
"github.com/gdamore/tcell/v2"
)
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()
}

156
tui/messages/html/text.go Normal file
View file

@ -0,0 +1,156 @@
// 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 <https://www.gnu.org/licenses/>.
package html
import (
"fmt"
"regexp"
"github.com/mattn/go-runewidth"
"go.mau.fi/mauview"
"maunium.net/go/gomuks/tui/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] == ' '
}

100
tui/messages/htmlmessage.go Normal file
View file

@ -0,0 +1,100 @@
// 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 <https://www.gnu.org/licenses/>.
package messages
import (
"go.mau.fi/mauview"
"github.com/gdamore/tcell/v2"
/*
"maunium.net/go/gomuks/matrix/muksevt"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/tui/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()
}

324
tui/messages/parser.go Normal file
View file

@ -0,0 +1,324 @@
// 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 <https://www.gnu.org/licenses/>.
package messages
import (
"fmt"
"strings"
"github.com/gdamore/tcell/v2"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
/* "maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/matrix/muksevt"
"maunium.net/go/gomuks/matrix/rooms"
"maunium.net/go/gomuks/tui/messages/html"
"maunium.net/go/gomuks/tui/messages/tstring"
"maunium.net/go/gomuks/tui/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)
}

View file

@ -0,0 +1,66 @@
// 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 <https://www.gnu.org/licenses/>.
package messages
import (
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
/* "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)
}
}

96
tui/messages/textbase.go Normal file
View file

@ -0,0 +1,96 @@
// 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 <https://www.gnu.org/licenses/>.
package messages
import (
"fmt"
"regexp"
/*
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/tui/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
}

View file

@ -0,0 +1,53 @@
// 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 <https://www.gnu.org/licenses/>.
package tstring
import (
"github.com/mattn/go-runewidth"
"go.mau.fi/mauview"
"github.com/gdamore/tcell/v2"
)
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
}

View file

@ -0,0 +1,4 @@
// 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

View file

@ -0,0 +1,270 @@
// 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 <https://www.gnu.org/licenses/>.
package tstring
import (
"strings"
"unicode"
"github.com/mattn/go-runewidth"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
)
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]
}

46
tui/no-crypto-commands.go Normal file
View file

@ -0,0 +1,46 @@
// 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 <https://www.gnu.org/licenses/>.
//go:build !cgo
package tui
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
)

143
tui/password-modal.go Normal file
View file

@ -0,0 +1,143 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"fmt"
"strings"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
)
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.App.Redraw()
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
}
}

135
tui/rainbow.go Normal file
View file

@ -0,0 +1,135 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"fmt"
"crypto/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, "<font color=\"%s\">%s</font>", 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, "<font color=\"%s\">%c</font>", 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("<br>\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
}

589
tui/room-list.go Normal file
View file

@ -0,0 +1,589 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"math"
"regexp"
"sort"
"strings"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/debug"
)
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()
}

929
tui/room-view.go Normal file
View file

@ -0,0 +1,929 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"fmt"
"sort"
"strings"
"time"
"unicode"
"github.com/mattn/go-runewidth"
"github.com/zyedidia/clipboard"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
"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/gomuks/debug"
"maunium.net/go/gomuks/tui/messages"
"maunium.net/go/gomuks/tui/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,
}
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()
//helpp
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 = `<a href="https://matrix.to/#/%[2]s">%[1]s</a>`
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.App.Redraw()
}
} else {
view.AddServiceMessage(fmt.Sprintf("Clipboard register %v unsupported", register))
view.parent.parent.App.Redraw()
}
}
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.App.Redraw()
return
}
view.AddServiceMessage(fmt.Sprintf("File downloaded to %s", path))
view.parent.parent.App.Redraw()
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.App.Redraw()
}
}
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.App.Redraw()
}
}
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.App.Redraw()
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.App.Redraw()
} else {
debug.Print("Event ID received:", eventID)
msg.EventID = eventID
msg.State = muksevt.StateDefault
view.MessageView().setMessageID(msg)
view.parent.parent.App.Redraw()
}
}
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
}

71
tui/syncing-modal.go Normal file
View file

@ -0,0 +1,71 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
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.App.Redraw()
}
func (sm *SyncingModal) Step() {
sm.progress.Increment(1)
}
func (sm *SyncingModal) Close() {
sm.parent.HideModal()
}

329
tui/tag-room-list.go Normal file
View file

@ -0,0 +1,329 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"encoding/json"
"fmt"
"math"
"strconv"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
"maunium.net/go/gomuks/debug"
)
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++
}
}

69
tui/tui.go Normal file
View file

@ -0,0 +1,69 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
package tui
import (
"os"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
"go.mau.fi/gomuks/pkg/gomuks"
)
type View string
// Allowed views in GomuksTUI
type GomuksTUI struct {
*gomuks.Gomuks
App *mauview.Application
mainView *MainView
loginView *LoginView
}
func New(gmx *gomuks.Gomuks) *GomuksTUI {
return &GomuksTUI{
Gomuks: gmx,
App: mauview.NewApplication(),
}
}
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 (gt *GomuksTUI) Run() {
gt.App = mauview.NewApplication()
if !gt.Client.IsLoggedIn() {
gt.App.SetRoot(gt.NewLoginView())
} else {
gt.App.SetRoot(gt.NewMainView())
}
err := gt.App.Start()
if err != nil {
panic(err)
}
}

252
tui/verification-modal.go Normal file
View file

@ -0,0 +1,252 @@
// 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 <https://www.gnu.org/licenses/>.
//go:build cgo
package tui
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/event"
"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.App.Redraw()
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.App.Redraw()
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.App.Redraw()
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.App.Redraw()
}
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.App.Redraw()
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()
}

145
tui/view-login.go Normal file
View file

@ -0,0 +1,145 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
package tui
import (
"math"
"github.com/mattn/go-runewidth"
"context"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
)
type LoginView struct {
*mauview.Form
container *mauview.Centerer
homeserverLabel *mauview.TextField
idLabel *mauview.TextField
passwordLabel *mauview.TextField
homeserver *mauview.InputField
id *mauview.InputField
password *mauview.InputField
error *mauview.TextView
loginButton *mauview.Button
quitButton *mauview.Button
loading bool
parent *GomuksTUI
}
func (gt *GomuksTUI) NewLoginView() mauview.Component {
view := &LoginView{
Form: mauview.NewForm(),
idLabel: mauview.NewTextField().SetText("User ID"),
passwordLabel: mauview.NewTextField().SetText("Password"),
homeserverLabel: mauview.NewTextField().SetText("Homeserver"),
id: mauview.NewInputField(),
password: mauview.NewInputField(),
homeserver: mauview.NewInputField(),
loginButton: mauview.NewButton("Login"),
quitButton: mauview.NewButton("Quit"),
parent: gt,
}
view.homeserver.SetPlaceholder("https://example.com").SetText("").SetTextColor(tcell.ColorWhite)
view.id.SetPlaceholder("@user:example.com").SetText("").SetTextColor(tcell.ColorWhite)
view.password.SetPlaceholder("correct horse battery staple").SetMaskCharacter('*').SetTextColor(tcell.ColorWhite)
view.quitButton.
SetOnClick(gt.App.ForceStop).
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.id, 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.idLabel, 1, 1, 1, 1).
AddComponent(view.passwordLabel, 1, 3, 1, 1).
AddComponent(view.homeserverLabel, 1, 5, 1, 1)
view.FocusNextItem()
gt.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) Error(err string) {
if len(err) == 0 && view.error != nil {
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 {
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.App.Redraw()
}
func (view *LoginView) actuallyLogin(ctx context.Context, hs, mxid, password string) {
view.loading = true
view.loginButton.SetText("Logging in...")
err := view.parent.Client.LoginPassword(ctx, hs, mxid, password)
if err == nil {
view.loginButton.SetText("it woked")
} else {
view.Error(err.Error())
}
view.loading = false
view.loginButton.SetText("Login")
}
func (view *LoginView) Login() {
if view.loading {
return
}
hs := view.homeserver.GetText()
mxid := view.id.GetText()
password := view.password.GetText()
ctx := context.TODO()
go view.actuallyLogin(ctx, hs, mxid, password)
}

480
tui/view-main.go Normal file
View file

@ -0,0 +1,480 @@
// 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 <https://www.gnu.org/licenses/>.
package tui
import (
"bufio"
"context"
"fmt"
"os"
"sync/atomic"
"time"
/*
sync "github.com/sasha-s/go-deadlock"
*/
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
//find what to use
"maunium.net/go/gomuks/gt/messages"
"maunium.net/go/gomuks/gt/widget"
"maunium.net/go/gomuks/lib/notification"
"maunium.net/go/gomuks/matrix/rooms"
)
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
parent *GomuksTUI
}
func (gt *GomuksTUI) NewMainView() mauview.Component {
mainView := &MainView{
flex: mauview.NewFlex().SetDirection(mauview.FlexColumn),
roomView: mauview.NewBox(nil).SetBorder(false),
rooms: make(map[id.RoomID]*RoomView),
/*crying face*
matrix: gt.Gomuks.Matrix(),
gmx: gt.Gomuks,
config: gt.Gomuks.Config(),
*/
parent: gt,
}
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)
gt.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) {
//config does not exist
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()) {
//? receipt type
view.parent.Client.MarkRead(context.TODO(), roomView.Room.ID, msg.ID())
}
}
}
}
func (view *MainView) InputChanged(roomView *RoomView, text string) {
if !roomView.config.Preferences.DisableTypingNotifs {
//fix args in this too
view.parent.Client.SetTyping(context.TODO(), 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")
})
}
/*//???? idk what it does
func (view *MainView) OpenSyncingModal() ifc.SyncingModal {
component, modal := NewSyncingModal(view)
view.ShowModal(component)
return modal
}
*/
/*
//umm this is some keyboard magic which idk how to implement
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
/*
//more :( key things
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.App.Redraw()
if msgView := roomView.MessageView(); len(msgView.messages) < 20 && !msgView.initialHistoryLoaded {
msgView.initialHistoryLoaded = true
go view.LoadHistory(room.ID)
}
if !room.MembersFetched {
go func() {
//another args thing
err := view.parent.Client.GetRoomState(context.TODO())
if err != nil {
debug.Print("Error fetching members:", err)
return
}
roomView.UpdateUserList()
view.parent.App.Redraw()
}()
}
}
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
}
// ifc + what do i get?
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.App.Redraw()
}
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.App.Redraw()
}
func (view *MainView) SetTyping(roomID id.RoomID, users []id.UserID) {
roomView, ok := view.getRoomView(roomID, true)
if ok {
roomView.SetTyping(users)
view.parent.App.Redraw()
}
}
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)
}
// another arg mess. Did not find what is userID
func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) {
view.Bump(room)
gtMsg, ok := message.(*messages.UIMessage)
if ok && gtMsg.SenderID == view.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 {
//args & also why not use the func in here
view.parent.Client.MarkRead(room.ID, message.ID())
}
//config
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.App.Redraw()
//history?????
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.App.Redraw()
return
}
//debug.Printf("Load pointer %d -> %d", msgView.historyLoadPtr, newLoadPtr)
msgView.historyLoadPtr = newLoadPtr
for _, evt := range history {
roomView.AddHistoryEvent(evt)
}
view.parent.App.Redraw()
}

63
tui/widget/border.go Normal file
View file

@ -0,0 +1,63 @@
// 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 <https://www.gnu.org/licenses/>.
package widget
import (
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
)
// 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
}

224
tui/widget/color.go Normal file
View file

@ -0,0 +1,224 @@
// 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 <https://www.gnu.org/licenses/>.
package widget
import (
"fmt"
"hash/fnv"
"github.com/gdamore/tcell/v2"
"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)
}

2
tui/widget/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package widget contains additional tview widgets.
package widget

73
tui/widget/util.go Normal file
View file

@ -0,0 +1,73 @@
// 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 <https://www.gnu.org/licenses/>.
package widget
import (
"fmt"
"strconv"
"github.com/mattn/go-runewidth"
"github.com/gdamore/tcell/v2"
"go.mau.fi/mauview"
)
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)
}

771
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -78,16 +78,11 @@ function getFallbackCharacter(from: unknown, idx: number): string {
return Array.from(from.slice(0, (idx + 1) * 2))[idx]?.toUpperCase().toWellFormed() ?? "" return Array.from(from.slice(0, (idx + 1) * 2))[idx]?.toUpperCase().toWellFormed() ?? ""
} }
export const getAvatarURL = ( export const getAvatarURL = (userID: UserID, content?: UserProfile | null, thumbnail = false): string | undefined => {
userID: UserID,
content?: UserProfile | null,
thumbnail = false,
forceFallback = false,
): string | undefined => {
const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1) const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1)
const backgroundColor = getUserColor(userID) const backgroundColor = getUserColor(userID)
const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url) const [server, mediaID] = parseMXC(content?.avatar_file?.url ?? content?.avatar_url)
if (!mediaID || forceFallback) { if (!mediaID) {
return makeFallbackAvatar(backgroundColor, fallbackCharacter) return makeFallbackAvatar(backgroundColor, fallbackCharacter)
} }
const encrypted = !!content?.avatar_file const encrypted = !!content?.avatar_file
@ -96,12 +91,8 @@ export const getAvatarURL = (
return thumbnail ? `${url}&thumbnail=avatar` : url return thumbnail ? `${url}&thumbnail=avatar` : url
} }
export const getAvatarThumbnailURL = ( export const getAvatarThumbnailURL = (userID: UserID, content?: UserProfile | null): string | undefined => {
userID: UserID, return getAvatarURL(userID, content, true)
content?: UserProfile | null,
forceFallback = false,
): string | undefined => {
return getAvatarURL(userID, content, true, forceFallback)
} }
interface RoomForAvatarURL { interface RoomForAvatarURL {
@ -113,21 +104,14 @@ interface RoomForAvatarURL {
} }
export const getRoomAvatarURL = ( export const getRoomAvatarURL = (
room: RoomForAvatarURL, room: RoomForAvatarURL, avatarOverride?: ContentURI, thumbnail = false,
avatarOverride?: ContentURI,
thumbnail = false,
forceFallback = false,
): string | undefined => { ): string | undefined => {
return getAvatarURL(room.dm_user_id ?? room.room_id, { return getAvatarURL(room.dm_user_id ?? room.room_id, {
displayname: room.name, displayname: room.name,
avatar_url: avatarOverride ?? room.avatar ?? room.avatar_url, avatar_url: avatarOverride ?? room.avatar ?? room.avatar_url,
}, thumbnail, forceFallback) }, thumbnail)
} }
export const getRoomAvatarThumbnailURL = ( export const getRoomAvatarThumbnailURL = (room: RoomForAvatarURL, avatarOverride?: ContentURI): string | undefined => {
room: RoomForAvatarURL, return getRoomAvatarURL(room, avatarOverride, true)
avatarOverride?: ContentURI,
forceFallback = false,
): string | undefined => {
return getRoomAvatarURL(room, avatarOverride, true, forceFallback)
} }

View file

@ -174,13 +174,8 @@ export default abstract class RPCClient {
setState( setState(
room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>, room_id: RoomID, type: EventType, state_key: string, content: Record<string, unknown>,
extra: { delay_ms?: number } = {},
): Promise<EventID> { ): Promise<EventID> {
return this.request("set_state", { room_id, type, state_key, content, ...extra }) return this.request("set_state", { room_id, type, state_key, content })
}
updateDelayedEvent(delay_id: string, action: string): Promise<void> {
return this.request("update_delayed_event", { delay_id, action })
} }
setMembership(room_id: RoomID, user_id: UserID, action: MembershipAction, reason?: string): Promise<void> { setMembership(room_id: RoomID, user_id: UserID, action: MembershipAction, reason?: string): Promise<void> {

View file

@ -45,7 +45,6 @@ export class InvitedRoomStore implements RoomListEntry, RoomSummary {
readonly invited_by?: UserID readonly invited_by?: UserID
readonly inviter_profile?: MemberEventContent readonly inviter_profile?: MemberEventContent
readonly is_direct: boolean readonly is_direct: boolean
readonly is_invite = true
constructor(public readonly meta: DBInvitedRoom, parent: StateStore) { constructor(public readonly meta: DBInvitedRoom, parent: StateStore) {
this.room_id = meta.room_id this.room_id = meta.room_id

View file

@ -55,7 +55,6 @@ export interface RoomListEntry {
unread_notifications: number unread_notifications: number
unread_highlights: number unread_highlights: number
marked_unread: boolean marked_unread: boolean
is_invite?: boolean
} }
export interface GCSettings { export interface GCSettings {
@ -256,10 +255,8 @@ export class StateStore {
} }
applySync(sync: SyncCompleteData) { applySync(sync: SyncCompleteData) {
let prevActiveRoom: RoomID | null = null
if (sync.clear_state && this.rooms.size > 0) { if (sync.clear_state && this.rooms.size > 0) {
console.info("Clearing state store as sync told to reset and there are rooms in the store") console.info("Clearing state store as sync told to reset and there are rooms in the store")
prevActiveRoom = this.activeRoomID
this.clear() this.clear()
} }
const resyncRoomList = this.roomList.current.length === 0 const resyncRoomList = this.roomList.current.length === 0
@ -390,10 +387,6 @@ export class StateStore {
this.topLevelSpaces.emit(sync.top_level_spaces) this.topLevelSpaces.emit(sync.top_level_spaces)
this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id })) this.spaceOrphans.children = sync.top_level_spaces.map(child_id => ({ child_id }))
} }
if (prevActiveRoom) {
// TODO this will fail if the room is not in the top 100 recent rooms
this.switchRoom?.(prevActiveRoom)
}
} }
invalidateEmojiPackKeyCache() { invalidateEmojiPackKeyCache() {

View file

@ -116,9 +116,6 @@ export interface PolicyRuleContent {
entity: string entity: string
reason: string reason: string
recommendation: string recommendation: string
"org.matrix.msc4205.hashes"?: {
sha256: string
}
} }
export interface PowerLevelEventContent { export interface PowerLevelEventContent {

View file

@ -55,22 +55,10 @@ export const preferences = {
}), }),
show_media_previews: new Preference<boolean>({ show_media_previews: new Preference<boolean>({
displayName: "Show image and video previews", displayName: "Show image and video previews",
description: "If disabled, images and videos will only be visible after clicking and will not be downloaded automatically. This will also disable images in URL previews.", description: "If disabled, images and videos will only be visible after clicking and will not be downloaded automatically.",
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: true, defaultValue: true,
}), }),
show_inline_images: new Preference<boolean>({
displayName: "Show inline images",
description: "If disabled, custom emojis and other inline images will not be rendered and the alt attribute will be shown instead.",
allowedContexts: anyContext,
defaultValue: true,
}),
show_invite_avatars: new Preference<boolean>({
displayName: "Show avatars in invites",
description: "If disabled, the avatar of the room or inviter will not be shown in the invite view.",
allowedContexts: anyGlobalContext,
defaultValue: true,
}),
code_block_line_wrap: new Preference<boolean>({ code_block_line_wrap: new Preference<boolean>({
displayName: "Code block line wrap", displayName: "Code block line wrap",
description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.", description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.",

View file

@ -167,6 +167,7 @@ body {
padding: 0; padding: 0;
background-color: var(--background-color); background-color: var(--background-color);
line-height: 1.5; line-height: 1.5;
font-size: 16px;
touch-action: none; touch-action: none;
color: var(--text-color); color: var(--text-color);
min-height: 100vh; min-height: 100vh;
@ -175,7 +176,6 @@ body {
html { html {
touch-action: none; touch-action: none;
background-color: var(--background-color); background-color: var(--background-color);
font-size: 16px;
} }
#root { #root {

View file

@ -117,15 +117,6 @@ const StylePreferences = ({ client, activeRoom }: StylePreferencesProps) => {
--timeline-status-size: 2rem; --timeline-status-size: 2rem;
} }
`, [preferences.display_read_receipts]) `, [preferences.display_read_receipts])
useStyle(() => !preferences.show_inline_images && css`
a.hicli-inline-img-fallback {
display: inline !important;
}
img.hicli-inline-img {
display: none;
}
`, [preferences.show_inline_images])
useAsyncStyle(() => preferences.code_block_theme === "auto" ? ` useAsyncStyle(() => preferences.code_block_theme === "auto" ? `
@import url("_gomuks/codeblock/github.css") (prefers-color-scheme: light); @import url("_gomuks/codeblock/github.css") (prefers-color-scheme: light);
@import url("_gomuks/codeblock/github-dark.css") (prefers-color-scheme: dark); @import url("_gomuks/codeblock/github-dark.css") (prefers-color-scheme: dark);

View file

@ -181,5 +181,10 @@ export const useSecondaryItems = (
title={pendingTitle} title={pendingTitle}
className="redact-button" className="redact-button"
><DeleteIcon/>{names && "Remove"}</button>} ><DeleteIcon/>{names && "Remove"}</button>}
{canUnredact && (evt.viewing_redacted ? <button onClick={onClickHideUnredacted}>
<DeleteIcon/>{names && "Hide content"}
</button> : <button onClick={onClickUnredact}>
<RestoreTrashIcon/>{names && "View content"}
</button>)}
</> </>
} }

View file

@ -30,7 +30,7 @@ div.overlay {
} }
> div.modal-box-inner { > div.modal-box-inner {
overflow: auto; overflow: scroll;
} }
} }
} }

View file

@ -29,7 +29,7 @@ const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperPr
return newState return newState
}, null) }, null)
const onClickWrapper = useCallback((evt?: React.MouseEvent) => { const onClickWrapper = useCallback((evt?: React.MouseEvent) => {
if (evt && (evt.target !== evt.currentTarget || state?.noDismiss)) { if (evt && evt.target !== evt.currentTarget) {
return return
} }
evt?.stopPropagation() evt?.stopPropagation()
@ -37,9 +37,9 @@ const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperPr
if (history.state?.[historyStateKey]) { if (history.state?.[historyStateKey]) {
history.back() history.back()
} }
}, [historyStateKey, state]) }, [historyStateKey])
const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => { const onKeyWrapper = (evt: React.KeyboardEvent<HTMLDivElement>) => {
if (evt.key === "Escape" && !state?.noDismiss) { if (evt.key === "Escape") {
setState(null) setState(null)
if (history.state?.[historyStateKey]) { if (history.state?.[historyStateKey]) {
history.back() history.back()
@ -55,17 +55,12 @@ const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperPr
}, [historyStateKey]) }, [historyStateKey])
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
window.closeModal = onClickWrapper
if (historyStateKey === "nestable_modal") {
window.openNestableModal = openModal
} else {
window.openModal = openModal
}
if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) { if (wrapperRef.current && (!document.activeElement || !wrapperRef.current.contains(document.activeElement))) {
wrapperRef.current.focus() wrapperRef.current.focus()
} }
}, [state, onClickWrapper, historyStateKey, openModal]) }, [state])
useEffect(() => { useEffect(() => {
window.closeModal = onClickWrapper
const listener = (evt: PopStateEvent) => { const listener = (evt: PopStateEvent) => {
if (!evt.state?.[historyStateKey]) { if (!evt.state?.[historyStateKey]) {
setState(null) setState(null)
@ -73,7 +68,7 @@ const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperPr
} }
window.addEventListener("popstate", listener) window.addEventListener("popstate", listener)
return () => window.removeEventListener("popstate", listener) return () => window.removeEventListener("popstate", listener)
}, [historyStateKey]) }, [historyStateKey, onClickWrapper])
let modal: JSX.Element | null = null let modal: JSX.Element | null = null
if (state) { if (state) {
let content = <ModalCloseContext value={onClickWrapper}> let content = <ModalCloseContext value={onClickWrapper}>
@ -102,6 +97,9 @@ const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperPr
modal = content modal = content
} }
} }
if (historyStateKey === "nestable_modal") {
window.openNestableModal = openModal
}
return <ContextType value={openModal}> return <ContextType value={openModal}>
{children} {children}
{modal} {modal}

View file

@ -33,7 +33,6 @@ export interface ModalState {
innerBoxClass?: string innerBoxClass?: string
onClose?: () => void onClose?: () => void
captureInput?: boolean captureInput?: boolean
noDismiss?: boolean
} }
export type openModal = (state: ModalState) => void export type openModal = (state: ModalState) => void

View file

@ -68,6 +68,8 @@ div.right-panel-content.widgets {
} }
div.right-panel-content.user { div.right-panel-content.user {
display: flex;
flex-direction: column;
padding: 1rem; padding: 1rem;
div.avatar-container { div.avatar-container {
@ -77,6 +79,7 @@ div.right-panel-content.user {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 1rem; padding: 1rem;
flex-shrink: 0;
margin: 0 auto; margin: 0 auto;
> img { > img {
@ -102,7 +105,6 @@ div.right-panel-content.user {
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
word-break: break-word; word-break: break-word;
overflow: hidden;
} }
div.userid, div.extended-profile, div.devices, div.user-moderation, div.mutual-rooms, div.errors { div.userid, div.extended-profile, div.devices, div.user-moderation, div.mutual-rooms, div.errors {

View file

@ -16,7 +16,7 @@
import type { IWidget } from "matrix-widget-api" import type { IWidget } from "matrix-widget-api"
import { JSX, use } from "react" import { JSX, use } from "react"
import type { UserID } from "@/api/types" import type { UserID } from "@/api/types"
import MainScreenContext, { MainScreenContextFields } from "../MainScreenContext.ts" import MainScreenContext from "../MainScreenContext.ts"
import ErrorBoundary from "../util/ErrorBoundary.tsx" import ErrorBoundary from "../util/ErrorBoundary.tsx"
import ElementCall from "../widget/ElementCall.tsx" import ElementCall from "../widget/ElementCall.tsx"
import LazyWidget from "../widget/LazyWidget.tsx" import LazyWidget from "../widget/LazyWidget.tsx"
@ -63,7 +63,7 @@ function getTitle(props: RightPanelProps): string {
} }
} }
function renderRightPanelContent(props: RightPanelProps, mainScreen: MainScreenContextFields): JSX.Element | null { function renderRightPanelContent(props: RightPanelProps): JSX.Element | null {
switch (props.type) { switch (props.type) {
case "pinned-messages": case "pinned-messages":
return <PinnedMessages /> return <PinnedMessages />
@ -72,9 +72,9 @@ function renderRightPanelContent(props: RightPanelProps, mainScreen: MainScreenC
case "widgets": case "widgets":
return <WidgetList /> return <WidgetList />
case "element-call": case "element-call":
return <ElementCall onClose={mainScreen.closeRightPanel} /> return <ElementCall />
case "widget": case "widget":
return <LazyWidget info={props.info} onClose={mainScreen.closeRightPanel} /> return <LazyWidget info={props.info} />
case "user": case "user":
return <UserInfo userID={props.userID} /> return <UserInfo userID={props.userID} />
} }
@ -104,7 +104,7 @@ const RightPanel = (props: RightPanelProps) => {
</div> </div>
<div className={`right-panel-content ${props.type}`}> <div className={`right-panel-content ${props.type}`}>
<ErrorBoundary thing="right panel content"> <ErrorBoundary thing="right panel content">
{renderRightPanelContent(props, mainScreen)} {renderRightPanelContent(props)}
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</div> </div>

View file

@ -29,7 +29,6 @@ export interface RoomListEntryProps {
room: RoomListEntry room: RoomListEntry
isActive: boolean isActive: boolean
hidden: boolean hidden: boolean
hideAvatar?: boolean
} }
function getPreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null): [string, JSX.Element | null] { function getPreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null): [string, JSX.Element | null] {
@ -58,7 +57,7 @@ function getPreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null):
return ["", null] return ["", null]
} }
function renderEntry(room: RoomListEntry, hideAvatar: boolean | undefined) { function renderEntry(room: RoomListEntry) {
const [previewText, croppedPreviewText] = getPreviewText(room.preview_event, room.preview_sender) const [previewText, croppedPreviewText] = getPreviewText(room.preview_event, room.preview_sender)
return <> return <>
@ -66,7 +65,7 @@ function renderEntry(room: RoomListEntry, hideAvatar: boolean | undefined) {
<img <img
loading="lazy" loading="lazy"
className="avatar room-avatar" className="avatar room-avatar"
src={getRoomAvatarThumbnailURL(room, undefined, hideAvatar)} src={getRoomAvatarThumbnailURL(room)}
alt="" alt=""
/> />
</div> </div>
@ -78,7 +77,7 @@ function renderEntry(room: RoomListEntry, hideAvatar: boolean | undefined) {
</> </>
} }
const Entry = ({ room, isActive, hidden, hideAvatar }: RoomListEntryProps) => { const Entry = ({ room, isActive, hidden }: RoomListEntryProps) => {
const [isVisible, divRef] = useContentVisibility<HTMLDivElement>() const [isVisible, divRef] = useContentVisibility<HTMLDivElement>()
const openModal = use(ModalContext) const openModal = use(ModalContext)
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
@ -106,7 +105,7 @@ const Entry = ({ room, isActive, hidden, hideAvatar }: RoomListEntryProps) => {
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
data-room-id={room.room_id} data-room-id={room.room_id}
> >
{isVisible ? renderEntry(room, hideAvatar) : null} {isVisible ? renderEntry(room) : null}
</div> </div>
} }

View file

@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useCallback, useRef, useState } from "react" import React, { use, useCallback, useRef, useState } from "react"
import { RoomListFilter, Space as SpaceStore, SpaceUnreadCounts, usePreference } from "@/api/statestore" import { RoomListFilter, Space as SpaceStore, SpaceUnreadCounts } from "@/api/statestore"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts" import { useEventAsState } from "@/util/eventdispatcher.ts"
import reverseMap from "@/util/reversemap.ts" import reverseMap from "@/util/reversemap.ts"
@ -103,7 +103,6 @@ const RoomList = ({ activeRoomID, space }: RoomListProps) => {
} }
} }
const showInviteAvatars = usePreference(client.store, null, "show_invite_avatars")
const roomListFilter = client.store.roomListFilterFunc const roomListFilter = client.store.roomListFilterFunc
return <div className="room-list-wrapper"> return <div className="room-list-wrapper">
<div className="room-search-wrapper"> <div className="room-search-wrapper">
@ -146,7 +145,6 @@ const RoomList = ({ activeRoomID, space }: RoomListProps) => {
isActive={room.room_id === activeRoomID} isActive={room.room_id === activeRoomID}
hidden={roomListFilter ? !roomListFilter(room) : false} hidden={roomListFilter ? !roomListFilter(room) : false}
room={room} room={room}
hideAvatar={room.is_invite && !showInviteAvatars}
/>, />,
)} )}
</div> </div>

View file

@ -16,7 +16,6 @@
import { use, useEffect, useState } from "react" import { use, useEffect, useState } from "react"
import { ScaleLoader } from "react-spinners" import { ScaleLoader } from "react-spinners"
import { getAvatarThumbnailURL, getAvatarURL, getRoomAvatarURL } from "@/api/media.ts" import { getAvatarThumbnailURL, getAvatarURL, getRoomAvatarURL } from "@/api/media.ts"
import { usePreference } from "@/api/statestore/hooks.ts"
import { InvitedRoomStore } from "@/api/statestore/invitedroom.ts" import { InvitedRoomStore } from "@/api/statestore/invitedroom.ts"
import { RoomID, RoomSummary } from "@/api/types" import { RoomID, RoomSummary } from "@/api/types"
import { getDisplayname, getServerName } from "@/util/validation.ts" import { getDisplayname, getServerName } from "@/util/validation.ts"
@ -85,15 +84,13 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
const name = summary?.name ?? summary?.canonical_alias ?? invite?.name ?? invite?.canonical_alias ?? alias ?? roomID const name = summary?.name ?? summary?.canonical_alias ?? invite?.name ?? invite?.canonical_alias ?? alias ?? roomID
const memberCount = summary?.num_joined_members || null const memberCount = summary?.num_joined_members || null
const topic = summary?.topic ?? invite?.topic ?? "" const topic = summary?.topic ?? invite?.topic ?? ""
const showInviteAvatars = usePreference(client.store, null, "show_invite_avatars")
const noAvatarPreview = invite && !showInviteAvatars
return <div className="room-view preview"> return <div className="room-view preview">
<div className="preview-inner"> <div className="preview-inner">
{invite?.invited_by && !invite.dm_user_id ? <div className="inviter-info"> {invite?.invited_by && !invite.dm_user_id ? <div className="inviter-info">
<img <img
className="small avatar" className="small avatar"
onClick={use(LightboxContext)} onClick={use(LightboxContext)}
src={getAvatarThumbnailURL(invite.invited_by, invite.inviter_profile, noAvatarPreview)} src={getAvatarThumbnailURL(invite.invited_by, invite.inviter_profile)}
data-full-src={getAvatarURL(invite.invited_by, invite.inviter_profile)} data-full-src={getAvatarURL(invite.invited_by, invite.inviter_profile)}
alt="" alt=""
/> />
@ -105,8 +102,7 @@ const RoomPreview = ({ roomID, via, alias, invite }: RoomPreviewProps) => {
<h2 className="room-name">{name}</h2> <h2 className="room-name">{name}</h2>
<img <img
// this is a big avatar (120px), use full resolution // this is a big avatar (120px), use full resolution
src={getRoomAvatarURL(invite ?? summary ?? { room_id: roomID }, undefined, false, noAvatarPreview)} src={getRoomAvatarURL(invite ?? summary ?? { room_id: roomID })}
data-full-src={getRoomAvatarURL(invite ?? summary ?? { room_id: roomID })}
className="large avatar" className="large avatar"
onClick={use(LightboxContext)} onClick={use(LightboxContext)}
alt="" alt=""

View file

@ -23,7 +23,6 @@ blockquote.reply-body {
height: calc(var(--small-font-size) * 1.5); height: calc(var(--small-font-size) * 1.5);
border-left: none; border-left: none;
padding: 0; padding: 0;
overflow: hidden;
> div.reply-spine { > div.reply-spine {
margin-top: calc(var(--small-font-size) * 0.75 - 1px); margin-top: calc(var(--small-font-size) * 0.75 - 1px);

View file

@ -246,15 +246,10 @@ div.event-content > div.event-reactions {
} }
} }
blockquote.reply-body.small > div.reply-sender > span.event-sender {
max-width: 10rem;
}
div.small-event > div.sender-avatar, blockquote.reply-body > div.reply-sender > div.sender-avatar { div.small-event > div.sender-avatar, blockquote.reply-body > div.reply-sender > div.sender-avatar {
margin-top: 0; margin-top: 0;
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0;
} }
div.date-separator { div.date-separator {

View file

@ -28,8 +28,6 @@ const URLPreviews = ({ event, room }: {
}) => { }) => {
const client = use(ClientContext)! const client = use(ClientContext)!
const renderPreviews = usePreference(client.store, room, "render_url_previews") const renderPreviews = usePreference(client.store, room, "render_url_previews")
// TODO support blurhashes and clicking to view image previews here?
const showPreviewImages = usePreference(client.store, room, "show_media_previews")
if (event.redacted_by || !renderPreviews) { if (event.redacted_by || !renderPreviews) {
return null return null
} }
@ -74,7 +72,7 @@ const URLPreviews = ({ event, room }: {
<a href={url} title={title} target="_blank" rel="noreferrer noopener">{title}</a> <a href={url} title={title} target="_blank" rel="noreferrer noopener">{title}</a>
</div> </div>
<div className="description" title={p["og:description"]}>{p["og:description"]}</div> <div className="description" title={p["og:description"]}>{p["og:description"]}</div>
{mediaURL && showPreviewImages && (inline {mediaURL && (inline
? <div className="inline-media-wrapper">{mediaContainer}</div> ? <div className="inline-media-wrapper">{mediaContainer}</div>
: mediaContainer)} : mediaContainer)}
</div> </div>

View file

@ -99,8 +99,6 @@ function useChangeDescription(
if (sender === target) { if (sender === target) {
if (prevContent?.membership === "knock") { if (prevContent?.membership === "knock") {
return "cancelled their join request" return "cancelled their join request"
} else if (prevContent?.membership === "invite") {
return "rejected the invite"
} }
return "left the room" return "left the room"
} }

View file

@ -25,21 +25,14 @@ const PolicyRuleBody = ({ event, sender }: EventContentProps) => {
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
const entity = content.entity ?? prevContent?.entity const entity = content.entity ?? prevContent?.entity
const hashedEntity = content["org.matrix.msc4205.hashes"]?.sha256
?? prevContent?.["org.matrix.msc4205.hashes"]?.sha256
const recommendation = content.recommendation ?? prevContent?.recommendation const recommendation = content.recommendation ?? prevContent?.recommendation
if ((!entity && !hashedEntity) || !recommendation) { if (!entity || !recommendation) {
return <div className="policy-body"> return <div className="policy-body">
{getDisplayname(event.sender, sender?.content)} sent an invalid policy rule {getDisplayname(event.sender, sender?.content)} sent an invalid policy rule
</div> </div>
} }
const target = event.type.replace(/^m\.policy\.rule\./, "")
let entityElement = <>{entity}</> let entityElement = <>{entity}</>
let matchingWord = `${target}s matching` if(event.type === "m.policy.rule.user" && !entity?.includes("*") && !entity?.includes("?")) {
if (!entity && hashedEntity) {
matchingWord = `the ${target} with hash`
entityElement = <>{hashedEntity}</>
} else if (event.type === "m.policy.rule.user" && entity && !entity.includes("*") && !entity.includes("?")) {
entityElement = ( entityElement = (
<a <a
className="hicli-matrix-uri hicli-matrix-uri-user" className="hicli-matrix-uri hicli-matrix-uri-user"
@ -51,18 +44,16 @@ const PolicyRuleBody = ({ event, sender }: EventContentProps) => {
{entity} {entity}
</a> </a>
) )
matchingWord = "user"
} }
let recommendationElement: JSX.Element | string = <code>{recommendation}</code> let recommendationElement: JSX.Element | string = <code>{recommendation}</code>
if (recommendation === "m.ban") { if (recommendation === "m.ban") {
recommendationElement = "ban" recommendationElement = "ban"
} else if (recommendation === "org.matrix.msc4204.takedown") {
recommendationElement = "takedown"
} }
const action = prevContent ? ((content.entity && content.recommendation) ? "updated" : "removed") : "added" const action = prevContent ? ((content.entity && content.recommendation) ? "updated" : "removed") : "added"
const target = event.type.replace(/^m\.policy\.rule\./, "")
return <div className="policy-body"> return <div className="policy-body">
{getDisplayname(event.sender, sender?.content)} {action} a {recommendationElement} rule {getDisplayname(event.sender, sender?.content)} {action} a {recommendationElement} rule
for {matchingWord} <code>{entityElement}</code> for {target}s matching <code>{entityElement}</code>
{content.reason ? <> for <code>{content.reason}</code></> : null} {content.reason ? <> for <code>{content.reason}</code></> : null}
</div> </div>
} }

View file

@ -33,7 +33,7 @@ const elementCallParams = new URLSearchParams({
appPrompt: "false", appPrompt: "false",
}).toString().replaceAll("%24", "$") }).toString().replaceAll("%24", "$")
const ElementCall = ({ onClose }: { onClose?: () => void }) => { const ElementCall = () => {
const room = use(RoomContext)?.store ?? null const room = use(RoomContext)?.store ?? null
const client = use(ClientContext)! const client = use(ClientContext)!
const baseURL = usePreference(client.store, room, "element_call_base_url") const baseURL = usePreference(client.store, room, "element_call_base_url")
@ -51,7 +51,7 @@ const ElementCall = ({ onClose }: { onClose?: () => void }) => {
if (!room || !client) { if (!room || !client) {
return null return null
} }
return <LazyWidget info={widgetInfo} onClose={onClose} /> return <LazyWidget info={widgetInfo} />
} }
export default ElementCall export default ElementCall

View file

@ -28,10 +28,9 @@ const widgetLoader = <div className="widget-container widget-loading">
export interface LazyWidgetProps { export interface LazyWidgetProps {
info: IWidget info: IWidget
onClose?: () => void
} }
const LazyWidget = ({ info, onClose }: LazyWidgetProps) => { const LazyWidget = ({ info }: LazyWidgetProps) => {
const room = use(RoomContext)?.store const room = use(RoomContext)?.store
const client = use(ClientContext) const client = use(ClientContext)
if (!room || !client) { if (!room || !client) {
@ -39,7 +38,7 @@ const LazyWidget = ({ info, onClose }: LazyWidgetProps) => {
} }
return ( return (
<Suspense fallback={widgetLoader}> <Suspense fallback={widgetLoader}>
<Widget info={info} room={room} client={client} onClose={onClose} /> <Widget info={info} room={room} client={client} />
</Suspense> </Suspense>
) )
} }

View file

@ -1,111 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 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 <https://www.gnu.org/licenses/>.
import { MatrixCapabilities } from "matrix-widget-api"
import { use, useState } from "react"
import { ModalCloseContext } from "../modal"
interface PermissionPromptProps {
capabilities: Set<string>
onConfirm: (approvedCapabilities: Set<string>) => void
}
const getCapabilityName = (capability: string): string => {
const paramIdx = capability.indexOf(":")
const capabilityID = paramIdx === -1 ? capability : capability.slice(0, paramIdx)
const parameter = paramIdx === -1 ? null : capability.slice(paramIdx + 1)
// Map capability IDs to human-readable names
const capabilityNames: Record<string, string> = {
[MatrixCapabilities.MSC2931Navigate]: "Navigate to other rooms",
[MatrixCapabilities.MSC3846TurnServers]: "Request TURN servers from the homeserver",
[MatrixCapabilities.MSC4157SendDelayedEvent]: "Send delayed events",
[MatrixCapabilities.MSC4157UpdateDelayedEvent]: "Update delayed events",
[MatrixCapabilities.MSC4039UploadFile]: "Upload files",
[MatrixCapabilities.MSC4039DownloadFile]: "Download files",
"org.matrix.msc2762.timeline": "Read room history",
"org.matrix.msc2762.send.event": "Send timeline events",
"org.matrix.msc2762.receive.event": "Receive timeline events",
"org.matrix.msc2762.send.state_event": "Send state events",
"org.matrix.msc2762.receive.state_event": "Receive state events",
"org.matrix.msc3819.send.to_device": "Send to-device events",
"org.matrix.msc3819.receive.to_device": "Receive to-device events",
}
const name = capabilityNames[capabilityID] || capabilityID
if (parameter) {
return `${name} (${parameter})`
}
return name
}
const PermissionPrompt = ({ capabilities, onConfirm }: PermissionPromptProps) => {
const [selectedCapabilities, setSelectedCapabilities] = useState<Set<string>>(() => new Set(capabilities))
const closeModal = use(ModalCloseContext)
const handleToggleCapability = (capability: string) => {
const newCapabilities = new Set(selectedCapabilities)
if (newCapabilities.has(capability)) {
newCapabilities.delete(capability)
} else {
newCapabilities.add(capability)
}
setSelectedCapabilities(newCapabilities)
}
const doConfirm = () => {
onConfirm(selectedCapabilities)
closeModal()
}
const doReject = () => {
onConfirm(new Set())
closeModal()
}
return <>
<h2>Widget Permissions</h2>
<p>This widget is requesting the following permissions:</p>
<div className="capability-list">
{Array.from(capabilities).map((capability) => (
<div key={capability} className="capability-item">
<label>
<input
type="checkbox"
checked={selectedCapabilities.has(capability)}
onChange={() => handleToggleCapability(capability)}
/>
{getCapabilityName(capability)}
</label>
</div>
))}
</div>
<div className="permission-actions">
<button onClick={doReject}>Reject all</button>
<button
onClick={doConfirm}
className="confirm-button"
>
Accept selected
</button>
</div>
</>
}
export default PermissionPrompt

View file

@ -1,25 +1,9 @@
div.right-panel-content.widget, div.right-panel-content.element-call { div.right-panel-content.widget, div.right-panel-content.element-call {
overflow: hidden !important; overflow: hidden !important;
} > iframe.widget-iframe {
width: 100%;
iframe.widget-iframe { height: 100%;
width: 100%; border: none;
height: 100%;
border: none;
}
div.permission-prompt {
> h2 {
margin: 0;
}
> div.permission-actions {
display: flex;
justify-content: space-between;
> button {
padding: .5rem 1rem;
}
} }
} }

View file

@ -19,7 +19,6 @@ import type Client from "@/api/client"
import type { RoomStateStore, WidgetListener } from "@/api/statestore" import type { RoomStateStore, WidgetListener } from "@/api/statestore"
import type { MemDBEvent, RoomID, SyncToDevice } from "@/api/types" import type { MemDBEvent, RoomID, SyncToDevice } from "@/api/types"
import { getDisplayname } from "@/util/validation" import { getDisplayname } from "@/util/validation"
import PermissionPrompt from "./PermissionPrompt"
import { memDBEventToIRoomEvent } from "./util" import { memDBEventToIRoomEvent } from "./util"
import GomuksWidgetDriver from "./widgetDriver" import GomuksWidgetDriver from "./widgetDriver"
import "./Widget.css" import "./Widget.css"
@ -28,7 +27,6 @@ export interface WidgetProps {
info: IWidget info: IWidget
room: RoomStateStore room: RoomStateStore
client: Client client: Client
onClose?: () => void
} }
// TODO remove this after widgets start using a parameter for it // TODO remove this after widgets start using a parameter for it
@ -70,24 +68,9 @@ class WidgetListenerImpl implements WidgetListener {
} }
} }
const openPermissionPrompt = (requested: Set<string>): Promise<Set<string>> => { const ReactWidget = ({ room, info, client }: WidgetProps) => {
return new Promise(resolve => {
window.openModal({
content: <PermissionPrompt
capabilities={requested}
onConfirm={resolve}
/>,
dimmed: true,
boxed: true,
noDismiss: true,
innerBoxClass: "permission-prompt",
})
})
}
const ReactWidget = ({ room, info, client, onClose }: WidgetProps) => {
const wrappedWidget = new WrappedWidget(info) const wrappedWidget = new WrappedWidget(info)
const driver = new GomuksWidgetDriver(client, room, openPermissionPrompt) const driver = new GomuksWidgetDriver(client, room)
const widgetURL = addLegacyParams(wrappedWidget.getCompleteUrl({ const widgetURL = addLegacyParams(wrappedWidget.getCompleteUrl({
widgetRoomId: room.roomID, widgetRoomId: room.roomID,
currentUserId: client.userID, currentUserId: client.userID,
@ -109,16 +92,13 @@ const ReactWidget = ({ room, info, client, onClose }: WidgetProps) => {
evt.preventDefault() evt.preventDefault()
clientAPI.transport.reply(evt.detail, {}) clientAPI.transport.reply(evt.detail, {})
} }
const closeWidget = (evt: CustomEvent) => {
noopReply(evt)
onClose?.()
}
clientAPI.on("action:io.element.join", noopReply) clientAPI.on("action:io.element.join", noopReply)
clientAPI.on("action:im.vector.hangup", noopReply) clientAPI.on("action:im.vector.hangup", noopReply)
clientAPI.on("action:io.element.device_mute", noopReply) clientAPI.on("action:io.element.device_mute", noopReply)
clientAPI.on("action:io.element.tile_layout", noopReply) clientAPI.on("action:io.element.tile_layout", noopReply)
clientAPI.on("action:io.element.spotlight_layout", noopReply) clientAPI.on("action:io.element.spotlight_layout", noopReply)
clientAPI.on("action:io.element.close", closeWidget) // TODO handle this one?
clientAPI.on("action:io.element.close", noopReply)
clientAPI.on("action:set_always_on_screen", noopReply) clientAPI.on("action:set_always_on_screen", noopReply)
const removeListener = client.addWidgetListener(new WidgetListenerImpl(clientAPI)) const removeListener = client.addWidgetListener(new WidgetListenerImpl(clientAPI))

View file

@ -19,13 +19,11 @@ import {
IOpenIDUpdate, IOpenIDUpdate,
IRoomAccountData, IRoomAccountData,
IRoomEvent, IRoomEvent,
ISendDelayedEventDetails,
ISendEventDetails, ISendEventDetails,
ITurnServer, ITurnServer,
OpenIDRequestState, OpenIDRequestState,
SimpleObservable, SimpleObservable,
Symbols, Symbols,
UpdateDelayedEventAction,
WidgetDriver, WidgetDriver,
} from "matrix-widget-api" } from "matrix-widget-api"
import Client from "@/api/client.ts" import Client from "@/api/client.ts"
@ -37,16 +35,12 @@ class GomuksWidgetDriver extends WidgetDriver {
private openIDToken: IOpenIDCredentials | null = null private openIDToken: IOpenIDCredentials | null = null
private openIDExpiry: number | null = null private openIDExpiry: number | null = null
constructor( constructor(private client: Client, private room: RoomStateStore) {
private client: Client,
private room: RoomStateStore,
private openPermissionPrompt: (requested: Set<string>) => Promise<Set<string>>,
) {
super() super()
} }
async validateCapabilities(requested: Set<string>): Promise<Set<string>> { async validateCapabilities(requested: Set<string>): Promise<Set<string>> {
return this.openPermissionPrompt(requested) return new Set(requested)
} }
async sendEvent( async sendEvent(
@ -68,31 +62,23 @@ class GomuksWidgetDriver extends WidgetDriver {
} }
} }
async sendDelayedEvent( // async sendDelayedEvent(
delay: number | null, // delay: number | null,
parentDelayID: string | null, // parentDelayID: string | null,
eventType: string, // eventType: string,
content: unknown, // content: unknown,
stateKey: string | null = null, // stateKey: string | null = null,
roomID: string | null = null, // roomID: string | null = null,
): Promise<ISendDelayedEventDetails> { // ): Promise<ISendDelayedEventDetails> {
if (!isRecord(content)) { // if (!isRecord(content)) {
throw new Error("Content must be an object") // throw new Error("Content must be an object")
} else if (stateKey === null) { // }
throw new Error("Non-state delayed events are not supported") // throw new Error("Delayed events are not supported")
} else if (parentDelayID !== null) { // }
throw new Error("Parent delayed events are not supported")
} else if (!delay) {
throw new Error("Delay must be a number")
}
roomID = roomID ?? this.room.roomID
const delayID = await this.client.rpc.setState(roomID, eventType, stateKey, content, { delay_ms: delay })
return { delayId: delayID, roomId: roomID }
}
async updateDelayedEvent(delayID: string, action: UpdateDelayedEventAction): Promise<void> { // async updateDelayedEvent(delayID: string, action: UpdateDelayedEventAction): Promise<void> {
await this.client.rpc.updateDelayedEvent(delayID, action) // throw new Error("Delayed events are not supported")
} // }
async sendToDevice( async sendToDevice(
eventType: string, eventType: string,

View file

@ -17,7 +17,6 @@ declare global {
gcSettings: GCSettings gcSettings: GCSettings
hackyOpenEventContextMenu?: string hackyOpenEventContextMenu?: string
closeModal: () => void closeModal: () => void
openModal: openModal
openNestableModal: openModal openNestableModal: openModal
gomuksAndroid?: true gomuksAndroid?: true
} }