forked from Mirrors/gomuks
Compare commits
104 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
11f6cb9d0e | ||
![]() |
05ccf81e57 | ||
![]() |
c3920a41b6 | ||
![]() |
fe0bdb225b | ||
![]() |
190eb0cdba | ||
![]() |
3c5a97576f | ||
![]() |
d495126371 | ||
![]() |
f96643230f | ||
![]() |
e72b7f91ef | ||
![]() |
3f1484ea4d | ||
![]() |
b177f2dde4 | ||
![]() |
993e0e5f33 | ||
![]() |
9b14639657 | ||
![]() |
d40649e9cc | ||
![]() |
85663fccc7 | ||
![]() |
d8b1fb40dc | ||
![]() |
58b212913c | ||
![]() |
ad1fd015cb | ||
![]() |
7e25710506 | ||
![]() |
8fcbdddc62 | ||
![]() |
948bf767bc | ||
![]() |
634a3350d5 | ||
![]() |
8a1e095f58 | ||
![]() |
3b333aef02 | ||
![]() |
fb10f59801 | ||
![]() |
f6f1a906d0 | ||
![]() |
a55f78d628 | ||
![]() |
80c8cd62ef | ||
![]() |
b7a4d58637 | ||
![]() |
2361a5fa96 | ||
![]() |
df0ff2035e | ||
![]() |
97154a6787 | ||
![]() |
ab18e4c28d | ||
![]() |
7d139c50d6 | ||
![]() |
97d2e77a8e | ||
![]() |
507aa3c61c | ||
![]() |
b8a41425bd | ||
![]() |
c628bfb97c | ||
![]() |
5db39fd50a | ||
![]() |
b1c940a0a8 | ||
![]() |
704fc53db1 | ||
![]() |
edda1a956a | ||
![]() |
4ebdb0fd38 | ||
![]() |
8889e2df54 | ||
![]() |
477326228e | ||
![]() |
5309f3c158 | ||
![]() |
c399f01227 | ||
![]() |
7aa90a9a36 | ||
![]() |
7251f684c9 | ||
![]() |
86659503f3 | ||
![]() |
0714fec38b | ||
![]() |
68cfe2be80 | ||
![]() |
6947f2c03c | ||
![]() |
89dd9f9a6a | ||
![]() |
e4c71fde7c | ||
![]() |
8ed5a4b1dd | ||
![]() |
2722459f22 | ||
![]() |
a6d6f7af04 | ||
![]() |
a9815e3b54 | ||
![]() |
02e1371f37 | ||
![]() |
2dbe60384f | ||
![]() |
cbb17effbf | ||
![]() |
1328aa82e3 | ||
![]() |
706375b5a1 | ||
![]() |
7e1f8bcc59 | ||
![]() |
73b0d3f1a2 | ||
![]() |
27160c1fc6 | ||
![]() |
50acd2474a | ||
![]() |
03f8db40a0 | ||
![]() |
81018c2da7 | ||
![]() |
04ceba153f | ||
![]() |
3b26a8fbd1 | ||
![]() |
6bb265cc66 | ||
![]() |
b9b363e686 | ||
![]() |
7a83ebd7f4 | ||
![]() |
22acad8287 | ||
![]() |
b9529e39e1 | ||
![]() |
abfcdae4ef | ||
![]() |
d4e820579c | ||
![]() |
7a2f907528 | ||
![]() |
99bd36f216 | ||
![]() |
9cecf0bd02 | ||
![]() |
0af8d507e1 | ||
![]() |
dc5632e946 | ||
![]() |
bd2c06e417 | ||
![]() |
3f01535cdf | ||
![]() |
80638d4a5b | ||
![]() |
6d4d6b7d20 | ||
![]() |
7fffa994af | ||
![]() |
1eb132a589 | ||
![]() |
f6b722f523 | ||
![]() |
a5ac5ec86e | ||
![]() |
167b4a497b | ||
![]() |
572bc357cb | ||
![]() |
78cda42654 | ||
![]() |
43b939f567 | ||
![]() |
24c0e66944 | ||
![]() |
2a01fdb559 | ||
![]() |
32665a2e5a | ||
![]() |
1a18a7e89b | ||
![]() |
5fae3fedb9 | ||
![]() |
1f62926e0e | ||
![]() |
a2432d031f | ||
![]() |
b086d68813 |
27 changed files with 1713 additions and 259 deletions
2
.github/workflows/go.yml
vendored
2
.github/workflows/go.yml
vendored
|
@ -1,4 +1,4 @@
|
|||
name: Go
|
||||
name: Lint + Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
|
|
99
.github/workflows/main.yml
vendored
Normal file
99
.github/workflows/main.yml
vendored
Normal 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
80
beeper/internal.go
Normal 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
59
beeper/login.go
Normal 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
|
||||
}
|
|
@ -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
24
config/display-mode.go
Normal 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"
|
||||
)
|
|
@ -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
|
||||
|
|
|
@ -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
2
go.mod
|
@ -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
5
go.sum
|
@ -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
172
headless/headless.go
Normal 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
69
headless/msg.go
Normal 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
88
initialize/dirs.go
Normal 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
|
||||
}
|
|
@ -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
|
|
@ -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
148
main.go
|
@ -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
|
||||
}
|
||||
|
|
112
matrix/matrix.go
112
matrix/matrix.go
|
@ -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)
|
||||
|
|
|
@ -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
93
ui/beepberry/led.go
Normal 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
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
571
ui/view-roster.go
Normal 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
|
||||
}
|
Loading…
Add table
Reference in a new issue