1
0
Fork 0
forked from Mirrors/gomuks

Compare commits

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

104 commits

Author SHA1 Message Date
FIGBERT
11f6cb9d0e
Revert "Attempt new headless sync implementation"
This reverts commit 05ccf81e57.
2023-09-18 00:47:38 -07:00
FIGBERT
05ccf81e57
Attempt new headless sync implementation 2023-09-18 00:43:09 -07:00
FIGBERT
c3920a41b6
Revert "Disable stream response in headless sync"
This reverts commit fe0bdb225b.
2023-09-17 20:22:33 -07:00
FIGBERT
fe0bdb225b
Disable stream response in headless sync 2023-09-17 20:13:15 -07:00
FIGBERT
190eb0cdba
Don't run start when headless 2023-09-15 14:36:29 -07:00
FIGBERT
3c5a97576f
Update artifact path for macOS ARM 2023-09-12 23:09:17 -07:00
FIGBERT
d495126371
Name gomuks binaries based on platform 2023-09-12 23:02:55 -07:00
FIGBERT
f96643230f
Run publishing after builds
Oops
2023-09-12 19:55:50 -07:00
FIGBERT
e72b7f91ef
Add a commit tag pointing to the branch 2023-09-12 19:20:27 -07:00
FIGBERT
3f1484ea4d
Use a tag instead of a commit hash 2023-09-12 19:18:43 -07:00
FIGBERT
b177f2dde4
Provide a commit hash to the publishing step 2023-09-12 16:13:42 -07:00
FIGBERT
993e0e5f33
Add CI step to push a release after building 2023-09-12 16:08:04 -07:00
FIGBERT
9b14639657
Rename previous workflow 2023-09-11 23:09:10 -07:00
FIGBERT
d40649e9cc
Add build workflow (based on Beepy CLI) 2023-09-11 23:07:55 -07:00
FIGBERT
85663fccc7
Add print-log-path flag with early return 2023-09-07 09:58:59 -07:00
FIGBERT
d8b1fb40dc
Fix goroutine double lock error 2023-08-29 23:20:59 -07:00
FIGBERT
58b212913c
Revert sync changes
This reverts commits:
  948bf767bc
  8fcbdddc62
2023-08-25 13:22:03 -07:00
FIGBERT
ad1fd015cb
Use new Beeper JWT login when headless 2023-08-24 10:47:14 -07:00
FIGBERT
7e25710506
Use Beeper JWT login 2023-08-23 22:55:57 -07:00
FIGBERT
8fcbdddc62
Move headless sync stop to InitDoneCallback 2023-08-17 10:49:01 -07:00
FIGBERT
948bf767bc
Use more generic sync process in headless mode 2023-08-17 10:30:18 -07:00
FIGBERT
634a3350d5
Standardize around "Recovery Code" language 2023-08-16 17:00:47 -07:00
FIGBERT
8a1e095f58
Return errors from sync in headless mode 2023-08-14 13:34:54 -07:00
FIGBERT
3b333aef02
Don't run UI code in HandleMessage when headless 2023-08-13 02:01:15 -07:00
FIGBERT
fb10f59801
Move sync before verification in headless mode 2023-08-09 23:50:31 -07:00
FIGBERT
f6f1a906d0
Delegate headless homeserver to configuration 2023-08-09 13:31:04 -07:00
FIGBERT
a55f78d628
Close update channel on headless return (again)
The issue was elsewhere nevermind
2023-08-09 11:31:53 -07:00
FIGBERT
80c8cd62ef
Return error instead of custom alias from headless 2023-08-09 11:31:20 -07:00
FIGBERT
b7a4d58637
Revert close channel change
I do not know why but this broke everything in beepy
2023-08-09 11:05:51 -07:00
FIGBERT
2361a5fa96
Close update channel on headless return 2023-08-09 10:53:00 -07:00
FIGBERT
df0ff2035e
Rework headless public message API 2023-08-08 23:57:35 -07:00
FIGBERT
97154a6787
Remove tautology 2023-08-08 19:59:32 -07:00
FIGBERT
ab18e4c28d
Add update channel to headless initialization 2023-08-08 19:47:19 -07:00
FIGBERT
7d139c50d6
Enforce modern displaymode in headless init 2023-08-08 18:43:23 -07:00
FIGBERT
97d2e77a8e
Remove temporary progress bar logs 2023-08-08 18:34:33 -07:00
FIGBERT
507aa3c61c
Use recovery code with all verification methods
The previous commit made one attempt at fixing an issue with verifying
keys, but was misguided: the issue at hand was not in attempting the
wrong method of authorization, but rather what was *passed* to the
method. Namely, the account password as opposed to the recovery phrase.
Regardless of terminology, the latter should be used. Certain code has
been restored, while the password parameter remains deleted.
2023-08-08 18:15:52 -07:00
FIGBERT
b8a41425bd
Use recovery phrase to verify in headless client 2023-08-08 17:37:45 -07:00
FIGBERT
c628bfb97c
Configure MxID and homeserver in headless startup 2023-08-08 16:56:57 -07:00
FIGBERT
5db39fd50a
Initialize client in headless Matrix client 2023-08-08 16:24:30 -07:00
FIGBERT
b1c940a0a8
Add sync implementation to headless.go 2023-08-08 14:22:29 -07:00
FIGBERT
704fc53db1
Change headless from flag to subpackage 2023-08-06 22:01:40 -07:00
FIGBERT
edda1a956a
Move directory logic to init backend 2023-08-05 21:28:27 -07:00
FIGBERT
4ebdb0fd38
Break init backend into its own package 2023-08-05 19:49:53 -07:00
FIGBERT
8889e2df54
Add debug logs to sync progress bar 2023-07-23 14:45:58 +03:00
FIGBERT
477326228e
Merge branch 'master' into beepberry 2023-07-16 09:23:04 +03:00
FIGBERT
5309f3c158
Merge branch 'master' into beepberry 2023-07-16 09:21:37 +03:00
FIGBERT
c399f01227
Fix device link in recommended reading 2023-07-16 02:37:15 +03:00
FIGBERT
7aa90a9a36
Import keys at start of Beepberry flow 2023-07-06 19:39:23 +03:00
FIGBERT
7251f684c9
Set modern displaymode when running new login flow 2023-07-06 00:06:38 +03:00
FIGBERT
86659503f3
Flash Beepberry LED on message receive
Colors are mapped symbolically as follows:
    White  -> Default
    Green  -> Service
    Red    -> Redaction
    Yellow -> Edit
    Purple -> Reaction
2023-07-05 00:45:50 +03:00
FIGBERT
0714fec38b
Add LED controls in ui/beepberry subpackage 2023-07-01 21:44:28 +03:00
FIGBERT
68cfe2be80
Don't respond to keys in roster when headless 2023-06-24 13:00:01 +03:00
FIGBERT
6947f2c03c
Exit gracefully if the transfer directory already exists 2023-06-24 12:44:17 +03:00
FIGBERT
89dd9f9a6a
Disable replies if a command's room is nil
This is implemented to prevent crashes on headless start, where
verification is performed without a specific room.
2023-06-24 12:42:03 +03:00
FIGBERT
e4c71fde7c
Add verification to headless log-in flow 2023-06-24 12:07:52 +03:00
FIGBERT
8ed5a4b1dd
Add headless initial log-in flow
This offloads a lot of intial processing if you're planning on running
gomuks on low-power hardware, but have access to high-power hardware for
the sync (see: large accounts on the beepberry).

A few planned improvements to this mode:
	- Exit gracefully if the transfer directory already exists
	- Make sure key presses are disabled during sync
	- Add verification to flow
2023-06-24 00:54:08 +03:00
FIGBERT
2722459f22
Restore ability to scroll inbox 2023-06-17 17:45:20 -07:00
FIGBERT
a6d6f7af04
Initial split inbox view implementation
This has one serious regression from the previous inbox view, which is a
lack of scrolling. The re-implementation of scrolling is in progress.
2023-06-15 22:18:25 -07:00
FIGBERT
a9815e3b54
Rename GetMostRecentEvent for clarity 2023-06-13 17:25:54 -07:00
FIGBERT
02e1371f37
Simplify range statement in room.go 2023-06-12 18:56:16 -07:00
FIGBERT
2dbe60384f
Add method to return latest state event of type 2023-06-12 18:53:39 -07:00
FIGBERT
cbb17effbf
Add Alt+Backspace escape equivalent for Beepberry 2023-05-03 09:34:03 -07:00
FIGBERT
1328aa82e3
Update UI on /escape 2023-05-03 09:34:03 -07:00
FIGBERT
706375b5a1
Add textual selection indicator in modern mode 2023-05-03 09:34:02 -07:00
FIGBERT
7e1f8bcc59
Italicize selected room in roster view 2023-05-03 09:34:02 -07:00
FIGBERT
73b0d3f1a2
Add an escape command for modern display mode 2023-05-03 09:33:55 -07:00
FIGBERT
27160c1fc6
Increase message width in modern display mode 2023-05-03 09:33:00 -07:00
FIGBERT
50acd2474a
Add vim-like top/bottom keybinds to roster view 2023-05-03 09:33:00 -07:00
FIGBERT
03f8db40a0
Remove scroll looping 2023-05-03 09:32:59 -07:00
FIGBERT
81018c2da7
Keep selected room on screen when scrolling 2023-05-03 09:32:59 -07:00
FIGBERT
04ceba153f
Fix reaction rendering in modern display mode 2023-05-03 09:32:59 -07:00
FIGBERT
3b26a8fbd1
Enforce title bar styling on display mode switch 2023-05-03 09:32:59 -07:00
FIGBERT
6bb265cc66
Add continuous scroll to rooms in roster view 2023-05-03 09:32:59 -07:00
FIGBERT
b9b363e686
Add edit indicator in modern display mode 2023-05-03 09:32:58 -07:00
FIGBERT
7a83ebd7f4
Add Bump implementation to roster view 2023-05-03 09:32:58 -07:00
FIGBERT
22acad8287
Synchronize access to roster view room list 2023-05-03 09:32:58 -07:00
FIGBERT
b9529e39e1
Adapt click behavior for modern username placement 2023-05-03 09:32:58 -07:00
FIGBERT
abfcdae4ef
Fix highlight height in modern mode 2023-05-03 09:32:58 -07:00
FIGBERT
d4e820579c
Render reactions properly in modern view 2023-05-03 09:32:57 -07:00
FIGBERT
7a2f907528
Open rooms on click from roster view 2023-05-03 09:32:57 -07:00
FIGBERT
99bd36f216
Forward mouse events to the roster view 2023-05-03 09:32:57 -07:00
FIGBERT
9cecf0bd02
Move utility functions above mauview interfaces 2023-05-03 09:32:57 -07:00
FIGBERT
0af8d507e1
Forward roster key events to room when focused 2023-05-03 09:32:57 -07:00
FIGBERT
dc5632e946
Move chat view username and timestamp inline 2023-05-03 09:32:56 -07:00
FIGBERT
bd2c06e417
Add comment about title styling bug 2023-05-03 09:32:56 -07:00
FIGBERT
3f01535cdf
Render topic view in modern style 2023-05-03 09:32:56 -07:00
FIGBERT
80638d4a5b
Make topic bar two high in modern display mode 2023-05-03 09:32:56 -07:00
FIGBERT
6d4d6b7d20
Display title in topic bar in modern room view UI 2023-05-03 09:32:56 -07:00
FIGBERT
7fffa994af
Hide user list in modern room view UI 2023-05-03 09:32:55 -07:00
FIGBERT
1eb132a589
Add RoomView previews to roster view 2023-05-03 09:32:55 -07:00
FIGBERT
f6b722f523
Add quit keybind to roster view 2023-05-03 09:32:55 -07:00
FIGBERT
a5ac5ec86e
Add keybindings to roster view 2023-05-03 09:32:55 -07:00
FIGBERT
167b4a497b
Use border utilities to draw horizontal rule 2023-05-03 09:32:55 -07:00
FIGBERT
572bc357cb
Constrain width of messages in roster view 2023-05-03 09:32:54 -07:00
FIGBERT
78cda42654
Retrieve the most recent message accurately 2023-05-03 09:32:54 -07:00
FIGBERT
43b939f567
Add initial message preview to roster view 2023-05-03 09:32:54 -07:00
FIGBERT
24c0e66944
Fix timestamp rounding 2023-05-03 09:32:54 -07:00
FIGBERT
2a01fdb559
Add pretty timestamp to rooms in RosterView 2023-05-03 09:32:54 -07:00
FIGBERT
32665a2e5a
Sort rooms in RosterView by most recent message 2023-05-03 09:32:53 -07:00
FIGBERT
1a18a7e89b
Add header to RosterView 2023-05-03 09:32:53 -07:00
FIGBERT
5fae3fedb9
Add rudimentary RosterView 2023-05-03 09:32:53 -07:00
FIGBERT
1f62926e0e
Update DisplayMode toggle message 2023-05-03 09:32:53 -07:00
FIGBERT
a2432d031f
Add DisplayMode to UserPreferences 2023-05-03 09:32:53 -07:00
FIGBERT
b086d68813
Update help page to better document existing flags 2023-05-03 09:32:52 -07:00
27 changed files with 1713 additions and 259 deletions

View file

@ -1,4 +1,4 @@
name: Go
name: Lint + Test
on: [push, pull_request]

99
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,99 @@
name: Build gomuks
on:
push:
paths-ignore:
- "**.md"
pull_request:
paths-ignore:
- "**.md"
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
macos_x86:
runs-on: macos-latest
name: Build on macOS (x86_64)
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
- name: Install libolm
run: brew install libolm
- name: Build CLI
run: ./build.sh && mv gomuks gomuks-macos-x86
- name: Upload macOS Build Artifacts
uses: actions/upload-artifact@v3
with:
name: macOS-x86_64
path: gomuks-macos-x86
if-no-files-found: error
macos_arm:
runs-on: macos-latest
name: Build on macOS (ARM)
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
- name: Install libolm
run: brew fetch --force --bottle-tag=arm64_monterey libolm && brew install $(brew --cache --bottle-tag=arm64_monterey libolm)
- name: Build CLI
run: CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 ./build.sh
- name: Update RPATH
run: |
install_name_tool -change /usr/local/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks
install_name_tool -add_rpath @executable_path gomuks
install_name_tool -add_rpath /opt/homebrew/opt/libolm/lib gomuks
install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks
- name: Rename binary
run: mv gomuks gomuks-macos-arm
- name: Upload macOS Build Artifacts
uses: actions/upload-artifact@v3
with:
name: macOS-ARM
path: gomuks-macos-arm
if-no-files-found: error
linux:
runs-on: ubuntu-latest
strategy:
matrix:
arch: [x86_64, aarch64, armv7]
steps:
- uses: actions/checkout@v3
- name: Setup Alpine (${{ matrix.arch }})
uses: jirutka/setup-alpine@v1
with:
arch: ${{ matrix.arch }}
packages: >
go
git
build-base
- name: Fetch libolm
run: git clone --single-branch --branch 3.2.14 https://gitlab.matrix.org/matrix-org/olm.git /olm
shell: alpine.sh --root {0}
- name: Install libolm
run: cd /olm && CFLAGS=-static-libgcc CPPFLAGS="-static-libgcc -static-libstdc++" make install
shell: alpine.sh --root {0}
- name: Build CLI
run: ./build.sh && mv gomuks gomuks-linux-${{ matrix.arch }}
shell: alpine.sh {0}
- name: Upload a Build Artifact
uses: actions/upload-artifact@v3
with:
name: linux-${{ matrix.arch }}
path: gomuks-linux-${{ matrix.arch }}
if-no-files-found: error
publish:
runs-on: ubuntu-latest
needs: [macos_x86, macos_arm, linux]
steps:
- uses: actions/download-artifact@v3
- uses: ncipollo/release-action@v1
with:
artifacts: "macOS-*/*,linux-*/*"
body: The most recent gomuks binaries
name: "Nightly"
tag: nightly
commit: beepberry
allowUpdates: true
artifactErrorsFailBuild: true
makeLatest: true

80
beeper/internal.go Normal file
View file

@ -0,0 +1,80 @@
package beeper
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"maunium.net/go/mautrix"
)
var cli = &http.Client{Timeout: 30 * time.Second}
func newRequest(token, method, path string) *http.Request {
req := &http.Request{
URL: &url.URL{
Scheme: "https",
Host: "api.beeper.com",
Path: path,
},
Method: method,
Header: http.Header{
"Authorization": {fmt.Sprintf("Bearer %s", token)},
"User-Agent": {mautrix.DefaultUserAgent},
},
}
if method == http.MethodPut || method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
return req
}
func encodeContent(into *http.Request, body any) error {
var buf bytes.Buffer
err := json.NewEncoder(&buf).Encode(body)
if err != nil {
return fmt.Errorf("failed to encode request: %w", err)
}
into.Body = io.NopCloser(&buf)
return nil
}
func doRequest(req *http.Request, reqData, resp any) (err error) {
if reqData != nil {
err = encodeContent(req, reqData)
if err != nil {
return
}
}
r, err := cli.Do(req)
if err != nil {
return fmt.Errorf("failed to send request: %w", err)
}
defer r.Body.Close()
if r.StatusCode < 200 || r.StatusCode >= 300 {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if body != nil {
retryCount, ok := body["retries"].(float64)
if ok && retryCount > 0 && r.StatusCode == 403 && req.URL.Path == "/user/login/response" {
return fmt.Errorf("%w (%d retries left)", ErrInvalidLoginCode, int(retryCount))
}
errorMsg, ok := body["error"].(string)
if ok {
return fmt.Errorf("server returned error (HTTP %d): %s", r.StatusCode, errorMsg)
}
}
return fmt.Errorf("unexpected status code %d", r.StatusCode)
}
if resp != nil {
err = json.NewDecoder(r.Body).Decode(resp)
if err != nil {
return fmt.Errorf("error decoding response: %w", err)
}
}
return nil
}

59
beeper/login.go Normal file
View file

@ -0,0 +1,59 @@
package beeper
import (
"bytes"
"fmt"
"io"
"net/http"
"time"
)
type RespStartLogin struct {
RequestID string `json:"request"`
Type []string `json:"type"`
Expires time.Time `json:"expires"`
}
type ReqSendLoginEmail struct {
RequestID string `json:"request"`
Email string `json:"email"`
}
type ReqSendLoginCode struct {
RequestID string `json:"request"`
Code string `json:"response"`
}
type RespSendLoginCode struct {
LoginToken string `json:"token"`
}
var ErrInvalidLoginCode = fmt.Errorf("invalid login code")
const loginAuth = "BEEPER-PRIVATE-API-PLEASE-DONT-USE"
func StartLogin() (resp *RespStartLogin, err error) {
req := newRequest(loginAuth, http.MethodPost, "/user/login")
req.Body = io.NopCloser(bytes.NewReader([]byte("{}")))
err = doRequest(req, nil, &resp)
return
}
func SendLoginEmail(request, email string) error {
req := newRequest(loginAuth, http.MethodPost, "/user/login/email")
reqData := &ReqSendLoginEmail{
RequestID: request,
Email: email,
}
return doRequest(req, reqData, nil)
}
func SendLoginCode(request, code string) (resp *RespSendLoginCode, err error) {
req := newRequest(loginAuth, http.MethodPost, "/user/login/response")
reqData := &ReqSendLoginCode{
RequestID: request,
Code: code,
}
err = doRequest(req, reqData, &resp)
return
}

View file

@ -62,6 +62,8 @@ type UserPreferences struct {
AltEnterToSend bool `yaml:"alt_enter_to_send"`
InlineURLMode string `yaml:"inline_url_mode"`
DisplayMode DisplayMode `yaml:"display_mode"`
}
var InlineURLsProbablySupported bool
@ -88,6 +90,7 @@ type Keybind struct {
type ParsedKeybindings struct {
Main map[Keybind]string
Roster map[Keybind]string
Room map[Keybind]string
Modal map[Keybind]string
Visual map[Keybind]string
@ -95,6 +98,7 @@ type ParsedKeybindings struct {
type RawKeybindings struct {
Main map[string]string `yaml:"main,omitempty"`
Roster map[string]string `yaml:"roster,omitempty"`
Room map[string]string `yaml:"room,omitempty"`
Modal map[string]string `yaml:"modal,omitempty"`
Visual map[string]string `yaml:"visual,omitempty"`
@ -275,6 +279,7 @@ func (config *Config) LoadKeybindings() {
_ = config.load("keybindings", config.Dir, "keybindings.yaml", &inputConfig)
config.Keybindings.Main = parseKeybindings(inputConfig.Main)
config.Keybindings.Roster = parseKeybindings(inputConfig.Roster)
config.Keybindings.Room = parseKeybindings(inputConfig.Room)
config.Keybindings.Modal = parseKeybindings(inputConfig.Modal)
config.Keybindings.Visual = parseKeybindings(inputConfig.Visual)

24
config/display-mode.go Normal file
View file

@ -0,0 +1,24 @@
// 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 config
type DisplayMode string
const (
DisplayModeIRC DisplayMode = "irc"
DisplayModeModern DisplayMode = "modern"
)

View file

@ -15,6 +15,19 @@ main:
'Alt+a': next_active_room
'Alt+l': show_bare
roster:
'j': next_room
'k': prev_room
'Down': next_room
'Up': prev_room
'g': top
'G': bottom
'Escape': clear
'Alt+Backspace': clear
'q': quit
'Enter': enter
'z': toggle_split
modal:
'Tab': select_next
'Down': select_next

View file

@ -64,6 +64,10 @@ func getUname() string {
return currUser.Username
}
func LogFile() string {
return filepath.Join(LogDirectory, "debug.log")
}
func Initialize() {
err := os.MkdirAll(LogDirectory, 0750)
if err != nil {

2
go.mod
View file

@ -27,6 +27,8 @@ require (
maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.11.1
mvdan.cc/xurls/v2 v2.4.0
periph.io/x/conn/v3 v3.7.0
periph.io/x/host/v3 v3.8.2
)
require (

5
go.sum
View file

@ -15,6 +15,7 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@ -127,3 +128,7 @@ maunium.net/go/mautrix v0.11.1 h1:S5TZGY3M1/bJcd6Y5SUWsNvqQAgFjgFYk5ULm/NCkqk=
maunium.net/go/mautrix v0.11.1/go.mod h1:K29EcHwsNg6r7fMfwvi0GHQ9o5wSjqB9+Q8RjCIQEjA=
mvdan.cc/xurls/v2 v2.4.0 h1:tzxjVAj+wSBmDcF6zBB7/myTy3gX9xvi8Tyr28AuQgc=
mvdan.cc/xurls/v2 v2.4.0/go.mod h1:+GEjq9uNjqs8LQfM9nVnM8rff0OQ5Iash5rzX+N1CSg=
periph.io/x/conn/v3 v3.7.0 h1:f1EXLn4pkf7AEWwkol2gilCNZ0ElY+bxS4WE2PQXfrA=
periph.io/x/conn/v3 v3.7.0/go.mod h1:ypY7UVxgDbP9PJGwFSVelRRagxyXYfttVh7hJZUHEhg=
periph.io/x/host/v3 v3.8.2 h1:ayKUDzgUCN0g8+/xM9GTkWaOBhSLVcVHGTfjAOi8OsQ=
periph.io/x/host/v3 v3.8.2/go.mod h1:yFL76AesNHR68PboofSWYaQTKmvPXsQH2Apvp/ls/K4=

172
headless/headless.go Normal file
View file

@ -0,0 +1,172 @@
package headless
import (
"context"
"errors"
"fmt"
"os"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/ssss"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/initialize"
"maunium.net/go/gomuks/matrix"
"maunium.net/go/gomuks/ui"
)
type Config struct {
OutputDir, Session, Code, KeyPath, KeyPassword, RecoveryCode string
}
func Init(conf Config, updates chan fmt.Stringer) error {
defer close(updates)
// setup package dir
os.Setenv("GOMUKS_ROOT", conf.OutputDir)
updates <- exportDirSet{dir: conf.OutputDir}
// init boilerplate
configDir, dataDir, cacheDir, downloadDir, err := initDirs()
if err != nil {
return err
}
gmx := initialize.NewGomuks(ui.NewGomuksUI, configDir, dataDir, cacheDir, downloadDir)
gmx.Matrix().(*matrix.Container).SetHeadless()
err = gmx.StartHeadless()
if err != nil {
return err
}
updates <- initializedGomuks{}
// login section
gmx.Config().HS = "https://matrix.beeper.com"
if err := gmx.Matrix().InitClient(false); err != nil {
return err
} else if err = gmx.Matrix().BeeperLogin(conf.Session, conf.Code); err != nil {
return err
}
updates <- loggedIn{}
// key import
data, err := os.ReadFile(conf.KeyPath)
if err != nil {
return err
}
mach := gmx.Matrix().Crypto().(*crypto.OlmMachine)
imported, total, err := mach.ImportKeys(conf.KeyPassword, data)
if err != nil {
return fmt.Errorf("Failed to import sessions: %v", err)
}
updates <- importedKeys{imported: imported, total: total}
// display mode
gmx.Config().Preferences.DisplayMode = config.DisplayModeModern
updates <- configuredDisplayMode{}
// sync
updates <- beginningSync{}
resp, err := gmx.Matrix().Client().FullSyncRequest(mautrix.ReqSync{
Timeout: 30000,
Since: "",
FilterID: "",
FullState: true,
SetPresence: gmx.Matrix().Client().SyncPresence,
Context: context.Background(),
StreamResponse: true,
})
if err != nil {
return err
}
updates <- fetchedSyncData{}
gmx.Matrix().(*matrix.Container).InitSyncer()
updates <- processingSync{}
err = gmx.Matrix().(*matrix.Container).ProcessSyncResponse(resp, "")
if err != nil {
return err
}
updates <- syncFinished{}
// verify (fetch)
key, err := getSSSS(mach, conf.RecoveryCode)
if err != nil {
return err
}
err = mach.FetchCrossSigningKeysFromSSSS(key)
if err != nil {
return fmt.Errorf("Error fetching cross-signing keys: %v", err)
}
updates <- fetchedVerificationKeys{}
// verify (sign)
if mach.CrossSigningKeys == nil {
return fmt.Errorf("Cross-signing keys not cached")
}
err = mach.SignOwnDevice(mach.OwnIdentity())
if err != nil {
return fmt.Errorf("Failed to self-sign: %v", err)
}
updates <- successfullyVerified{}
return err
}
func initDirs() (string, string, string, string, error) {
config, err := initialize.UserConfigDir()
if err != nil {
return "", "", "", "", fmt.Errorf("Failed to get config directory: %v", err)
}
data, err := initialize.UserDataDir()
if err != nil {
return "", "", "", "", fmt.Errorf("Failed to get data directory: %v", err)
}
cache, err := initialize.UserCacheDir()
if err != nil {
return "", "", "", "", fmt.Errorf("Failed to get cache directory: %v", err)
}
download, err := initialize.UserDownloadDir()
if err != nil {
return "", "", "", "", fmt.Errorf("Failed to get download directory: %v", err)
}
return config, data, cache, download, nil
}
func getSSSS(mach *crypto.OlmMachine, recoveryCode string) (*ssss.Key, error) {
_, keyData, err := mach.SSSS.GetDefaultKeyData()
if err != nil {
if errors.Is(err, mautrix.MNotFound) {
return nil, fmt.Errorf("SSSS not set up, use `!ssss generate --set-default` first")
} else {
return nil, fmt.Errorf("Failed to fetch default SSSS key data: %v", err)
}
}
var key *ssss.Key
if keyData.Passphrase != nil && keyData.Passphrase.Algorithm == ssss.PassphraseAlgorithmPBKDF2 {
key, err = keyData.VerifyPassphrase(recoveryCode)
if errors.Is(err, ssss.ErrIncorrectSSSSKey) {
return nil, fmt.Errorf("Incorrect passphrase")
}
} else {
key, err = keyData.VerifyRecoveryKey(recoveryCode)
if errors.Is(err, ssss.ErrInvalidRecoveryKey) {
return nil, fmt.Errorf("Malformed recovery key")
} else if errors.Is(err, ssss.ErrIncorrectSSSSKey) {
return nil, fmt.Errorf("Incorrect recovery key")
}
}
// All the errors should already be handled above, this is just for backup
if err != nil {
return nil, fmt.Errorf("Failed to get SSSS key: %v", err)
}
return key, nil
}

69
headless/msg.go Normal file
View file

@ -0,0 +1,69 @@
package headless
import "fmt"
type exportDirSet struct{ dir string }
func (msg exportDirSet) String() string {
return fmt.Sprintf("Set gomuks root directory to %s…", msg.dir)
}
type initializedGomuks struct{}
func (msg initializedGomuks) String() string {
return "Initialized gomuks…"
}
type loggedIn struct{}
func (msg loggedIn) String() string {
return fmt.Sprintf("Logged in…")
}
type importedKeys struct{ imported, total int }
func (msg importedKeys) String() string {
return fmt.Sprintf("Successfully imported %d/%d sessions", msg.imported, msg.total)
}
type configuredDisplayMode struct{}
func (msg configuredDisplayMode) String() string {
return "Configured display mode…"
}
type beginningSync struct{}
func (msg beginningSync) String() string {
return "Beginning the sync process…"
}
type fetchedSyncData struct{}
func (msg fetchedSyncData) String() string {
return "Fetched sync data…"
}
type processingSync struct{}
func (msg processingSync) String() string {
return "Processing sync response…"
}
type syncFinished struct{}
func (msg syncFinished) String() string {
return "Sync completed…"
}
type fetchedVerificationKeys struct{}
func (msg fetchedVerificationKeys) String() string {
return "Successfully unlocked cross-signing keys…"
}
type successfullyVerified struct{}
func (msg successfullyVerified) String() string {
return "Successfully self-signed. This device is now trusted by other devices…"
}

88
initialize/dirs.go Normal file
View file

@ -0,0 +1,88 @@
package initialize
import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
func getRootDir(subdir string) string {
rootDir := os.Getenv("GOMUKS_ROOT")
if rootDir == "" {
return ""
}
return filepath.Join(rootDir, subdir)
}
func UserCacheDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_CACHE_HOME")
if dir == "" {
dir = getRootDir("cache")
}
if dir == "" {
dir, err = os.UserCacheDir()
dir = filepath.Join(dir, "gomuks")
}
return
}
func UserDataDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_DATA_HOME")
if dir != "" {
return
}
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return UserConfigDir()
}
dir = getRootDir("data")
if dir == "" {
dir = os.Getenv("XDG_DATA_HOME")
}
if dir == "" {
dir = os.Getenv("HOME")
if dir == "" {
return "", errors.New("neither $XDG_DATA_HOME nor $HOME are defined")
}
dir = filepath.Join(dir, ".local", "share")
}
dir = filepath.Join(dir, "gomuks")
return
}
func getXDGUserDir(name string) (dir string, err error) {
cmd := exec.Command("xdg-user-dir", name)
var out strings.Builder
cmd.Stdout = &out
err = cmd.Run()
dir = strings.TrimSpace(out.String())
return
}
func UserDownloadDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_DOWNLOAD_HOME")
if dir != "" {
return
}
dir, _ = getXDGUserDir("DOWNLOAD")
if dir != "" {
return
}
dir, err = os.UserHomeDir()
dir = filepath.Join(dir, "Downloads")
return
}
func UserConfigDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_CONFIG_HOME")
if dir == "" {
dir = getRootDir("config")
}
if dir == "" {
dir, err = os.UserConfigDir()
dir = filepath.Join(dir, "gomuks")
}
return
}

View file

@ -14,7 +14,7 @@
// 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 main
package initialize
import (
"errors"
@ -22,7 +22,6 @@ import (
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
@ -33,45 +32,13 @@ import (
"maunium.net/go/gomuks/matrix"
)
// Information to find out exactly which commit gomuks was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
var (
// Version is the version number of gomuks. Changed manually when making a release.
Version = "0.3.0"
// VersionString is the gomuks version, plus commit information. Filled in init() using the build-time values.
VersionString = ""
)
func init() {
if len(Tag) > 0 && Tag[0] == 'v' {
Tag = Tag[1:]
}
if Tag != Version {
suffix := ""
if !strings.HasSuffix(Version, "+dev") {
suffix = "+dev"
}
if len(Commit) > 8 {
Version = fmt.Sprintf("%s%s.%s", Version, suffix, Commit[:8])
} else {
Version = fmt.Sprintf("%s%s.unknown", Version, suffix)
}
}
VersionString = fmt.Sprintf("gomuks %s (%s with %s)", Version, BuildTime, runtime.Version())
}
// Gomuks is the wrapper for everything.
type Gomuks struct {
ui ifc.GomuksUI
matrix *matrix.Container
config *config.Config
stop chan bool
ui ifc.GomuksUI
matrix *matrix.Container
config *config.Config
stop chan bool
version string
}
// NewGomuks creates a new Gomuks instance with everything initialized,
@ -94,7 +61,7 @@ func NewGomuks(uiProvider ifc.UIProvider, configDir, dataDir, cacheDir, download
}
func (gmx *Gomuks) Version() string {
return Version
return gmx.version
}
// Save saves the active session and message history.
@ -145,7 +112,7 @@ func (gmx *Gomuks) internalStop(save bool) {
// If the tview app returns an error, it will be passed into panic(), which
// will be recovered as specified in Recover().
func (gmx *Gomuks) Start() {
err := gmx.matrix.InitClient(true)
err := gmx.StartHeadless()
if err != nil {
if errors.Is(err, matrix.ErrServerOutdated) {
_, _ = fmt.Fprintln(os.Stderr, strings.Replace(err.Error(), "homeserver", gmx.config.HS, 1))
@ -176,6 +143,10 @@ func (gmx *Gomuks) Start() {
}
}
func (gmx *Gomuks) StartHeadless() error {
return gmx.matrix.InitClient(true)
}
// Matrix returns the MatrixContainer instance.
func (gmx *Gomuks) Matrix() ifc.MatrixContainer {
return gmx.matrix

View file

@ -50,6 +50,7 @@ type MatrixContainer interface {
Stop()
Login(user, password string) error
BeeperLogin(session, code string) error
Logout()
UIAFallback(authType mautrix.AuthType, sessionID string) error

148
main.go
View file

@ -17,11 +17,8 @@
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@ -29,23 +26,58 @@ import (
flag "maunium.net/go/mauflag"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/initialize"
ifc "maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/matrix"
"maunium.net/go/gomuks/ui"
)
// Information to find out exactly which commit gomuks was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
var (
// Version is the version number of gomuks. Changed manually when making a release.
Version = "0.3.0"
// VersionString is the gomuks version, plus commit information. Filled in init() using the build-time values.
VersionString = ""
)
func init() {
if len(Tag) > 0 && Tag[0] == 'v' {
Tag = Tag[1:]
}
if Tag != Version {
suffix := ""
if !strings.HasSuffix(Version, "+dev") {
suffix = "+dev"
}
if len(Commit) > 8 {
Version = fmt.Sprintf("%s%s.%s", Version, suffix, Commit[:8])
} else {
Version = fmt.Sprintf("%s%s.unknown", Version, suffix)
}
}
VersionString = fmt.Sprintf("gomuks %s (%s with %s)", Version, BuildTime, runtime.Version())
}
var MainUIProvider ifc.UIProvider = ui.NewGomuksUI
var wantVersion = flag.MakeFull("v", "version", "Show the version of gomuks", "false").Bool()
var clearCache = flag.MakeFull("c", "clear-cache", "Clear the cache directory instead of starting", "false").Bool()
var clearData = flag.Make().LongKey("clear-all-data").Usage("Clear all data instead of starting").Default("false").Bool()
var skipVersionCheck = flag.MakeFull("s", "skip-version-check", "Skip the homeserver version checks at startup and login", "false").Bool()
var printLogPath = flag.MakeFull("l", "print-log-path", "Print the log path instead of starting", "false").Bool()
var clearData = flag.Make().LongKey("clear-all-data").Usage("Clear all data instead of starting").Default("false").Bool()
var wantHelp, _ = flag.MakeHelpFlag()
func main() {
flag.SetHelpTitles(
"gomuks - A terminal Matrix client written in Go.",
"gomuks [-vch] [--clear-all-data]",
"gomuks [-vcsh] [--clear-all-data|--print-log-path]",
)
err := flag.Parse()
if err != nil {
@ -58,6 +90,7 @@ func main() {
fmt.Println(VersionString)
return
}
debugDir := os.Getenv("DEBUG_DIR")
if len(debugDir) > 0 {
debug.LogDirectory = debugDir
@ -68,27 +101,32 @@ func main() {
debug.DeadlockDetection = true
debug.WriteLogs = true
}
if *printLogPath {
fmt.Println(debug.LogFile())
return
}
debug.Initialize()
defer debug.Recover()
var configDir, dataDir, cacheDir, downloadDir string
configDir, err = UserConfigDir()
configDir, err = initialize.UserConfigDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get config directory:", err)
os.Exit(3)
}
dataDir, err = UserDataDir()
dataDir, err = initialize.UserDataDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get data directory:", err)
os.Exit(3)
}
cacheDir, err = UserCacheDir()
cacheDir, err = initialize.UserCacheDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get cache directory:", err)
os.Exit(3)
}
downloadDir, err = UserDownloadDir()
downloadDir, err = initialize.UserDownloadDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get download directory:", err)
os.Exit(3)
@ -100,19 +138,19 @@ func main() {
debug.Print("Download directory:", downloadDir)
matrix.SkipVersionCheck = *skipVersionCheck
gmx := NewGomuks(MainUIProvider, configDir, dataDir, cacheDir, downloadDir)
gmx := initialize.NewGomuks(MainUIProvider, configDir, dataDir, cacheDir, downloadDir)
if *clearCache {
debug.Print("Clearing cache as requested by CLI flag")
gmx.config.Clear()
fmt.Printf("Cleared cache at %s\n", gmx.config.CacheDir)
gmx.Config().Clear()
fmt.Printf("Cleared cache at %s\n", gmx.Config().CacheDir)
return
} else if *clearData {
debug.Print("Clearing all data as requested by CLI flag")
gmx.config.Clear()
gmx.config.ClearData()
_ = os.RemoveAll(gmx.config.Dir)
fmt.Printf("Cleared cache at %s, data at %s and config at %s\n", gmx.config.CacheDir, gmx.config.DataDir, gmx.config.Dir)
gmx.Config().Clear()
gmx.Config().ClearData()
_ = os.RemoveAll(gmx.Config().Dir)
fmt.Printf("Cleared cache at %s, data at %s and config at %s\n", gmx.Config().CacheDir, gmx.Config().DataDir, gmx.Config().Dir)
return
}
@ -123,81 +161,3 @@ func main() {
fmt.Println("Unexpected exit by return from gmx.Start().")
os.Exit(2)
}
func getRootDir(subdir string) string {
rootDir := os.Getenv("GOMUKS_ROOT")
if rootDir == "" {
return ""
}
return filepath.Join(rootDir, subdir)
}
func UserCacheDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_CACHE_HOME")
if dir == "" {
dir = getRootDir("cache")
}
if dir == "" {
dir, err = os.UserCacheDir()
dir = filepath.Join(dir, "gomuks")
}
return
}
func UserDataDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_DATA_HOME")
if dir != "" {
return
}
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return UserConfigDir()
}
dir = getRootDir("data")
if dir == "" {
dir = os.Getenv("XDG_DATA_HOME")
}
if dir == "" {
dir = os.Getenv("HOME")
if dir == "" {
return "", errors.New("neither $XDG_DATA_HOME nor $HOME are defined")
}
dir = filepath.Join(dir, ".local", "share")
}
dir = filepath.Join(dir, "gomuks")
return
}
func getXDGUserDir(name string) (dir string, err error) {
cmd := exec.Command("xdg-user-dir", name)
var out strings.Builder
cmd.Stdout = &out
err = cmd.Run()
dir = strings.TrimSpace(out.String())
return
}
func UserDownloadDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_DOWNLOAD_HOME")
if dir != "" {
return
}
dir, _ = getXDGUserDir("DOWNLOAD")
if dir != "" {
return
}
dir, err = os.UserHomeDir()
dir = filepath.Join(dir, "Downloads")
return
}
func UserConfigDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_CONFIG_HOME")
if dir == "" {
dir = getRootDir("config")
}
if dir == "" {
dir, err = os.UserConfigDir()
dir = filepath.Join(dir, "gomuks")
}
return
}

View file

@ -41,6 +41,7 @@ import (
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
"maunium.net/go/gomuks/beeper"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
ifc "maunium.net/go/gomuks/interface"
@ -53,15 +54,16 @@ import (
//
// It is used for all Matrix calls from the UI and Matrix event handlers.
type Container struct {
client *mautrix.Client
crypto ifc.Crypto
syncer *GomuksSyncer
gmx ifc.Gomuks
ui ifc.GomuksUI
config *config.Config
history *HistoryManager
running bool
stop chan bool
client *mautrix.Client
crypto ifc.Crypto
syncer *GomuksSyncer
gmx ifc.Gomuks
ui ifc.GomuksUI
config *config.Config
history *HistoryManager
running bool
stop chan bool
headless bool
typing int64
}
@ -77,6 +79,10 @@ func NewContainer(gmx ifc.Gomuks) *Container {
return c
}
func (c *Container) SetHeadless() {
c.headless = true
}
// Client returns the underlying mautrix Client.
func (c *Container) Client() *mautrix.Client {
return c.client
@ -174,7 +180,7 @@ func (c *Container) InitClient(isStartup bool) error {
c.stop = make(chan bool, 1)
if len(accessToken) > 0 {
if len(accessToken) > 0 && !c.headless {
go c.Start()
}
return nil
@ -185,6 +191,22 @@ func (c *Container) Initialized() bool {
return c.client != nil
}
func (c *Container) JWTLogin(token string) error {
resp, err := c.client.Login(&mautrix.ReqLogin{
Type: "org.matrix.login.jwt",
Token: token,
InitialDeviceDisplayName: "gomuks",
StoreCredentials: true,
StoreHomeserverURL: true,
})
if err != nil {
return err
}
c.finishLogin(resp)
return nil
}
func (c *Container) PasswordLogin(user, password string) error {
resp, err := c.client.Login(&mautrix.ReqLogin{
Type: "m.login.password",
@ -214,7 +236,9 @@ func (c *Container) finishLogin(resp *mautrix.RespLogin) {
}
c.config.Save()
go c.Start()
if !c.headless {
go c.Start()
}
}
func respondHTML(w http.ResponseWriter, status int, message string) {
@ -283,6 +307,15 @@ func (c *Container) SingleSignOn() error {
return err
}
func (c *Container) BeeperLogin(request, code string) error {
resp, err := beeper.SendLoginCode(request, code)
if err != nil {
return err
}
return c.JWTLogin(resp.LoginToken)
}
// Login sends a password login request with the given username and password.
func (c *Container) Login(user, password string) error {
resp, err := c.client.GetLoginFlows()
@ -381,15 +414,9 @@ func (s StubSyncingModal) SetSteps(i int) {}
func (s StubSyncingModal) Step() {}
func (s StubSyncingModal) Close() {}
// OnLogin initializes the syncer and updates the room list.
func (c *Container) OnLogin() {
c.cryptoOnLogin()
c.ui.OnLogin()
c.client.Store = c.config
debug.Print("Initializing syncer")
func (c *Container) InitSyncer() {
c.syncer = NewGomuksSyncer(c.config.Rooms)
if c.crypto != nil {
c.syncer.OnSync(c.crypto.ProcessSyncResponse)
c.syncer.OnEventType(event.StateMember, func(source mautrix.EventSource, evt *event.Event) {
@ -403,6 +430,7 @@ func (c *Container) OnLogin() {
} else {
c.syncer.OnEventType(event.EventEncrypted, c.HandleEncryptedUnsupported)
}
c.syncer.OnEventType(event.EventMessage, c.HandleMessage)
c.syncer.OnEventType(event.EventSticker, c.HandleMessage)
c.syncer.OnEventType(event.EventReaction, c.HandleMessage)
@ -418,16 +446,7 @@ func (c *Container) OnLogin() {
c.syncer.OnEventType(event.AccountDataPushRules, c.HandlePushRules)
c.syncer.OnEventType(event.AccountDataRoomTags, c.HandleTag)
c.syncer.OnEventType(AccountDataGomuksPreferences, c.HandlePreferences)
if len(c.config.AuthCache.NextBatch) == 0 {
c.syncer.Progress = c.ui.MainView().OpenSyncingModal()
c.syncer.Progress.SetMessage("Waiting for /sync response from server")
c.syncer.Progress.SetIndeterminate()
c.syncer.FirstDoneCallback = func() {
c.syncer.Progress.Close()
c.syncer.Progress = StubSyncingModal{}
c.syncer.FirstDoneCallback = nil
}
}
c.syncer.InitDoneCallback = func() {
debug.Print("Initial sync done")
c.config.AuthCache.InitialSyncDone = true
@ -448,7 +467,38 @@ func (c *Container) OnLogin() {
runtime.GC()
dbg.FreeOSMemory()
}
if c.headless {
c.syncer.FirstDoneCallback = func() {
c.Stop()
}
}
c.client.Syncer = c.syncer
}
func (c *Container) ProcessSyncResponse(res *mautrix.RespSync, since string) error {
return c.syncer.ProcessResponse(res, since)
}
// OnLogin initializes the syncer and updates the room list.
func (c *Container) OnLogin() {
c.cryptoOnLogin()
c.ui.OnLogin()
c.client.Store = c.config
debug.Print("Initializing syncer")
c.InitSyncer()
if len(c.config.AuthCache.NextBatch) == 0 {
c.syncer.Progress = c.ui.MainView().OpenSyncingModal()
c.syncer.Progress.SetMessage("Waiting for /sync response from server")
c.syncer.Progress.SetIndeterminate()
c.syncer.FirstDoneCallback = func() {
c.syncer.Progress.Close()
c.syncer.Progress = StubSyncingModal{}
c.syncer.FirstDoneCallback = nil
}
}
debug.Print("Setting existing rooms")
c.ui.MainView().SetRooms(c.config.Rooms)
@ -677,6 +727,10 @@ func (c *Container) HandleMessage(source mautrix.EventSource, mxEvent *event.Eve
return
}
if c.headless {
return
}
mainView := c.ui.MainView()
roomView := mainView.GetRoom(evt.RoomID)

View file

@ -463,6 +463,23 @@ func (room *Room) GetStateEvent(eventType event.Type, stateKey string) *event.Ev
return evt
}
// MostRecentStateEventOfType returns the most recent state event for the given
// type, or nil.
func (room *Room) MostRecentStateEventOfType(eventType event.Type) *event.Event {
room.Load()
room.lock.RLock()
defer room.lock.RUnlock()
stateEventMap, _ := room.state[eventType]
var evt *event.Event = nil
for _, e := range stateEventMap {
if evt == nil || time.UnixMilli(e.Timestamp).After(time.UnixMilli(evt.Timestamp)) {
evt = e
}
}
return evt
}
// getStateEvents returns the state events for the given type.
func (room *Room) getStateEvents(eventType event.Type) map[string]*event.Event {
stateEventMap, _ := room.state[eventType]
@ -656,7 +673,7 @@ func (room *Room) GetMemberList() []id.UserID {
members := room.GetMembers()
memberList := make([]id.UserID, len(members))
index := 0
for userID, _ := range members {
for userID := range members {
memberList[index] = userID
index++
}

93
ui/beepberry/led.go Normal file
View file

@ -0,0 +1,93 @@
package beepberry
import (
"periph.io/x/conn/v3/i2c"
"periph.io/x/conn/v3/i2c/i2creg"
"periph.io/x/host/v3"
)
const (
write = 0x80
power = 0x20
red = 0x21
green = 0x22
blue = 0x23
)
type LED struct {
bus i2c.BusCloser
chip *i2c.Dev
}
func NewLED() (*LED, error) {
if _, err := host.Init(); err != nil {
return nil, err
}
i, err := i2creg.Open("1")
if err != nil {
return nil, err
}
return &LED{
bus: i,
chip: &i2c.Dev{Addr: 0x1F, Bus: i},
}, nil
}
func (l *LED) Close() error {
return l.bus.Close()
}
func (l *LED) On() error {
_, err := l.chip.Write([]byte{power + write, 0x01})
return err
}
func (l *LED) Off() error {
_, err := l.chip.Write([]byte{power + write, 0x00})
return err
}
func (l *LED) SetColor(r, g, b uint16) error {
if _, err := l.chip.Write([]byte{red + write, byte(r)}); err != nil {
return err
}
if _, err := l.chip.Write([]byte{green + write, byte(g)}); err != nil {
return err
}
if _, err := l.chip.Write([]byte{blue + write, byte(b)}); err != nil {
return err
}
return nil
}
func (l *LED) IsOn() (bool, error) {
p := make([]byte, 1)
if err := l.chip.Tx([]byte{power}, p); err != nil {
return false, err
}
return p[0] > 0, nil
}
func (l *LED) Color() ([]byte, error) {
r := make([]byte, 1)
if err := l.chip.Tx([]byte{red}, r); err != nil {
return nil, err
}
g := make([]byte, 1)
if err := l.chip.Tx([]byte{green}, g); err != nil {
return nil, err
}
b := make([]byte, 1)
if err := l.chip.Tx([]byte{blue}, b); err != nil {
return nil, err
}
return []byte{r[0], g[0], b[0]}, nil
}

View file

@ -53,7 +53,9 @@ func (cmd *Command) Reply(message string, args ...interface{}) {
if len(args) > 0 {
message = fmt.Sprintf(message, args...)
}
cmd.Room.AddServiceMessage(message)
if cmd.Room != nil {
cmd.Room.AddServiceMessage(message)
}
cmd.UI.Render()
}
@ -113,6 +115,7 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor {
"cs": {"cross-signing"},
"power": {"powerlevel"},
"pl": {"powerlevel"},
"esc": {"escape"},
},
autocompleters: map[string]CommandAutocompleter{
"devices": autocompleteUser,
@ -181,6 +184,8 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor {
"rainbownotice": cmdRainbowNotice,
"escape": cmdEscape,
"fingerprint": cmdFingerprint,
"devices": cmdDevices,
"verify-device": cmdVerifyDevice,

View file

@ -44,6 +44,7 @@ import (
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/lib/filepicker"
)
@ -907,6 +908,21 @@ func cmdSetState(cmd *Command) {
}
}
func cmdEscape(cmd *Command) {
if cmd.Config.Preferences.DisplayMode != config.DisplayModeModern {
cmd.Reply("/escape can only be used in the modern display mode")
return
}
if cmd.MainView.rosterView.room == nil || !cmd.MainView.rosterView.focused {
cmd.Reply("/escape is used to exit from an open room (no room opened)")
return
}
cmd.MainView.rosterView.focused = false
cmd.MainView.rosterView.split = nil
cmd.MainView.rosterView.room = nil
cmd.UI.Render()
}
type ToggleMessage interface {
Name() string
Format(state bool) string
@ -1040,6 +1056,16 @@ func cmdToggle(cmd *Command) {
cmd.Reply("Force-enabled using fancy terminal features to render URLs inside text. Restart gomuks to apply changes.")
}
continue
case "displaymode":
switch cmd.Config.Preferences.DisplayMode {
case "modern":
cmd.Config.Preferences.DisplayMode = config.DisplayModeIRC
cmd.Reply("Enabled IRC display mode.")
default:
cmd.Config.Preferences.DisplayMode = config.DisplayModeModern
cmd.Reply("Enabled modern display mode.")
}
continue
case "newline":
val = &cmd.Config.Preferences.AltEnterToSend
default:

View file

@ -159,7 +159,10 @@ func (view *MessageView) AddMessage(ifcMessage ifc.Message, direction MessageDir
width := view.width()
bare := view.config.Preferences.BareMessageView
if !bare {
modern := view.config.Preferences.DisplayMode == config.DisplayModeModern
if modern {
width -= 5
} else if !bare {
width -= view.widestSender() + SenderMessageGap
if !view.config.Preferences.HideTimestamp {
width -= view.TimestampWidth + TimestampSenderGap
@ -177,7 +180,7 @@ func (view *MessageView) AddMessage(ifcMessage ifc.Message, direction MessageDir
if direction == AppendMessage {
if view.ScrollOffset > 0 {
view.ScrollOffset += message.Height()
view.ScrollOffset += message.Height(view.showModernHeader(message))
}
view.messagesLock.Lock()
if len(view.messages) > 0 && !view.messages[len(view.messages)-1].SameDate(message) {
@ -251,6 +254,17 @@ func (view *MessageView) setMessageID(message *messages.UIMessage) {
view.messageIDLock.Unlock()
}
func (view *MessageView) showModernHeader(message *messages.UIMessage) bool {
if view.config.Preferences.DisplayMode != config.DisplayModeModern {
return false
}
if message.IsService {
return false
}
return true
}
func (view *MessageView) appendBuffer(message *messages.UIMessage) {
view.msgBufferLock.Lock()
view.appendBufferUnlocked(message)
@ -258,7 +272,7 @@ func (view *MessageView) appendBuffer(message *messages.UIMessage) {
}
func (view *MessageView) appendBufferUnlocked(message *messages.UIMessage) {
for i := 0; i < message.Height(); i++ {
for i := 0; i < message.Height(view.showModernHeader(message)); i++ {
view.msgBuffer = append(view.msgBuffer, message)
}
view.prevMsgCount++
@ -291,13 +305,13 @@ func (view *MessageView) replaceBuffer(original *messages.UIMessage, new *messag
end++
}
if new.Height() == 0 {
if new.Height(view.showModernHeader(new)) == 0 {
new.CalculateBuffer(view.prevPrefs, view.prevWidth())
}
view.msgBufferLock.Lock()
if new.Height() != end-start {
height := new.Height()
if new.Height(view.showModernHeader(new)) != end-start {
height := new.Height(view.showModernHeader(new))
newBuffer := make([]*messages.UIMessage, height+len(view.msgBuffer)-end)
for i := 0; i < height; i++ {
@ -325,7 +339,9 @@ func (view *MessageView) recalculateBuffers() {
view.msgBufferLock.Lock()
if recalculateMessageBuffers || len(view.messages) != view.prevMsgCount {
width := view.width()
if !prefs.BareMessageView {
if prefs.DisplayMode == config.DisplayModeModern {
width -= 5
} else if !prefs.BareMessageView {
width -= view.widestSender() + SenderMessageGap
if !prefs.HideTimestamp {
width -= view.TimestampWidth + TimestampSenderGap
@ -441,6 +457,14 @@ func (view *MessageView) OnMouseEvent(event mauview.MouseEvent) bool {
}
view.msgBufferLock.RUnlock()
if view.config.Preferences.DisplayMode == config.DisplayModeModern {
if prevMessage == message {
return view.handleMessageClick(message, event.Modifiers())
} else {
return view.handleUsernameClick(message, prevMessage)
}
}
usernameX := 0
if !view.config.Preferences.HideTimestamp {
usernameX += view.TimestampWidth + TimestampSenderGap
@ -616,9 +640,13 @@ func (view *MessageView) Draw(screen mauview.Screen) {
}
messageX := usernameX + view.widestSender() + SenderMessageGap
bareMode := view.config.Preferences.BareMessageView
if bareMode {
messageX = 0
noLeftPad := view.config.Preferences.BareMessageView || view.config.Preferences.DisplayMode == config.DisplayModeModern
if noLeftPad {
if view.config.Preferences.DisplayMode == config.DisplayModeModern {
messageX = 2
} else {
messageX = 0
}
}
indexOffset := view.getIndexOffset(screen, height, messageX)
@ -628,7 +656,7 @@ func (view *MessageView) Draw(screen mauview.Screen) {
viewStart = -indexOffset
}
if !bareMode {
if !noLeftPad {
separatorX := usernameX + view.widestSender() + SenderSeparatorGap
scrollBarHeight, scrollBarPos := view.calculateScrollBar(height)
@ -649,32 +677,57 @@ func (view *MessageView) Draw(screen mauview.Screen) {
index := indexOffset + line
msg := view.msgBuffer[index]
header := view.showModernHeader(msg)
if msg == prevMsg {
debug.Print("Unexpected re-encounter of", msg, msg.Height(), "at", line, index)
debug.Print("Unexpected re-encounter of", msg, msg.Height(header), "at", line, index)
line++
continue
}
if len(msg.FormatTime()) > 0 && !view.config.Preferences.HideTimestamp {
widget.WriteLineSimpleColor(screen, msg.FormatTime(), 0, line, msg.TimestampColor())
}
// TODO hiding senders might not be that nice after all, maybe an option? (disabled for now)
//if !bareMode && (prevMsg == nil || meta.Sender() != prevMsg.Sender()) {
widget.WriteLineColor(
screen, mauview.AlignRight, msg.Sender(),
usernameX, line, view.widestSender(),
msg.SenderColor())
//}
if msg.Edited {
// TODO add better indicator for edits
screen.SetCell(usernameX+view.widestSender(), line, tcell.StyleDefault.Foreground(tcell.ColorDarkRed), '*')
if view.config.Preferences.DisplayMode != config.DisplayModeModern {
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()
offset := 0
if header {
offset = 1
boldStyle := tcell.StyleDefault.Bold(true)
username := msg.Sender()
widget.WriteLine(screen, mauview.AlignLeft, username,
messageX, line, len(username), boldStyle.Foreground(msg.SenderColor()))
widget.WriteLine(screen, mauview.AlignLeft, " "+string(tcell.RuneBullet)+" ",
messageX+len(username), line, 3, boldStyle)
widget.WriteLine(screen, mauview.AlignLeft, msg.FormatTime(),
messageX+len(username)+3, line, view.width()-len(username)-3,
boldStyle.Foreground(msg.TimestampColor()),
)
if msg.Edited {
widget.WriteLine(screen, mauview.AlignLeft, " "+string(tcell.RuneBullet)+" ",
messageX+len(username)+3+len(msg.FormatTime()), line, 3, boldStyle)
widget.WriteLine(screen, mauview.AlignLeft, "Edited",
messageX+len(username)+len(msg.FormatTime())+6, line, 6, boldStyle.Foreground(tcell.ColorDarkRed))
}
}
msg.Draw(mauview.NewProxyScreen(screen, messageX, line+offset, view.width()-messageX, msg.Height(header)), header)
line += msg.Height(header)
prevMsg = msg
}

View file

@ -254,7 +254,7 @@ func (msg *UIMessage) TimestampColor() tcell.Color {
func (msg *UIMessage) ReplyHeight() int {
if msg.ReplyTo != nil {
return 1 + msg.ReplyTo.Height()
return 1 + msg.ReplyTo.Height(false)
}
return 0
}
@ -267,8 +267,13 @@ func (msg *UIMessage) ReactionHeight() int {
}
// 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) Height(modernHeader bool) int {
height := msg.ReplyHeight() + msg.Renderer.Height() + msg.ReactionHeight()
if modernHeader {
height++
}
return height
}
func (msg *UIMessage) Time() time.Time {
@ -306,12 +311,18 @@ func (msg *UIMessage) SetIsHighlight(isHighlight bool) {
msg.IsHighlight = isHighlight
}
func (msg *UIMessage) DrawReactions(screen mauview.Screen) {
func (msg *UIMessage) DrawReactions(screen mauview.Screen, modernHeader bool) {
if len(msg.Reactions) == 0 {
return
}
width, height := screen.Size()
screen = mauview.NewProxyScreen(screen, 0, height-1, width, 1)
diff := 1
if modernHeader && height == msg.Height(modernHeader) {
diff = 2
}
screen = mauview.NewProxyScreen(screen, 0, height-diff, width, 1)
x := 0
for _, reaction := range msg.Reactions {
@ -323,12 +334,15 @@ func (msg *UIMessage) DrawReactions(screen mauview.Screen) {
}
}
func (msg *UIMessage) Draw(screen mauview.Screen) {
proxyScreen := msg.DrawReply(screen)
func (msg *UIMessage) Draw(screen mauview.Screen, modernHeader bool) {
proxyScreen := msg.DrawReply(screen, modernHeader)
msg.Renderer.Draw(proxyScreen, msg)
msg.DrawReactions(proxyScreen)
msg.DrawReactions(proxyScreen, modernHeader)
if msg.IsSelected {
w, h := screen.Size()
if modernHeader {
h--
}
for x := 0; x < w; x++ {
for y := 0; y < h; y++ {
mainc, combc, style, _ := screen.GetContent(x, y)
@ -361,19 +375,19 @@ func (msg *UIMessage) CalculateBuffer(preferences config.UserPreferences, width
msg.CalculateReplyBuffer(preferences, width)
}
func (msg *UIMessage) DrawReply(screen mauview.Screen) mauview.Screen {
func (msg *UIMessage) DrawReply(screen mauview.Screen, modernHeader bool) mauview.Screen {
if msg.ReplyTo == nil {
return screen
}
width, height := screen.Size()
replyHeight := msg.ReplyTo.Height()
replyHeight := msg.ReplyTo.Height(false)
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)
msg.ReplyTo.Draw(replyScreen, modernHeader)
return mauview.NewProxyScreen(screen, 0, replyHeight+1, width, height-replyHeight-1)
}

View file

@ -131,6 +131,7 @@ func NewRoomView(parent *MainView, room *rooms.Room) *RoomView {
view.input.SetPlaceholder("Send an encrypted message...")
}
// TODO: update when displaymode toggled
view.topic.
SetTextColor(tcell.ColorWhite).
SetBackgroundColor(tcell.ColorDarkGreen)
@ -300,10 +301,29 @@ func (view *RoomView) Draw(screen mauview.Screen) {
}
contentHeight := height - inputHeight - TopicBarHeight - StatusBarHeight
contentWidth := width - StaticHorizontalSpace
if view.config.Preferences.HideUserList {
if view.config.Preferences.HideUserList || view.config.Preferences.DisplayMode == config.DisplayModeModern {
contentWidth = width
}
if view.config.Preferences.DisplayMode == config.DisplayModeModern {
view.topicScreen.Height = 2
view.contentScreen.OffsetY = 2
contentHeight--
view.topic.
SetTextColor(tcell.ColorDefault).
SetBackgroundColor(tcell.ColorDefault).
SetTextAlign(mauview.AlignCenter)
} else {
view.topicScreen.Height = TopicBarHeight
view.contentScreen.OffsetY = StatusBarHeight
view.topic.
SetTextColor(tcell.ColorWhite).
SetBackgroundColor(tcell.ColorDarkGreen).
SetTextAlign(mauview.AlignLeft)
}
view.topicScreen.Width = width
view.contentScreen.Width = contentWidth
view.contentScreen.Height = contentHeight
@ -323,10 +343,13 @@ func (view *RoomView) Draw(screen mauview.Screen) {
view.status.SetText(view.GetStatus())
view.status.Draw(view.statusScreen)
view.input.Draw(view.inputScreen)
if !view.config.Preferences.HideUserList {
if !view.config.Preferences.HideUserList && view.config.Preferences.DisplayMode != config.DisplayModeModern {
view.ulBorder.Draw(view.ulBorderScreen)
view.userList.Draw(view.ulScreen)
}
if view.config.Preferences.DisplayMode == config.DisplayModeModern {
widget.NewBorder().Draw(mauview.NewProxyScreen(view.topicScreen, 2, 1, view.topicScreen.Width-5, 1))
}
}
func (view *RoomView) ClearAllContext() {
@ -858,7 +881,9 @@ func (view *RoomView) MxRoom() *rooms.Room {
func (view *RoomView) Update() {
topicStr := strings.TrimSpace(strings.ReplaceAll(view.Room.GetTopic(), "\n", " "))
if view.config.Preferences.HideRoomList {
if view.config.Preferences.DisplayMode == config.DisplayModeModern {
topicStr = view.Room.GetTitle()
} else if view.config.Preferences.HideRoomList {
if len(topicStr) > 0 {
topicStr = fmt.Sprintf("%s - %s", view.Room.GetTitle(), topicStr)
} else {
@ -883,6 +908,7 @@ func (view *RoomView) UpdateUserList() {
func (view *RoomView) AddServiceMessage(text string) {
view.content.AddMessage(messages.NewServiceMessage(text), AppendMessage)
go view.parent.FlashLED(0x00, 0xFF, 0x00)
}
func (view *RoomView) parseEvent(evt *muksevt.Event) *messages.UIMessage {
@ -898,6 +924,7 @@ func (view *RoomView) AddHistoryEvent(evt *muksevt.Event) {
func (view *RoomView) AddEvent(evt *muksevt.Event) ifc.Message {
if msg := view.parseEvent(evt); msg != nil {
view.content.AddMessage(msg, AppendMessage)
go view.parent.FlashLED(0xFF, 0xFF, 0xFF)
return msg
}
return nil
@ -905,17 +932,20 @@ func (view *RoomView) AddEvent(evt *muksevt.Event) ifc.Message {
func (view *RoomView) AddRedaction(redactedEvt *muksevt.Event) {
view.AddEvent(redactedEvt)
go view.parent.FlashLED(0xFF, 0x00, 0x00)
}
func (view *RoomView) AddEdit(evt *muksevt.Event) {
if msg := view.parseEvent(evt); msg != nil {
view.content.AddMessage(msg, IgnoreMessage)
go view.parent.FlashLED(0xFF, 0xFF, 0x00)
}
}
func (view *RoomView) AddReaction(evt *muksevt.Event, key string) {
msgView := view.MessageView()
msg := msgView.getMessageByID(evt.ID)
go view.parent.FlashLED(0xFF, 0x00, 0xFF)
if msg == nil {
// Message not in view, nothing to do
return

View file

@ -25,8 +25,8 @@ import (
"go.mau.fi/tcell"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/beeper"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
ifc "maunium.net/go/gomuks/interface"
@ -37,19 +37,18 @@ type LoginView struct {
container *mauview.Centerer
homeserverLabel *mauview.TextField
usernameLabel *mauview.TextField
passwordLabel *mauview.TextField
emailLabel *mauview.TextField
codeLabel *mauview.TextField
homeserver *mauview.InputField
username *mauview.InputField
password *mauview.InputField
error *mauview.TextView
email *mauview.InputField
code *mauview.InputField
error *mauview.TextView
loginButton *mauview.Button
quitButton *mauview.Button
loading bool
session string
matrix ifc.MatrixContainer
config *config.Config
@ -60,13 +59,11 @@ func (ui *GomuksUI) NewLoginView() mauview.Component {
view := &LoginView{
Form: mauview.NewForm(),
usernameLabel: mauview.NewTextField().SetText("Username"),
passwordLabel: mauview.NewTextField().SetText("Password"),
homeserverLabel: mauview.NewTextField().SetText("Homeserver"),
emailLabel: mauview.NewTextField().SetText("Email"),
codeLabel: mauview.NewTextField().SetText("Code"),
username: mauview.NewInputField(),
password: mauview.NewInputField(),
homeserver: mauview.NewInputField(),
email: mauview.NewInputField(),
code: mauview.NewInputField(),
loginButton: mauview.NewButton("Login"),
quitButton: mauview.NewButton("Quit"),
@ -76,10 +73,8 @@ func (ui *GomuksUI) NewLoginView() mauview.Component {
parent: ui,
}
hs := ui.gmx.Config().HS
view.homeserver.SetPlaceholder("https://example.com").SetText(hs).SetTextColor(tcell.ColorWhite)
view.username.SetPlaceholder("@user:example.com").SetText(string(ui.gmx.Config().UserID)).SetTextColor(tcell.ColorWhite)
view.password.SetPlaceholder("correct horse battery staple").SetMaskCharacter('*').SetTextColor(tcell.ColorWhite)
view.email.SetPlaceholder("example@example.com").SetTextColor(tcell.ColorWhite)
view.code.SetPlaceholder("123456").SetTextColor(tcell.ColorWhite)
view.quitButton.
SetOnClick(func() { ui.gmx.Stop(true) }).
@ -93,45 +88,51 @@ func (ui *GomuksUI) NewLoginView() mauview.Component {
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})
SetColumns([]int{1, 5, 1, 30, 1}).
SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1})
view.
AddFormItem(view.username, 3, 1, 1, 1).
AddFormItem(view.password, 3, 3, 1, 1).
AddFormItem(view.homeserver, 3, 5, 1, 1).
AddFormItem(view.loginButton, 1, 7, 3, 1).
AddFormItem(view.quitButton, 1, 9, 3, 1).
AddComponent(view.usernameLabel, 1, 1, 1, 1).
AddComponent(view.passwordLabel, 1, 3, 1, 1).
AddComponent(view.homeserverLabel, 1, 5, 1, 1)
AddFormItem(view.email, 3, 1, 1, 1).
AddFormItem(view.code, 3, 3, 1, 1).
AddFormItem(view.loginButton, 1, 5, 3, 1).
AddFormItem(view.quitButton, 1, 7, 3, 1).
AddComponent(view.emailLabel, 1, 1, 1, 1).
AddComponent(view.codeLabel, 1, 3, 1, 1)
view.SetOnFocusChanged(view.focusChanged)
view.FocusNextItem()
ui.loginView = view
view.container = mauview.Center(mauview.NewBox(view).SetTitle("Log in to Matrix"), 45, 13)
view.container = mauview.Center(mauview.NewBox(view).SetTitle("Log in to Matrix"), 40, 11)
view.container.SetAlwaysFocusChild(true)
return view.container
}
func (view *LoginView) resolveWellKnown() {
_, homeserver, err := id.UserID(view.username.GetText()).Parse()
func (view *LoginView) emailAuthFlow() {
view.Error("")
resp, err := beeper.StartLogin()
if err != nil {
view.code.SetText("")
view.Error(err.Error())
return
}
view.homeserver.SetText("Resolving...")
resp, err := mautrix.DiscoverClientAPI(homeserver)
view.code.SetPlaceholder("Talking to Beeper servers…")
view.parent.Render()
err = beeper.SendLoginEmail(resp.RequestID, view.email.GetText())
if err != nil {
view.homeserver.SetText("")
view.code.SetText("")
view.code.SetPlaceholder("123456")
view.Error(err.Error())
} else if resp != nil {
view.homeserver.SetText(resp.Homeserver.BaseURL)
view.parent.Render()
return
}
view.session = resp.RequestID
view.code.SetPlaceholder("Check your inbox…")
view.parent.Render()
}
func (view *LoginView) focusChanged(from, to mauview.Component) {
if from == view.username && view.homeserver.GetText() == "" {
go view.resolveWellKnown()
if from == view.email {
go view.emailAuthFlow()
}
}
@ -139,32 +140,32 @@ func (view *LoginView) Error(err string) {
if len(err) == 0 && view.error != nil {
debug.Print("Hiding error")
view.RemoveComponent(view.error)
view.container.SetHeight(13)
view.container.SetHeight(11)
view.SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1})
view.error = nil
} else if len(err) > 0 {
debug.Print("Showing error", err)
if view.error == nil {
view.error = mauview.NewTextView().SetTextColor(tcell.ColorRed)
view.AddComponent(view.error, 1, 11, 3, 1)
view.AddComponent(view.error, 1, 9, 3, 1)
}
view.error.SetText(err)
errorHeight := int(math.Ceil(float64(runewidth.StringWidth(err)) / 41))
view.container.SetHeight(14 + errorHeight)
view.container.SetHeight(12 + errorHeight)
view.SetRow(11, errorHeight)
}
view.parent.Render()
}
func (view *LoginView) actuallyLogin(hs, mxid, password string) {
debug.Printf("Logging into %s as %s...", hs, mxid)
view.config.HS = hs
func (view *LoginView) actuallyLogin(session, code string) {
debug.Printf("Logging into Beeper with code %s...", code)
view.config.HS = "https://matrix.beeper.com"
if err := view.matrix.InitClient(false); err != nil {
debug.Print("Init error:", err)
view.Error(err.Error())
} else if err = view.matrix.Login(mxid, password); err != nil {
} else if err = view.matrix.BeeperLogin(session, code); err != nil {
if httpErr, ok := err.(mautrix.HTTPError); ok {
if httpErr.RespError != nil && len(httpErr.RespError.Err) > 0 {
view.Error(httpErr.RespError.Err)
@ -186,11 +187,9 @@ func (view *LoginView) Login() {
if view.loading {
return
}
hs := view.homeserver.GetText()
mxid := view.username.GetText()
password := view.password.GetText()
code := view.code.GetText()
view.loading = true
view.loginButton.SetText("Logging in...")
go view.actuallyLogin(hs, mxid, password)
go view.actuallyLogin(view.session, code)
}

View file

@ -36,6 +36,7 @@ import (
ifc "maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/lib/notification"
"maunium.net/go/gomuks/matrix/rooms"
"maunium.net/go/gomuks/ui/beepberry"
"maunium.net/go/gomuks/ui/messages"
"maunium.net/go/gomuks/ui/widget"
)
@ -45,6 +46,7 @@ type MainView struct {
roomList *RoomList
roomView *mauview.Box
rosterView *RosterView
currentRoom *RoomView
rooms map[id.RoomID]*RoomView
roomsLock sync.RWMutex
@ -55,6 +57,9 @@ type MainView struct {
lastFocusTime time.Time
led *beepberry.LED
ledLock sync.RWMutex
matrix ifc.MatrixContainer
gmx ifc.Gomuks
config *config.Config
@ -73,8 +78,13 @@ func (ui *GomuksUI) NewMainView() mauview.Component {
parent: ui,
}
mainView.roomList = NewRoomList(mainView)
mainView.rosterView = NewRosterView(mainView)
mainView.cmdProcessor = NewCommandProcessor(mainView)
if led, err := beepberry.NewLED(); err == nil {
mainView.led = led
}
mainView.flex.
AddFixedComponent(mainView.roomList, 25).
AddFixedComponent(widget.NewBorder(), 1).
@ -86,6 +96,25 @@ func (ui *GomuksUI) NewMainView() mauview.Component {
return mainView
}
func (view *MainView) CmdProcessor() *CommandProcessor {
return view.cmdProcessor
}
func (view *MainView) FlashLED(r, g, b uint16) {
if view.led == nil {
return
}
view.ledLock.Lock()
defer view.ledLock.Unlock()
view.led.SetColor(r, g, b)
view.led.On()
time.Sleep(time.Second)
view.led.Off()
view.led.SetColor(0xFF, 0xFF, 0xFF)
}
func (view *MainView) ShowModal(modal mauview.Component) {
view.modal = modal
var ok bool
@ -103,7 +132,9 @@ func (view *MainView) HideModal() {
}
func (view *MainView) Draw(screen mauview.Screen) {
if view.config.Preferences.HideRoomList {
if view.config.Preferences.DisplayMode == config.DisplayModeModern {
view.rosterView.Draw(screen)
} else if view.config.Preferences.HideRoomList {
view.roomView.Draw(screen)
} else {
view.flex.Draw(screen)
@ -168,6 +199,8 @@ func (view *MainView) OnKeyEvent(event mauview.KeyEvent) bool {
if view.modal != nil {
return view.modal.OnKeyEvent(event)
} else if view.config.Preferences.DisplayMode == config.DisplayModeModern {
return view.rosterView.OnKeyEvent(event)
}
kb := config.Keybind{
@ -211,6 +244,9 @@ func (view *MainView) OnMouseEvent(event mauview.MouseEvent) bool {
if view.modal != nil {
return view.modal.OnMouseEvent(event)
}
if view.config.Preferences.DisplayMode == config.DisplayModeModern {
return view.rosterView.OnMouseEvent(event)
}
if view.config.Preferences.HideRoomList {
return view.roomView.OnMouseEvent(event)
}
@ -324,6 +360,7 @@ func (view *MainView) RemoveRoom(room *rooms.Room) {
}
debug.Print("Removing", room.ID, room.GetTitle())
view.rosterView.Remove(room)
view.roomList.Remove(room)
t, r := view.roomList.Selected()
view.switchRoom(t, r, false)
@ -340,6 +377,7 @@ func (view *MainView) addRoom(room *rooms.Room) *RoomView {
}
debug.Print("Adding", room.ID, room.GetTitle())
view.roomList.Add(room)
view.rosterView.Add(room)
view.roomsLock.Lock()
roomView := view.addRoomPage(room)
if !view.roomList.HasSelected() {
@ -359,6 +397,7 @@ func (view *MainView) SetRooms(rooms *rooms.RoomCache) {
continue
}
view.roomList.Add(room)
view.rosterView.Add(room)
view.addRoomPage(room)
}
t, r := view.roomList.First()
@ -397,6 +436,7 @@ func sendNotification(room *rooms.Room, sender, text string, critical, sound boo
func (view *MainView) Bump(room *rooms.Room) {
view.roomList.Bump(room)
view.rosterView.Bump(room)
}
func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) {

571
ui/view-roster.go Normal file
View file

@ -0,0 +1,571 @@
// 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 ui
import (
"strings"
"time"
sync "github.com/sasha-s/go-deadlock"
"go.mau.fi/mauview"
"go.mau.fi/tcell"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/matrix/rooms"
"maunium.net/go/gomuks/ui/widget"
"maunium.net/go/mautrix/event"
)
const beeperBridgeSuffix = ":beeper.local"
type split struct {
name, tag string
collapsed bool
rooms []*rooms.Room
}
func (splt *split) title(selected bool) string {
char := "▼"
if splt.collapsed {
if selected {
char = "▷"
} else {
char = "▶"
}
}
return splt.name + " " + char
}
type RosterView struct {
mauview.Component
sync.RWMutex
split *split
room *rooms.Room
splits []*split
splitLookup map[string]*split
height, width,
splitOffset, roomOffset int
focused bool
parent *MainView
}
func NewRosterView(mainView *MainView) *RosterView {
splts := make([]*split, 0)
splts = append(splts, &split{
name: "Favorites",
tag: "m.favourite",
rooms: make([]*rooms.Room, 0),
})
splts = append(splts, &split{
name: "Inbox",
tag: "",
rooms: make([]*rooms.Room, 0),
})
splts = append(splts, &split{
name: "Low Priority",
tag: "m.lowpriority",
collapsed: true,
rooms: make([]*rooms.Room, 0),
})
rstr := &RosterView{
parent: mainView,
splits: splts,
splitLookup: make(map[string]*split, 0),
}
for _, splt := range rstr.splits {
rstr.splitLookup[splt.tag] = splt
}
return rstr
}
// splitForRoom returns the corresponding split for a given room.
func (rstr *RosterView) splitForRoom(room *rooms.Room, create bool) *split {
if room == nil {
return nil
}
if strings.HasSuffix(room.ID.String(), beeperBridgeSuffix) {
splt, sortByTag := rstr.splitForDiscordAndSlackRooms(room, create)
if !sortByTag {
return splt
}
}
for _, tag := range room.Tags() {
if splt, ok := rstr.splitLookup[tag.Tag]; ok {
return splt
}
}
return nil
}
// splitForDiscordAndSlackRooms returns the corresponding split for
// passed bridged rooms from the Discord and Slack networks. If the room
// is not bridged, or is not from Discord or Slack, it returns (nil, true).
// If the split does not yet exist, it is created.
func (rstr *RosterView) splitForDiscordAndSlackRooms(room *rooms.Room, create bool) (*split, bool) {
bridgeEvent := room.MostRecentStateEventOfType(event.StateBridge)
if bridgeEvent == nil {
return nil, true
}
if _, server, err := bridgeEvent.Sender.Parse(); err != nil || server != beeperBridgeSuffix[1:] {
return nil, true
}
content := bridgeEvent.Content
bridge := content.AsBridge()
if bridge.Protocol.DisplayName != "Discord" && bridge.Protocol.DisplayName != "Slack" {
return nil, true
}
if bridge.Network == nil {
// Need to check account data for "show in inbox" settings, which
// govern the display of DMs.
if _, ok := content.Raw["com.beeper.room_type"]; ok && bridge.Protocol.DisplayName == "Discord" {
bridge.Network = &event.BridgeInfoSection{
ID: "discord-dms",
DisplayName: "Discord DMs",
}
} else {
return nil, true
}
}
if splt, ok := rstr.splitLookup[bridge.Network.ID]; ok {
return splt, false
}
if create {
splt := &split{
name: bridge.Network.DisplayName,
tag: bridge.Network.ID,
collapsed: true,
rooms: make([]*rooms.Room, 0),
}
rstr.splits = append(rstr.splits, splt)
rstr.splitLookup[splt.tag] = splt
return splt, false
}
return nil, true
}
func (rstr *RosterView) Add(room *rooms.Room) {
if room.IsReplaced() {
return
}
rstr.Lock()
defer rstr.Unlock()
splt := rstr.splitForRoom(room, true)
if splt == nil {
return
}
insertAt := len(splt.rooms)
for i := 0; i < len(splt.rooms); i++ {
if splt.rooms[i] == room {
return
} else if room.LastReceivedMessage.After(splt.rooms[i].LastReceivedMessage) {
insertAt = i
break
}
}
splt.rooms = append(splt.rooms, nil)
copy(splt.rooms[insertAt+1:], splt.rooms[insertAt:len(splt.rooms)-1])
splt.rooms[insertAt] = room
}
func (rstr *RosterView) Remove(room *rooms.Room) {
rstr.Lock()
defer rstr.Unlock()
splt, index := rstr.indexOfRoom(room)
if index < 0 || index > len(splt.rooms) {
return
}
last := len(splt.rooms) - 1
if index < last {
copy(splt.rooms[index:], splt.rooms[index+1:])
}
splt.rooms[last] = nil
splt.rooms = splt.rooms[:last]
}
func (rstr *RosterView) Bump(room *rooms.Room) {
rstr.Remove(room)
rstr.Add(room)
}
func (rstr *RosterView) indexOfRoom(room *rooms.Room) (*split, int) {
if room == nil {
return nil, -1
}
splt := rstr.splitForRoom(room, false)
if splt == nil {
return nil, -1
}
for index, entry := range splt.rooms {
if entry == room {
return splt, index
}
}
return nil, -1
}
func (rstr *RosterView) indexOfSplit(split *split) int {
for index, entry := range rstr.splits {
if entry == split {
return index
}
}
return -1
}
func (rstr *RosterView) getMostRecentMessage(room *rooms.Room) (string, bool) {
roomView, _ := rstr.parent.getRoomView(room.ID, true)
if msgView := roomView.MessageView(); len(msgView.messages) < 20 && !msgView.initialHistoryLoaded {
msgView.initialHistoryLoaded = true
go rstr.parent.LoadHistory(room.ID)
}
if len(roomView.content.messages) > 0 {
for index := len(roomView.content.messages) - 1; index >= 0; index-- {
if roomView.content.messages[index].Type == event.MsgText {
return roomView.content.messages[index].PlainText(), true
}
}
}
return "It's quite empty in here.", false
}
func (rstr *RosterView) first() (*split, *rooms.Room) {
for _, splt := range rstr.splits {
if !splt.collapsed && len(splt.rooms) > 0 {
return splt, splt.rooms[0]
}
}
return rstr.splits[0], rstr.splits[0].rooms[0]
}
func (rstr *RosterView) Last() (*split, *rooms.Room) {
rstr.Lock()
defer rstr.Unlock()
for index := len(rstr.splits) - 1; index >= 0; index-- {
if rstr.splits[index].collapsed || len(rstr.splits[index].rooms) == 0 {
continue
}
splt := rstr.splits[index]
return splt, splt.rooms[len(splt.rooms)-1]
}
return rstr.splits[len(rstr.splits)-1], rstr.splits[len(rstr.splits)-1].rooms[0]
}
func (rstr *RosterView) MatchOffsetsToSelection() {
rstr.Lock()
defer rstr.Unlock()
var splt *split
splt, rstr.roomOffset = rstr.indexOfRoom(rstr.room)
rstr.splitOffset = rstr.indexOfSplit(splt)
}
func (rstr *RosterView) ScrollNext() {
rstr.Lock()
if splt, index := rstr.indexOfRoom(rstr.room); splt == nil || index == -1 {
rstr.split, rstr.room = rstr.first()
} else if index < len(splt.rooms)-1 && !splt.collapsed {
rstr.room = splt.rooms[index+1]
} else {
idx := -1
for i, s := range rstr.splits {
if s == rstr.split {
idx = i
}
}
for i := idx + 1; i < len(rstr.splits); i++ {
if len(rstr.splits[i].rooms) > 0 {
rstr.split = rstr.splits[i]
rstr.room = rstr.splits[i].rooms[0]
break
}
}
}
rstr.Unlock()
if rstr.HeightThroughSelection() > rstr.height {
rstr.MatchOffsetsToSelection()
}
}
func (rstr *RosterView) ScrollPrev() {
rstr.Lock()
defer rstr.Unlock()
if splt, index := rstr.indexOfRoom(rstr.room); splt == nil || index == -1 {
return
} else if index > 0 && !splt.collapsed {
rstr.room = splt.rooms[index-1]
if index == rstr.roomOffset {
rstr.roomOffset--
}
} else {
for idx := len(rstr.splits) - 1; idx > 0; idx-- {
if rstr.splits[idx] == rstr.split {
rstr.split = rstr.splits[idx-1]
rstr.splitOffset = idx - 1
if len(rstr.split.rooms) > 0 {
if rstr.split.collapsed {
rstr.room = rstr.split.rooms[0]
rstr.roomOffset = 0
} else {
rstr.room = rstr.split.rooms[len(rstr.split.rooms)-1]
rstr.roomOffset = len(rstr.split.rooms) - 1
}
}
return
}
}
}
}
func (rstr *RosterView) HeightThroughSelection() int {
height := 3
for _, splt := range rstr.splits[rstr.splitOffset:] {
if len(splt.rooms) == 0 {
continue
}
height++
if splt.collapsed {
continue
}
for _, r := range splt.rooms[rstr.roomOffset:] {
height += 2
if r == rstr.room {
return height
}
}
}
return -1
}
func (rstr *RosterView) Draw(screen mauview.Screen) {
if rstr.focused {
if roomView, ok := rstr.parent.getRoomView(rstr.room.ID, true); ok {
roomView.Update()
roomView.Draw(screen)
return
}
}
rstr.width, rstr.height = screen.Size()
titleStyle := tcell.StyleDefault.Foreground(tcell.ColorDefault).Bold(true)
mainStyle := titleStyle.Bold(false)
now := time.Now()
tm := now.Format("15:04")
tmX := rstr.width - 3 - len(tm)
// first line
widget.WriteLine(screen, mauview.AlignLeft, "GOMUKS", 2, 1, tmX, titleStyle)
widget.WriteLine(screen, mauview.AlignLeft, tm, tmX, 1, 2+len(tm), titleStyle)
// second line
widget.WriteLine(screen, mauview.AlignRight, now.Format("Mon, Jan 02"), 0, 2, rstr.width-3, mainStyle)
// third line
widget.NewBorder().Draw(mauview.NewProxyScreen(screen, 2, 3, rstr.width-5, 1))
y := 4
for _, splt := range rstr.splits[rstr.splitOffset:] {
if len(splt.rooms) == 0 {
continue
}
name := splt.title(splt == rstr.split)
halfWidth := (rstr.width - 5 - len(name)) / 2
widget.WriteLineColor(screen, mauview.AlignCenter, name, halfWidth, y, halfWidth, tcell.ColorGray)
y++
if splt.collapsed {
continue
}
iter := splt.rooms
if splt == rstr.split {
iter = iter[rstr.roomOffset:]
}
for _, room := range iter {
if room.IsReplaced() {
continue
}
renderHeight := 2
if y+renderHeight >= rstr.height {
renderHeight = rstr.height - y
}
isSelected := room == rstr.room
style := tcell.StyleDefault.
Foreground(tcell.ColorDefault).
Bold(room.HasNewMessages())
if isSelected {
style = style.
Foreground(tcell.ColorBlack).
Background(tcell.ColorWhite).
Italic(true)
}
timestamp := room.LastReceivedMessage
tm := timestamp.Format("15:04")
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
if timestamp.Before(today) {
if timestamp.Before(today.AddDate(0, 0, -6)) {
tm = timestamp.Format("2006-01-02")
} else {
tm = timestamp.Format("Monday")
}
}
lastMessage, received := rstr.getMostRecentMessage(room)
msgStyle := style.Foreground(tcell.ColorGray).Italic(!received)
startingX := 2
if isSelected {
lastMessage = " " + lastMessage
msgStyle = msgStyle.Background(tcell.ColorWhite).Italic(true)
startingX += 2
widget.WriteLine(screen, mauview.AlignLeft, string(tcell.RuneDiamond)+" ", 2, y, 4, style)
}
tmX := rstr.width - 3 - len(tm)
widget.WriteLinePadded(screen, mauview.AlignLeft, room.GetTitle(), startingX, y, tmX, style)
widget.WriteLine(screen, mauview.AlignLeft, tm, tmX, y, startingX+len(tm), style)
widget.WriteLinePadded(screen, mauview.AlignLeft, lastMessage, 2, y+1, rstr.width-5, msgStyle)
y += renderHeight
if y >= rstr.height {
break
}
}
}
}
func (rstr *RosterView) OnKeyEvent(event mauview.KeyEvent) bool {
kb := config.Keybind{
Key: event.Key(),
Ch: event.Rune(),
Mod: event.Modifiers(),
}
if rstr.focused {
if rstr.parent.config.Keybindings.Roster[kb] == "clear" {
rstr.focused = false
rstr.split = nil
rstr.room = nil
} else {
if roomView, ok := rstr.parent.getRoomView(rstr.room.ID, true); ok {
return roomView.OnKeyEvent(event)
}
}
}
switch rstr.parent.config.Keybindings.Roster[kb] {
case "next_room":
rstr.ScrollNext()
case "prev_room":
rstr.ScrollPrev()
rstr.MatchOffsetsToSelection()
case "top":
rstr.Lock()
rstr.split, rstr.room = rstr.first()
rstr.Unlock()
rstr.MatchOffsetsToSelection()
case "bottom":
rstr.split, rstr.room = rstr.Last()
if rstr.HeightThroughSelection() > rstr.height {
rstr.MatchOffsetsToSelection()
}
case "clear":
rstr.split = nil
rstr.room = nil
case "quit":
rstr.parent.gmx.Stop(true)
case "enter":
if rstr.split != nil && !rstr.split.collapsed {
rstr.focused = rstr.room != nil
}
case "toggle_split":
if rstr.split != nil {
rstr.split.collapsed = !rstr.split.collapsed
}
default:
return false
}
return true
}
func (rstr *RosterView) OnMouseEvent(event mauview.MouseEvent) bool {
if rstr.focused {
if roomView, ok := rstr.parent.getRoomView(rstr.room.ID, true); ok {
return roomView.OnMouseEvent(event)
}
}
if event.HasMotion() {
return false
}
switch event.Buttons() {
case tcell.WheelUp:
rstr.ScrollPrev()
return true
case tcell.WheelDown:
rstr.ScrollNext()
return true
}
return false
}