1
0
Fork 0
forked from Mirrors/gomuks

Compare commits

...
Sign in to create a new pull request.

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
53 changed files with 10123 additions and 2 deletions

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()
} }

6
go.mod
View file

@ -11,6 +11,7 @@ require (
github.com/coder/websocket v1.8.12 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,6 +19,7 @@ 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/mauview v0.2.2-0.20241209125653-292e0a6914c5
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95 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
@ -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/mattn/go-runewidth v0.0.16 // indirect
github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a // 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
github.com/zyedidia/clipboard v1.0.4 // indirect
golang.org/x/exp v0.0.0-20250215185904-eff6e970281f // indirect golang.org/x/exp v0.0.0-20250215185904-eff6e970281f // indirect
golang.org/x/sys v0.30.0 // 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
) )

47
go.sum
View file

@ -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,6 +44,9 @@ 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-20250211185408-f2b9d978cd7a h1:ckxP/kGzsxvxXo8jO6E/0QJ8MMmwI7IRj4Fys9QbAZA= github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a h1:ckxP/kGzsxvxXo8jO6E/0QJ8MMmwI7IRj4Fys9QbAZA=
@ -47,6 +54,8 @@ github.com/petermattis/goid v0.0.0-20250211185408-f2b9d978cd7a/go.mod h1:pxMtw7c
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,14 +75,21 @@ 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=
github.com/zyedidia/clipboard v1.0.4 h1:r6GUQOyPtIaApRLeD56/U+2uJbXis6ANGbKWCljULEo=
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 h1:5EfVWWjU2Hte9uE6B/hBgvjnVfBx/7SYDZBnsuo+EBs=
go.mau.fi/util v0.8.6-0.20250227184636-7ff63b0b9d95/go.mod h1:Ycug9mrbztlahHPEJ6H5r8Nu/xqZaWbE5vPHVWmfz6M= 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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.34.0 h1:+/C6tk6rf/+t5DhUketUbD1aNGqiSX3j15Z6xuIDlBA= golang.org/x/crypto v0.34.0 h1:+/C6tk6rf/+t5DhUketUbD1aNGqiSX3j15Z6xuIDlBA=
golang.org/x/crypto v0.34.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 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 h1:oFMYAjX0867ZD2jcNiLBrI9BdpmEkvPyi5YrBGXbamg=
@ -81,17 +97,48 @@ golang.org/x/exp v0.0.0-20250215185904-eff6e970281f/go.mod h1:BHOTPb3L19zxehTsLo
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.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= 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.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 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 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.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 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/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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 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=

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")
if gmx.TUI != nil {
gmx.TUI.Run()
} else {
gmx.WaitForInterrupt() 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

@ -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"

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)
}