diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 186383d..ee71f45 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -5,45 +5,34 @@ on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ["1.22", "1.23"] + name: Lint and test ${{ matrix.go-version == '1.23' && '(latest)' || '(old)' }} + steps: - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: "1.22" - - - name: Install goimports - run: | - go install golang.org/x/tools/cmd/goimports@latest - export PATH="$HOME/go/bin:$PATH" - - - name: Install pre-commit - run: pip install pre-commit - - - name: Lint - run: pre-commit run -a - - build: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - go-version: ["1.21", "1.22"] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Go ${{ matrix.go-version }} uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} + cache: true - - name: Install libolm - run: sudo apt-get install libolm-dev libolm3 + - name: Install dependencies + run: | + sudo apt-get install libolm-dev libolm3 + go install golang.org/x/tools/cmd/goimports@latest + go install honnef.co/go/tools/cmd/staticcheck@latest + pip install pre-commit + export PATH="$HOME/go/bin:$PATH" - name: Build run: go build -v ./... + - name: Lint + run: pre-commit run -a + - name: Test run: go test -v ./... diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7ec01c1..11d0782 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,5 @@ stages: - build -- package default: before_script: @@ -14,7 +13,7 @@ cache: .build-linux: &build-linux stage: build before_script: - - export GO_LDFLAGS="-s -w -linkmode external -extldflags -static -X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" + - export GO_LDFLAGS="-s -w -linkmode external -extldflags -static -X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date -Iseconds`'" script: - go build -ldflags "$GO_LDFLAGS" -o gomuks artifacts: @@ -24,10 +23,16 @@ cache: linux/amd64: <<: *build-linux image: dock.mau.dev/tulir/gomuks-build-docker:linux-amd64 + tags: + - linux + - amd64 linux/arm: <<: *build-linux image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm + tags: + - linux + - amd64 linux/arm64: <<: *build-linux @@ -36,15 +41,6 @@ linux/arm64: - linux - arm64 -windows/amd64: - image: dock.mau.dev/tulir/gomuks-build-docker:windows-amd64 - stage: build - script: - - go build -o gomuks.exe - artifacts: - paths: - - gomuks.exe - macos/arm64: stage: build tags: @@ -54,31 +50,15 @@ macos/arm64: - export LIBRARY_PATH=/opt/homebrew/lib - export CPATH=/opt/homebrew/include - export PATH=/opt/homebrew/bin:$PATH - - export GO_LDFLAGS="-X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" + - export GO_LDFLAGS="-X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '-Iseconds'`'" script: - - mkdir gomuks-macos-arm64 - - go build -ldflags "$GO_LDFLAGS" -o gomuks-macos-arm64/gomuks - - install_name_tool -change /opt/homebrew/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks-macos-arm64/gomuks - - install_name_tool -add_rpath @executable_path gomuks-macos-arm64/gomuks - - install_name_tool -add_rpath /opt/homebrew/opt/libolm/lib gomuks-macos-arm64/gomuks - - install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks-macos-arm64/gomuks - - cp /opt/homebrew/opt/libolm/lib/libolm.3.dylib gomuks-macos-arm64/ + - go build -ldflags "$GO_LDFLAGS" -o gomuks + - install_name_tool -change /opt/homebrew/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks + - install_name_tool -add_rpath @executable_path gomuks + - install_name_tool -add_rpath /opt/homebrew/opt/libolm/lib gomuks + - install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks + - cp /opt/homebrew/opt/libolm/lib/libolm.3.dylib . artifacts: paths: - - gomuks-macos-arm64 - -debian: - image: debian - stage: package - dependencies: - - linux/amd64 - only: - - tags - script: - - mkdir -p deb/usr/bin - - cp gomuks deb/usr/bin/gomuks - - chmod -R -s deb/DEBIAN && chmod -R 0755 deb/DEBIAN - - dpkg-deb --build deb gomuks.deb - artifacts: - paths: - - gomuks.deb + - gomuks + - libolm.3.dylib diff --git a/README.md b/README.md index cbec5d3..a6ba8ba 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,9 @@ [![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE) [![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases) [![GitLab CI](https://mau.dev/tulir/gomuks/badges/master/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines) -[![Maintainability](https://img.shields.io/codeclimate/maintainability/tulir/gomuks.svg)](https://codeclimate.com/github/tulir/gomuks) [![Packaging status](https://repology.org/badge/tiny-repos/gomuks.svg)](https://repology.org/project/gomuks/versions) -![Chat Preview](chat-preview.png) - -A terminal Matrix client written in Go using [mautrix](https://github.com/tulir/mautrix-go) and [mauview](https://github.com/tulir/mauview). - -## Docs -For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/). +A Matrix client written in Go using [mautrix](https://github.com/tulir/mautrix-go). ## Discussion Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net) diff --git a/build.sh b/build.sh deleted file mode 100755 index 2409c5b..0000000 --- a/build.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@" diff --git a/chat-preview.png b/chat-preview.png deleted file mode 100644 index 4f7c875..0000000 Binary files a/chat-preview.png and /dev/null differ diff --git a/config/config.go b/config/config.go deleted file mode 100644 index b3de936..0000000 --- a/config/config.go +++ /dev/null @@ -1,401 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package config - -import ( - _ "embed" - "encoding/json" - "fmt" - "io/ioutil" - "os" - "path/filepath" - "strconv" - "strings" - - "gopkg.in/yaml.v3" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" - - "go.mau.fi/cbind" - "go.mau.fi/tcell" - - "maunium.net/go/gomuks/debug" - "maunium.net/go/gomuks/matrix/rooms" -) - -type AuthCache struct { - NextBatch string `yaml:"next_batch"` - FilterID string `yaml:"filter_id"` - FilterVersion int `yaml:"filter_version"` - InitialSyncDone bool `yaml:"initial_sync_done"` -} - -type UserPreferences struct { - HideUserList bool `yaml:"hide_user_list"` - HideRoomList bool `yaml:"hide_room_list"` - HideTimestamp bool `yaml:"hide_timestamp"` - BareMessageView bool `yaml:"bare_message_view"` - DisableImages bool `yaml:"disable_images"` - DisableTypingNotifs bool `yaml:"disable_typing_notifs"` - DisableEmojis bool `yaml:"disable_emojis"` - DisableMarkdown bool `yaml:"disable_markdown"` - DisableHTML bool `yaml:"disable_html"` - DisableDownloads bool `yaml:"disable_downloads"` - DisableNotifications bool `yaml:"disable_notifications"` - DisableShowURLs bool `yaml:"disable_show_urls"` - - InlineURLMode string `yaml:"inline_url_mode"` -} - -var InlineURLsProbablySupported bool - -func init() { - vteVersion, _ := strconv.Atoi(os.Getenv("VTE_VERSION")) - term := os.Getenv("TERM") - // Enable inline URLs by default on VTE 0.50.0+ - InlineURLsProbablySupported = vteVersion > 5000 || - os.Getenv("TERM_PROGRAM") == "iTerm.app" || - term == "foot" || - term == "xterm-kitty" -} - -func (up *UserPreferences) EnableInlineURLs() bool { - return up.InlineURLMode == "enable" || (InlineURLsProbablySupported && up.InlineURLMode != "disable") -} - -type Keybind struct { - Mod tcell.ModMask - Key tcell.Key - Ch rune -} - -type ParsedKeybindings struct { - Main map[Keybind]string - Room map[Keybind]string - Modal map[Keybind]string - Visual map[Keybind]string -} - -type RawKeybindings struct { - Main map[string]string `yaml:"main,omitempty"` - Room map[string]string `yaml:"room,omitempty"` - Modal map[string]string `yaml:"modal,omitempty"` - Visual map[string]string `yaml:"visual,omitempty"` -} - -// Config contains the main config of gomuks. -type Config struct { - UserID id.UserID `yaml:"mxid"` - DeviceID id.DeviceID `yaml:"device_id"` - AccessToken string `yaml:"access_token"` - HS string `yaml:"homeserver"` - - RoomCacheSize int `yaml:"room_cache_size"` - RoomCacheAge int64 `yaml:"room_cache_age"` - - NotifySound bool `yaml:"notify_sound"` - SendToVerifiedOnly bool `yaml:"send_to_verified_only"` - - Backspace1RemovesWord bool `yaml:"backspace1_removes_word"` - Backspace2RemovesWord bool `yaml:"backspace2_removes_word"` - - AlwaysClearScreen bool `yaml:"always_clear_screen"` - - Dir string `yaml:"-"` - DataDir string `yaml:"data_dir"` - CacheDir string `yaml:"cache_dir"` - HistoryPath string `yaml:"history_path"` - RoomListPath string `yaml:"room_list_path"` - MediaDir string `yaml:"media_dir"` - DownloadDir string `yaml:"download_dir"` - StateDir string `yaml:"state_dir"` - - Preferences UserPreferences `yaml:"-"` - AuthCache AuthCache `yaml:"-"` - Rooms *rooms.RoomCache `yaml:"-"` - PushRules *pushrules.PushRuleset `yaml:"-"` - Keybindings ParsedKeybindings `yaml:"-"` - - nosave bool -} - -// NewConfig creates a config that loads data from the given directory. -func NewConfig(configDir, dataDir, cacheDir, downloadDir string) *Config { - return &Config{ - Dir: configDir, - DataDir: dataDir, - CacheDir: cacheDir, - DownloadDir: downloadDir, - HistoryPath: filepath.Join(cacheDir, "history.db"), - RoomListPath: filepath.Join(cacheDir, "rooms.gob.gz"), - StateDir: filepath.Join(cacheDir, "state"), - MediaDir: filepath.Join(cacheDir, "media"), - - RoomCacheSize: 32, - RoomCacheAge: 1 * 60, - - NotifySound: true, - SendToVerifiedOnly: false, - Backspace1RemovesWord: true, - AlwaysClearScreen: true, - } -} - -// Clear clears the session cache and removes all history. -func (config *Config) Clear() { - _ = os.Remove(config.HistoryPath) - _ = os.Remove(config.RoomListPath) - _ = os.RemoveAll(config.StateDir) - _ = os.RemoveAll(config.MediaDir) - _ = os.RemoveAll(config.CacheDir) - config.nosave = true -} - -// ClearData clears non-temporary session data. -func (config *Config) ClearData() { - _ = os.RemoveAll(config.DataDir) -} - -func (config *Config) CreateCacheDirs() { - _ = os.MkdirAll(config.CacheDir, 0700) - _ = os.MkdirAll(config.DataDir, 0700) - _ = os.MkdirAll(config.StateDir, 0700) - _ = os.MkdirAll(config.MediaDir, 0700) -} - -func (config *Config) DeleteSession() { - config.AuthCache.NextBatch = "" - config.AuthCache.InitialSyncDone = false - config.AccessToken = "" - config.DeviceID = "" - config.Rooms = rooms.NewRoomCache(config.RoomListPath, config.StateDir, config.RoomCacheSize, config.RoomCacheAge, config.GetUserID) - config.PushRules = nil - - config.ClearData() - config.Clear() - config.nosave = false - config.CreateCacheDirs() -} - -func (config *Config) LoadAll() { - config.Load() - config.Rooms = rooms.NewRoomCache(config.RoomListPath, config.StateDir, config.RoomCacheSize, config.RoomCacheAge, config.GetUserID) - config.LoadAuthCache() - config.LoadPushRules() - config.LoadPreferences() - config.LoadKeybindings() - err := config.Rooms.LoadList() - if err != nil { - panic(err) - } -} - -// Load loads the config from config.yaml in the directory given to the config struct. -func (config *Config) Load() { - err := config.load("config", config.Dir, "config.yaml", config) - if err != nil { - panic(fmt.Errorf("failed to load config.yaml: %w", err)) - } - config.CreateCacheDirs() -} - -func (config *Config) SaveAll() { - config.Save() - config.SaveAuthCache() - config.SavePushRules() - config.SavePreferences() - err := config.Rooms.SaveList() - if err != nil { - panic(err) - } - config.Rooms.SaveLoadedRooms() -} - -// Save saves this config to config.yaml in the directory given to the config struct. -func (config *Config) Save() { - config.save("config", config.Dir, "config.yaml", config) -} - -func (config *Config) LoadPreferences() { - _ = config.load("user preferences", config.CacheDir, "preferences.yaml", &config.Preferences) -} - -func (config *Config) SavePreferences() { - config.save("user preferences", config.CacheDir, "preferences.yaml", &config.Preferences) -} - -//go:embed keybindings.yaml -var DefaultKeybindings string - -func parseKeybindings(input map[string]string) (output map[Keybind]string) { - output = make(map[Keybind]string, len(input)) - for shortcut, action := range input { - mod, key, ch, err := cbind.Decode(shortcut) - if err != nil { - panic(fmt.Errorf("failed to parse keybinding %s -> %s: %w", shortcut, action, err)) - } - // TODO find out if other keys are parsed incorrectly like this - if key == tcell.KeyEscape { - ch = 0 - } - parsedShortcut := Keybind{ - Mod: mod, - Key: key, - Ch: ch, - } - output[parsedShortcut] = action - } - return -} - -func (config *Config) LoadKeybindings() { - var inputConfig RawKeybindings - - err := yaml.Unmarshal([]byte(DefaultKeybindings), &inputConfig) - if err != nil { - panic(fmt.Errorf("failed to unmarshal default keybindings: %w", err)) - } - _ = config.load("keybindings", config.Dir, "keybindings.yaml", &inputConfig) - - config.Keybindings.Main = parseKeybindings(inputConfig.Main) - config.Keybindings.Room = parseKeybindings(inputConfig.Room) - config.Keybindings.Modal = parseKeybindings(inputConfig.Modal) - config.Keybindings.Visual = parseKeybindings(inputConfig.Visual) -} - -func (config *Config) SaveKeybindings() { - config.save("keybindings", config.Dir, "keybindings.yaml", &config.Keybindings) -} - -func (config *Config) LoadAuthCache() { - err := config.load("auth cache", config.CacheDir, "auth-cache.yaml", &config.AuthCache) - if err != nil { - panic(fmt.Errorf("failed to load auth-cache.yaml: %w", err)) - } -} - -func (config *Config) SaveAuthCache() { - config.save("auth cache", config.CacheDir, "auth-cache.yaml", &config.AuthCache) -} - -func (config *Config) LoadPushRules() { - _ = config.load("push rules", config.CacheDir, "pushrules.json", &config.PushRules) - -} - -func (config *Config) SavePushRules() { - if config.PushRules == nil { - return - } - config.save("push rules", config.CacheDir, "pushrules.json", &config.PushRules) -} - -func (config *Config) load(name, dir, file string, target interface{}) error { - err := os.MkdirAll(dir, 0700) - if err != nil { - debug.Print("Failed to create", dir) - return err - } - - path := filepath.Join(dir, file) - data, err := ioutil.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil - } - debug.Print("Failed to read", name, "from", path) - return err - } - - if strings.HasSuffix(file, ".yaml") { - err = yaml.Unmarshal(data, target) - } else { - err = json.Unmarshal(data, target) - } - if err != nil { - debug.Print("Failed to parse", name, "at", path) - return err - } - return nil -} - -func (config *Config) save(name, dir, file string, source interface{}) { - if config.nosave { - return - } - - err := os.MkdirAll(dir, 0700) - if err != nil { - debug.Print("Failed to create", dir) - panic(err) - } - var data []byte - if strings.HasSuffix(file, ".yaml") { - data, err = yaml.Marshal(source) - } else { - data, err = json.Marshal(source) - } - if err != nil { - debug.Print("Failed to marshal", name) - panic(err) - } - - path := filepath.Join(dir, file) - err = ioutil.WriteFile(path, data, 0600) - if err != nil { - debug.Print("Failed to write", name, "to", path) - panic(err) - } -} - -func (config *Config) GetUserID() id.UserID { - return config.UserID -} - -const FilterVersion = 1 - -func (config *Config) SaveFilterID(_ id.UserID, filterID string) { - config.AuthCache.FilterID = filterID - config.AuthCache.FilterVersion = FilterVersion - config.SaveAuthCache() -} - -func (config *Config) LoadFilterID(_ id.UserID) string { - if config.AuthCache.FilterVersion != FilterVersion { - return "" - } - return config.AuthCache.FilterID -} - -func (config *Config) SaveNextBatch(_ id.UserID, nextBatch string) { - config.AuthCache.NextBatch = nextBatch - config.SaveAuthCache() -} - -func (config *Config) LoadNextBatch(_ id.UserID) string { - return config.AuthCache.NextBatch -} - -func (config *Config) SaveRoom(_ *mautrix.Room) { - panic("SaveRoom is not supported") -} - -func (config *Config) LoadRoom(_ id.RoomID) *mautrix.Room { - panic("LoadRoom is not supported") -} diff --git a/config/doc.go b/config/doc.go deleted file mode 100644 index e570e0d..0000000 --- a/config/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package config contains the wrappers for gomuks configurations and sessions. -package config diff --git a/config/keybindings.yaml b/config/keybindings.yaml deleted file mode 100644 index 3e6cf32..0000000 --- a/config/keybindings.yaml +++ /dev/null @@ -1,42 +0,0 @@ -main: - 'Ctrl+Down': next_room - 'Ctrl+Up': prev_room - 'Ctrl+k': search_rooms - 'Ctrl+Home': scroll_up - 'Ctrl+End': scroll_down - 'Ctrl+Enter': add_newline - 'Ctrl+l': show_bare - 'Alt+Down': next_room - 'Alt+Up': prev_room - 'Alt+k': search_rooms - 'Alt+Home': scroll_up - 'Alt+End': scroll_down - 'Alt+Enter': add_newline - 'Alt+a': next_active_room - 'Alt+l': show_bare - -modal: - 'Tab': select_next - 'Down': select_next - 'Backtab': select_prev - 'Up': select_prev - 'Enter': confirm - 'Escape': cancel - -visual: - 'Escape': clear - 'h': clear - 'Up': select_prev - 'k': select_prev - 'Down': select_next - 'j': select_next - 'Enter': confirm - 'l': confirm - -room: - 'Escape': clear - 'Ctrl+p': scroll_up - 'Ctrl+n': scroll_down - 'PageUp': scroll_up - 'PageDown': scroll_down - 'Enter': send diff --git a/deb/DEBIAN/control b/deb/DEBIAN/control deleted file mode 100644 index 2bfcbc2..0000000 --- a/deb/DEBIAN/control +++ /dev/null @@ -1,7 +0,0 @@ -Package: gomuks -Version: 0.3.1-1 -Section: net -Priority: optional -Architecture: amd64 -Maintainer: Tulir Asokan -Description: A terminal based Matrix client written in Go. diff --git a/debug/debug.go b/debug/debug.go deleted file mode 100644 index f350124..0000000 --- a/debug/debug.go +++ /dev/null @@ -1,184 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package debug - -import ( - "bytes" - "fmt" - "io" - "io/ioutil" - "os" - "os/user" - "path/filepath" - "runtime" - "runtime/debug" - "time" - - "github.com/sasha-s/go-deadlock" -) - -var writer io.Writer -var RecoverPrettyPanic bool = true -var DeadlockDetection bool -var WriteLogs bool -var OnRecover func() -var LogDirectory = GetUserDebugDir() - -func GetUserDebugDir() string { - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { - return filepath.Join(os.TempDir(), "gomuks-"+getUname()) - } - // See https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html - if xdgStateHome := os.Getenv("XDG_STATE_HOME"); xdgStateHome != "" { - return filepath.Join(xdgStateHome, "gomuks") - } - home := os.Getenv("HOME") - if home == "" { - fmt.Println("XDG_STATE_HOME and HOME are both unset") - os.Exit(1) - } - return filepath.Join(home, ".local", "state", "gomuks") -} - -func getUname() string { - currUser, err := user.Current() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - return currUser.Username -} - -func Initialize() { - err := os.MkdirAll(LogDirectory, 0750) - if err != nil { - RecoverPrettyPanic = false - DeadlockDetection = false - WriteLogs = false - return - } - - if WriteLogs { - writer, err = os.OpenFile(filepath.Join(LogDirectory, "debug.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0640) - if err != nil { - panic(err) - } - _, _ = fmt.Fprintf(writer, "======================= Debug init @ %s =======================\n", time.Now().Format("2006-01-02 15:04:05")) - } - - if DeadlockDetection { - deadlocks, err := os.OpenFile(filepath.Join(LogDirectory, "deadlock.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0640) - if err != nil { - panic(err) - } - deadlock.Opts.LogBuf = deadlocks - deadlock.Opts.OnPotentialDeadlock = func() { - if OnRecover != nil { - OnRecover() - } - _, _ = fmt.Fprintf(os.Stderr, "Potential deadlock detected. See %s/deadlock.log for more information.", LogDirectory) - os.Exit(88) - } - _, err = fmt.Fprintf(deadlocks, "======================= Debug init @ %s =======================\n", time.Now().Format("2006-01-02 15:04:05")) - if err != nil { - panic(err) - } - } else { - deadlock.Opts.Disable = true - } -} - -func Printf(text string, args ...interface{}) { - if writer != nil { - _, _ = fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] ")) - _, _ = fmt.Fprintf(writer, text+"\n", args...) - } -} - -func Print(text ...interface{}) { - if writer != nil { - _, _ = fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] ")) - _, _ = fmt.Fprintln(writer, text...) - } -} - -func PrintStack() { - if writer != nil { - _, _ = writer.Write(debug.Stack()) - } -} - -// Recover recovers a panic, runs the OnRecover handler and either re-panics or -// shows an user-friendly message about the panic depending on whether or not -// the pretty panic mode is enabled. -func Recover() { - if p := recover(); p != nil { - if OnRecover != nil { - OnRecover() - } - if RecoverPrettyPanic { - PrettyPanic(p) - } else { - panic(p) - } - } -} - -const Oops = ` __________ -< Oh noes! > - ‾‾‾\‾‾‾‾‾‾ - \ ^__^ - \ (XX)\_______ - (__)\ )\/\ - U ||----W | - || || - -A fatal error has occurred. - -` - -func PrettyPanic(panic interface{}) { - fmt.Print(Oops) - traceFile := fmt.Sprintf(filepath.Join(LogDirectory, "panic-%s.txt"), time.Now().Format("2006-01-02--15-04-05")) - - var buf bytes.Buffer - _, _ = fmt.Fprintln(&buf, panic) - buf.Write(debug.Stack()) - err := ioutil.WriteFile(traceFile, buf.Bytes(), 0640) - - if err != nil { - fmt.Println("Saving the stack trace to", traceFile, "failed:") - fmt.Println("--------------------------------------------------------------------------------") - fmt.Println(err) - fmt.Println("--------------------------------------------------------------------------------") - fmt.Println("") - fmt.Println("You can file an issue at https://github.com/tulir/gomuks/issues.") - fmt.Println("Please provide the file save error (above) and the stack trace of the original error (below) when filing an issue.") - fmt.Println("") - fmt.Println("--------------------------------------------------------------------------------") - fmt.Println(panic) - debug.PrintStack() - fmt.Println("--------------------------------------------------------------------------------") - } else { - fmt.Println("The stack trace has been saved to", traceFile) - fmt.Println("") - fmt.Println("You can file an issue at https://github.com/tulir/gomuks/issues.") - fmt.Println("Please provide the contents of that file when filing an issue.") - } - os.Exit(1) -} diff --git a/debug/doc.go b/debug/doc.go deleted file mode 100644 index 253441c..0000000 --- a/debug/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package debug contains utilities to log debug messages and display panics nicely. -package debug diff --git a/go.mod b/go.mod index df2b20c..3d0c041 100644 --- a/go.mod +++ b/go.mod @@ -1,51 +1,5 @@ -module maunium.net/go/gomuks +module go.mau.fi/gomuks -go 1.21 +go 1.23.0 -require ( - github.com/alecthomas/chroma v0.10.0 - github.com/disintegration/imaging v1.6.2 - github.com/gabriel-vasile/mimetype v1.4.4 - github.com/kyokomi/emoji/v2 v2.2.13 - github.com/lithammer/fuzzysearch v1.1.8 - github.com/lucasb-eyer/go-colorful v1.2.0 - github.com/mattn/go-runewidth v0.0.15 - github.com/mattn/go-sqlite3 v1.14.22 - github.com/rivo/uniseg v0.4.7 - github.com/sasha-s/go-deadlock v0.3.1 - github.com/yuin/goldmark v1.7.4 - github.com/zyedidia/clipboard v1.0.4 - go.etcd.io/bbolt v1.3.10 - go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e - go.mau.fi/mauview v0.2.1 - go.mau.fi/tcell v0.4.0 - golang.org/x/image v0.18.0 - golang.org/x/net v0.27.0 - gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 - gopkg.in/vansante/go-ffprobe.v2 v2.2.0 - gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mauflag v1.0.0 - maunium.net/go/mautrix v0.11.2-0.20240620211416-fa19263891f5 - mvdan.cc/xurls/v2 v2.5.0 -) - -require ( - github.com/dlclark/regexp2 v1.4.0 // indirect - github.com/gdamore/encoding v1.0.0 // indirect - github.com/gorilla/mux v1.8.0 // indirect - github.com/gorilla/websocket v1.5.0 // indirect - github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect - github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect - github.com/tidwall/gjson v1.17.1 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect - golang.org/x/crypto v0.25.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/term v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - maunium.net/go/maulogger/v2 v2.3.2 // indirect -) - -replace github.com/mattn/go-runewidth => github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246 +toolchain go1.23.2 diff --git a/go.sum b/go.sum index c63c45d..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,129 +0,0 @@ -github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= -github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= -github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= -github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= -github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= -github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= -github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= -github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= -github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= -github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= -github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= -github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= -github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= -github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= -github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246 h1:WjkNcgoEaoL7i9mJuH+ff/hZHkSBR1KDdvoOoLpG6vs= -github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= -github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/zyedidia/clipboard v1.0.4 h1:r6GUQOyPtIaApRLeD56/U+2uJbXis6ANGbKWCljULEo= -github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA= -go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= -go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= -go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e h1:zY4TZmHAaUhrMFJQfh02dqxDYSfnnXlw/qRoFanxZTw= -go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e/go.mod h1:9nnzlslhUo/xO+8tsQgkFqG/W+SgD+r0iTYAuglzlmA= -go.mau.fi/mauview v0.2.1 h1:Sv+L3MQoo0VWuqgO/SIzhTzDcd7iqPGZgxH3au2kUGw= -go.mau.fi/mauview v0.2.1/go.mod h1:aTb1VjsjFmZ5YsdMQTIHrma9Ki2O0WwkS2Er7bIgoUs= -go.mau.fi/tcell v0.4.0 h1:IPFKhkzF3yZkcRYjzgYBWWiW0JWPTwEBoXlWTBT8o/4= -go.mau.fi/tcell v0.4.0/go.mod h1:77zV/6KL4Zip1u9ndjswACmu/LWwZ/oe3BE188uWMrA= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= -golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 h1:MZF6J7CV6s/h0HBkfqebrYfKCVEo5iN+wzE4QhV3Evo= -gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2/go.mod h1:s1Sn2yZos05Qfs7NKt867Xe18emOmtsO3eAKbDaon0o= -gopkg.in/vansante/go-ffprobe.v2 v2.2.0 h1:iuOqTsbfYuqIz4tAU9NWh22CmBGxlGHdgj4iqP+NUmY= -gopkg.in/vansante/go-ffprobe.v2 v2.2.0/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= -maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0= -maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= -maunium.net/go/mautrix v0.11.2-0.20240620211416-fa19263891f5 h1:zAELWR3594qziixinqE+CgKZzgQwpiubArNZXXTmfIs= -maunium.net/go/mautrix v0.11.2-0.20240620211416-fa19263891f5/go.mod h1:K29EcHwsNg6r7fMfwvi0GHQ9o5wSjqB9+Q8RjCIQEjA= -mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= -mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= diff --git a/gomuks.go b/gomuks.go deleted file mode 100644 index 392944f..0000000 --- a/gomuks.go +++ /dev/null @@ -1,192 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "errors" - "fmt" - "os" - "os/signal" - "path/filepath" - "runtime" - "strings" - "syscall" - "time" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/matrix" -) - -// Information to find out exactly which commit gomuks was built from. -// These are filled at build time with the -X linker flag. -var ( - Tag = "unknown" - Commit = "unknown" - BuildTime = "unknown" -) - -var ( - // Version is the version number of gomuks. Changed manually when making a release. - Version = "0.3.1" - // VersionString is the gomuks version, plus commit information. Filled in init() using the build-time values. - VersionString = "" -) - -func init() { - if len(Tag) > 0 && Tag[0] == 'v' { - Tag = Tag[1:] - } - if Tag != Version { - suffix := "" - if !strings.HasSuffix(Version, "+dev") { - suffix = "+dev" - } - if len(Commit) > 8 { - Version = fmt.Sprintf("%s%s.%s", Version, suffix, Commit[:8]) - } else { - Version = fmt.Sprintf("%s%s.unknown", Version, suffix) - } - } - VersionString = fmt.Sprintf("gomuks %s (%s with %s)", Version, BuildTime, runtime.Version()) -} - -// Gomuks is the wrapper for everything. -type Gomuks struct { - ui ifc.GomuksUI - matrix *matrix.Container - config *config.Config - stop chan bool -} - -// NewGomuks creates a new Gomuks instance with everything initialized, -// but does not start it. -func NewGomuks(uiProvider ifc.UIProvider, configDir, dataDir, cacheDir, downloadDir string) *Gomuks { - gmx := &Gomuks{ - stop: make(chan bool, 1), - } - - gmx.config = config.NewConfig(configDir, dataDir, cacheDir, downloadDir) - gmx.ui = uiProvider(gmx) - gmx.matrix = matrix.NewContainer(gmx) - - gmx.config.LoadAll() - gmx.ui.Init() - - debug.OnRecover = gmx.ui.Finish - - return gmx -} - -func (gmx *Gomuks) Version() string { - return Version -} - -// Save saves the active session and message history. -func (gmx *Gomuks) Save() { - gmx.config.SaveAll() -} - -// StartAutosave calls Save() every minute until it receives a stop signal -// on the Gomuks.stop channel. -func (gmx *Gomuks) StartAutosave() { - defer debug.Recover() - ticker := time.NewTicker(time.Minute) - for { - select { - case <-ticker.C: - if gmx.config.AuthCache.InitialSyncDone { - gmx.Save() - } - case val := <-gmx.stop: - if val { - return - } - } - } -} - -// Stop stops the Matrix syncer, the tview app and the autosave goroutine, -// then saves everything and calls os.Exit(0). -func (gmx *Gomuks) Stop(save bool) { - go gmx.internalStop(save) -} - -func (gmx *Gomuks) internalStop(save bool) { - debug.Print("Disconnecting from Matrix...") - gmx.matrix.Stop() - debug.Print("Cleaning up UI...") - gmx.ui.Stop() - gmx.stop <- true - if save { - gmx.Save() - } - debug.Print("Exiting process") - os.Exit(0) -} - -// Start opens a goroutine for the autosave loop and starts the tview app. -// -// If the tview app returns an error, it will be passed into panic(), which -// will be recovered as specified in Recover(). -func (gmx *Gomuks) Start() { - err := gmx.matrix.InitClient(true) - if err != nil { - if errors.Is(err, matrix.ErrServerOutdated) { - _, _ = fmt.Fprintln(os.Stderr, strings.Replace(err.Error(), "homeserver", gmx.config.HS, 1)) - _, _ = fmt.Fprintln(os.Stderr) - _, _ = fmt.Fprintf(os.Stderr, "See `%s --help` if you want to skip this check or clear all data.\n", os.Args[0]) - os.Exit(4) - } else if strings.HasPrefix(err.Error(), "failed to check server versions") { - _, _ = fmt.Fprintln(os.Stderr, "Failed to check versions supported by server:", errors.Unwrap(err)) - _, _ = fmt.Fprintln(os.Stderr) - _, _ = fmt.Fprintf(os.Stderr, "Modify %s if the server has moved.\n", filepath.Join(gmx.config.Dir, "config.yaml")) - _, _ = fmt.Fprintf(os.Stderr, "See `%s --help` if you want to skip this check or clear all data.\n", os.Args[0]) - os.Exit(5) - } else { - panic(err) - } - } - - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt, syscall.SIGTERM) - go func() { - <-c - gmx.Stop(true) - }() - - go gmx.StartAutosave() - if err = gmx.ui.Start(); err != nil { - panic(err) - } -} - -// Matrix returns the MatrixContainer instance. -func (gmx *Gomuks) Matrix() ifc.MatrixContainer { - return gmx.matrix -} - -// Config returns the Gomuks config instance. -func (gmx *Gomuks) Config() *config.Config { - return gmx.config -} - -// UI returns the Gomuks UI instance. -func (gmx *Gomuks) UI() ifc.GomuksUI { - return gmx.ui -} diff --git a/interface/doc.go b/interface/doc.go deleted file mode 100644 index cc09d44..0000000 --- a/interface/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package ifc contains interfaces to allow circular function calls without circular imports. -package ifc diff --git a/interface/gomuks.go b/interface/gomuks.go deleted file mode 100644 index e269071..0000000 --- a/interface/gomuks.go +++ /dev/null @@ -1,32 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ifc - -import ( - "maunium.net/go/gomuks/config" -) - -// Gomuks is the wrapper for everything. -type Gomuks interface { - Matrix() MatrixContainer - UI() GomuksUI - Config() *config.Config - Version() string - - Start() - Stop(save bool) -} diff --git a/interface/matrix.go b/interface/matrix.go deleted file mode 100644 index 58c428f..0000000 --- a/interface/matrix.go +++ /dev/null @@ -1,92 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ifc - -import ( - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/matrix/muksevt" - "maunium.net/go/gomuks/matrix/rooms" -) - -type Relation struct { - Type event.RelationType - Event *muksevt.Event -} - -type UploadedMediaInfo struct { - *mautrix.RespMediaUpload - EncryptionInfo *attachment.EncryptedFile - MsgType event.MessageType - Name string - Info *event.FileInfo -} - -type MatrixContainer interface { - Client() *mautrix.Client - Preferences() *config.UserPreferences - InitClient(isStartup bool) error - Initialized() bool - - Start() - Stop() - - Login(user, password string) error - Logout() - UIAFallback(authType mautrix.AuthType, sessionID string) error - - SendPreferencesToMatrix() - PrepareMarkdownMessage(roomID id.RoomID, msgtype event.MessageType, text, html string, relation *Relation) *muksevt.Event - PrepareMediaMessage(room *rooms.Room, path string, relation *Relation) (*muksevt.Event, error) - SendEvent(evt *muksevt.Event) (id.EventID, error) - Redact(roomID id.RoomID, eventID id.EventID, reason string) error - SendTyping(roomID id.RoomID, typing bool) - MarkRead(roomID id.RoomID, eventID id.EventID) - JoinRoom(roomID id.RoomID, server string) (*rooms.Room, error) - LeaveRoom(roomID id.RoomID) error - CreateRoom(req *mautrix.ReqCreateRoom) (*rooms.Room, error) - - FetchMembers(room *rooms.Room) error - GetHistory(room *rooms.Room, limit int, dbPointer uint64) ([]*muksevt.Event, uint64, error) - GetEvent(room *rooms.Room, eventID id.EventID) (*muksevt.Event, error) - GetRoom(roomID id.RoomID) *rooms.Room - GetOrCreateRoom(roomID id.RoomID) *rooms.Room - - UploadMedia(path string, encrypt bool) (*UploadedMediaInfo, error) - Download(uri id.ContentURI, file *attachment.EncryptedFile) ([]byte, error) - DownloadToDisk(uri id.ContentURI, file *attachment.EncryptedFile, target string) (string, error) - GetDownloadURL(uri id.ContentURI, file *attachment.EncryptedFile) string - GetCachePath(uri id.ContentURI) string - - Crypto() Crypto -} - -type Crypto interface { - Load() error - FlushStore() error - ProcessSyncResponse(resp *mautrix.RespSync, since string) bool - ProcessInRoomVerification(evt *event.Event) error - HandleMemberEvent(*event.Event) - DecryptMegolmEvent(*event.Event) (*event.Event, error) - EncryptMegolmEvent(id.RoomID, event.Type, interface{}) (*event.EncryptedEventContent, error) - ShareGroupSession(id.RoomID, []id.UserID) error - Fingerprint() string -} diff --git a/interface/ui.go b/interface/ui.go deleted file mode 100644 index 460da0e..0000000 --- a/interface/ui.go +++ /dev/null @@ -1,89 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ifc - -import ( - "time" - - "maunium.net/go/gomuks/matrix/muksevt" - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" -) - -type UIProvider func(gmx Gomuks) GomuksUI - -type GomuksUI interface { - Render() - HandleNewPreferences() - OnLogin() - OnLogout() - MainView() MainView - - Init() - Start() error - Stop() - Finish() -} - -type SyncingModal interface { - SetIndeterminate() - SetMessage(string) - SetSteps(int) - Step() - Close() -} - -type MainView interface { - GetRoom(roomID id.RoomID) RoomView - AddRoom(room *rooms.Room) - RemoveRoom(room *rooms.Room) - SetRooms(rooms *rooms.RoomCache) - Bump(room *rooms.Room) - - UpdateTags(room *rooms.Room) - - SetTyping(roomID id.RoomID, users []id.UserID) - OpenSyncingModal() SyncingModal - - NotifyMessage(room *rooms.Room, message Message, should pushrules.PushActionArrayShould) -} - -type RoomView interface { - MxRoom() *rooms.Room - - SetCompletions(completions []string) - SetTyping(users []id.UserID) - UpdateUserList() - - AddEvent(evt *muksevt.Event) Message - AddRedaction(evt *muksevt.Event) - AddEdit(evt *muksevt.Event) - AddReaction(evt *muksevt.Event, key string) - GetEvent(eventID id.EventID) Message - AddServiceMessage(message string) -} - -type Message interface { - ID() id.EventID - Time() time.Time - NotificationSenderName() string - NotificationContent() string - - SetIsHighlight(highlight bool) - SetID(id id.EventID) -} diff --git a/lib/ansimage/LICENSE b/lib/ansimage/LICENSE deleted file mode 100644 index a612ad9..0000000 --- a/lib/ansimage/LICENSE +++ /dev/null @@ -1,373 +0,0 @@ -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/lib/ansimage/ansimage.go b/lib/ansimage/ansimage.go deleted file mode 100644 index 1001fe9..0000000 --- a/lib/ansimage/ansimage.go +++ /dev/null @@ -1,297 +0,0 @@ -// ___ _____ ____ -// / _ \/ _/ |/_/ /____ ______ _ -// / ___// /_> =2") - - // ErrOutOfBounds happens when ANSI-pixel coordinates are out of ANSImage bounds. - ErrOutOfBounds = errors.New("ANSImage: out of bounds") -) - -// ANSIpixel represents a pixel of an ANSImage. -type ANSIpixel struct { - Brightness uint8 - R, G, B uint8 - upper bool - source *ANSImage -} - -// ANSImage represents an image encoded in ANSI escape codes. -type ANSImage struct { - h, w int - maxprocs int - bgR uint8 - bgG uint8 - bgB uint8 - pixmap [][]*ANSIpixel -} - -func (ai *ANSImage) Pixmap() [][]*ANSIpixel { - return ai.pixmap -} - -// Height gets total rows of ANSImage. -func (ai *ANSImage) Height() int { - return ai.h -} - -// Width gets total columns of ANSImage. -func (ai *ANSImage) Width() int { - return ai.w -} - -// SetMaxProcs sets the maximum number of parallel goroutines to render the ANSImage -// (user should manually sets `runtime.GOMAXPROCS(max)` before to this change takes effect). -func (ai *ANSImage) SetMaxProcs(max int) { - ai.maxprocs = max -} - -// GetMaxProcs gets the maximum number of parallels goroutines to render the ANSImage. -func (ai *ANSImage) GetMaxProcs() int { - return ai.maxprocs -} - -// SetAt sets ANSI-pixel color (RBG) and brightness in coordinates (y,x). -func (ai *ANSImage) SetAt(y, x int, r, g, b, brightness uint8) error { - if y >= 0 && y < ai.h && x >= 0 && x < ai.w { - ai.pixmap[y][x].R = r - ai.pixmap[y][x].G = g - ai.pixmap[y][x].B = b - ai.pixmap[y][x].Brightness = brightness - ai.pixmap[y][x].upper = y%2 == 0 - return nil - } - return ErrOutOfBounds -} - -// GetAt gets ANSI-pixel in coordinates (y,x). -func (ai *ANSImage) GetAt(y, x int) (*ANSIpixel, error) { - if y >= 0 && y < ai.h && x >= 0 && x < ai.w { - return &ANSIpixel{ - R: ai.pixmap[y][x].R, - G: ai.pixmap[y][x].G, - B: ai.pixmap[y][x].B, - Brightness: ai.pixmap[y][x].Brightness, - upper: ai.pixmap[y][x].upper, - source: ai.pixmap[y][x].source, - }, - nil - } - return nil, ErrOutOfBounds -} - -// Render returns the ANSI-compatible string form of ANSImage. -// (Nice info for ANSI True Colour - https://gist.github.com/XVilka/8346728) -func (ai *ANSImage) Render() []tstring.TString { - type renderData struct { - row int - render tstring.TString - } - - rows := make([]tstring.TString, ai.h/2) - for y := 0; y < ai.h; y += ai.maxprocs { - ch := make(chan renderData, ai.maxprocs) - for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n { - go func(row, y int) { - defer func() { - err := recover() - if err != nil { - debug.Print("Panic rendering ANSImage:", err) - ch <- renderData{row: row, render: tstring.NewColorTString("ERROR", tcell.ColorRed)} - } - }() - str := make(tstring.TString, ai.w) - for x := 0; x < ai.w; x++ { - topPixel := ai.pixmap[y][x] - topColor := tcell.NewRGBColor(int32(topPixel.R), int32(topPixel.G), int32(topPixel.B)) - - bottomPixel := ai.pixmap[y+1][x] - bottomColor := tcell.NewRGBColor(int32(bottomPixel.R), int32(bottomPixel.G), int32(bottomPixel.B)) - - str[x] = tstring.Cell{ - Char: '▄', - Style: tcell.StyleDefault.Background(topColor).Foreground(bottomColor), - } - } - ch <- renderData{row: row, render: str} - }(row, 2*row) - } - for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n { - data := <-ch - rows[data.row] = data.render - } - } - return rows -} - -// New creates a new empty ANSImage ready to draw on it. -func New(h, w int, bg color.Color) (*ANSImage, error) { - if h%2 != 0 { - return nil, ErrHeightNonMoT - } - - if h < 2 || w < 2 { - return nil, ErrInvalidBoundsMoT - } - - r, g, b, _ := bg.RGBA() - ansimage := &ANSImage{ - h: h, w: w, - maxprocs: 1, - bgR: uint8(r), - bgG: uint8(g), - bgB: uint8(b), - pixmap: nil, - } - - ansimage.pixmap = func() [][]*ANSIpixel { - v := make([][]*ANSIpixel, h) - for y := 0; y < h; y++ { - v[y] = make([]*ANSIpixel, w) - for x := 0; x < w; x++ { - v[y][x] = &ANSIpixel{ - R: 0, - G: 0, - B: 0, - Brightness: 0, - source: ansimage, - upper: y%2 == 0, - } - } - } - return v - }() - - return ansimage, nil -} - -// NewFromReader creates a new ANSImage from an io.Reader. -// Background color is used to fill when image has transparency or dithering mode is enabled -// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements). -func NewFromReader(reader io.Reader, bg color.Color) (*ANSImage, error) { - img, _, err := image.Decode(reader) - if err != nil { - return nil, err - } - - return createANSImage(img, bg) -} - -// NewScaledFromReader creates a new scaled ANSImage from an io.Reader. -// Background color is used to fill when image has transparency or dithering mode is enabled -// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements). -func NewScaledFromReader(reader io.Reader, y, x int, bg color.Color) (*ANSImage, error) { - img, _, err := image.Decode(reader) - if err != nil { - return nil, err - } - - img = imaging.Resize(img, x, y, imaging.Lanczos) - - return createANSImage(img, bg) -} - -// NewFromFile creates a new ANSImage from a file. -// Background color is used to fill when image has transparency or dithering mode is enabled -// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements). -func NewFromFile(name string, bg color.Color) (*ANSImage, error) { - reader, err := os.Open(name) - if err != nil { - return nil, err - } - defer reader.Close() - return NewFromReader(reader, bg) -} - -// NewScaledFromFile creates a new scaled ANSImage from a file. -// Background color is used to fill when image has transparency or dithering mode is enabled -// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements). -func NewScaledFromFile(name string, y, x int, bg color.Color) (*ANSImage, error) { - reader, err := os.Open(name) - if err != nil { - return nil, err - } - defer reader.Close() - return NewScaledFromReader(reader, y, x, bg) -} - -// createANSImage loads data from an image and returns an ANSImage. -// Background color is used to fill when image has transparency or dithering mode is enabled -// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements). -func createANSImage(img image.Image, bg color.Color) (*ANSImage, error) { - var rgbaOut *image.RGBA - bounds := img.Bounds() - - // do compositing only if background color has no transparency (thank you @disq for the idea!) - // (info - http://stackoverflow.com/questions/36595687/transparent-pixel-color-go-lang-image) - if _, _, _, a := bg.RGBA(); a >= 0xffff { - rgbaOut = image.NewRGBA(bounds) - draw.Draw(rgbaOut, bounds, image.NewUniform(bg), image.ZP, draw.Src) - draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Over) - } else { - if v, ok := img.(*image.RGBA); ok { - rgbaOut = v - } else { - rgbaOut = image.NewRGBA(bounds) - draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Src) - } - } - - yMin, xMin := bounds.Min.Y, bounds.Min.X - yMax, xMax := bounds.Max.Y, bounds.Max.X - - // always sets an even number of ANSIPixel rows... - yMax = yMax - yMax%2 // one for upper pixel and another for lower pixel --> without dithering - - ansimage, err := New(yMax, xMax, bg) - if err != nil { - return nil, err - } - - for y := yMin; y < yMax; y++ { - for x := xMin; x < xMax; x++ { - v := rgbaOut.RGBAAt(x, y) - if err := ansimage.SetAt(y, x, v.R, v.G, v.B, 0); err != nil { - return nil, err - } - } - } - - return ansimage, nil -} diff --git a/lib/ansimage/doc.go b/lib/ansimage/doc.go deleted file mode 100644 index dfc0c43..0000000 --- a/lib/ansimage/doc.go +++ /dev/null @@ -1,11 +0,0 @@ -// Package ansimage is a simplified version of the ansimage package -// in https://github.com/eliukblau/pixterm focused in rendering images -// to a tcell-based TUI app. -// -// ___ _____ ____ -// / _ \/ _/ |/_/ /____ ______ _ -// / ___// /_> . - -package filepicker - -import ( - "bytes" - "errors" - "os/exec" - "strings" -) - -var zenity string - -func init() { - zenity, _ = exec.LookPath("zenity") -} - -func IsSupported() bool { - return len(zenity) > 0 -} - -func Open() (string, error) { - cmd := exec.Command(zenity, "--file-selection") - var output bytes.Buffer - cmd.Stdout = &output - err := cmd.Run() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { - return "", nil - } - return "", err - } - return strings.TrimSpace(output.String()), nil -} diff --git a/lib/notification/doc.go b/lib/notification/doc.go deleted file mode 100644 index 05295c6..0000000 --- a/lib/notification/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package notification contains a simple cross-platform desktop notification sending function. -package notification diff --git a/lib/notification/notify_darwin.go b/lib/notification/notify_darwin.go deleted file mode 100644 index 317641c..0000000 --- a/lib/notification/notify_darwin.go +++ /dev/null @@ -1,65 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package notification - -import ( - "fmt" - "os/exec" -) - -var terminalNotifierAvailable = false - -func init() { - if err := exec.Command("which", "terminal-notifier").Run(); err != nil { - terminalNotifierAvailable = false - } - terminalNotifierAvailable = true -} - -const sendScript = `on run {notifText, notifTitle} - display notification notifText with title "gomuks" subtitle notifTitle -end run` - -func Send(title, text string, critical, sound bool) error { - if terminalNotifierAvailable { - args := []string{"-title", "gomuks", "-subtitle", title, "-message", text} - if critical { - args = append(args, "-timeout", "15") - } else { - args = append(args, "-timeout", "4") - } - if sound { - args = append(args, "-sound", "default") - } - //if len(iconPath) > 0 { - // args = append(args, "-appIcon", iconPath) - //} - return exec.Command("terminal-notifier", args...).Run() - } - cmd := exec.Command("osascript", "-", text, title) - if stdin, err := cmd.StdinPipe(); err != nil { - return fmt.Errorf("failed to get stdin pipe for osascript: %w", err) - } else if _, err = stdin.Write([]byte(sendScript)); err != nil { - return fmt.Errorf("failed to write notification script to osascript: %w", err) - } else if err = cmd.Run(); err != nil { - return fmt.Errorf("failed to run notification script: %w", err) - } else if !cmd.ProcessState.Success() { - return fmt.Errorf("notification script exited unsuccessfully") - } else { - return nil - } -} diff --git a/lib/notification/notify_windows.go b/lib/notification/notify_windows.go deleted file mode 100644 index 954788b..0000000 --- a/lib/notification/notify_windows.go +++ /dev/null @@ -1,39 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package notification - -import ( - "gopkg.in/toast.v1" -) - -func Send(title, text string, critical, sound bool) error { - notification := toast.Notification{ - AppID: "gomuks", - Title: title, - Message: text, - Audio: toast.Silent, - Duration: toast.Short, - // Icon: ..., - } - if sound { - notification.Audio = toast.IM - } - if critical { - notification.Duration = toast.Long - } - return notification.Push() -} diff --git a/lib/notification/notify_xdg.go b/lib/notification/notify_xdg.go deleted file mode 100644 index 5320ee8..0000000 --- a/lib/notification/notify_xdg.go +++ /dev/null @@ -1,84 +0,0 @@ -//go:build !windows && !darwin - -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package notification - -import ( - "os" - "os/exec" -) - -var notifySendPath string -var audioCommand string -var tryAudioCommands = []string{"ogg123", "paplay"} -var soundNormal = "/usr/share/sounds/freedesktop/stereo/message-new-instant.oga" -var soundCritical = "/usr/share/sounds/freedesktop/stereo/complete.oga" - -func getSoundPath(env, defaultPath string) string { - if path, ok := os.LookupEnv(env); ok { - // Sound file overriden by environment - return path - } else if _, err := os.Stat(defaultPath); os.IsNotExist(err) { - // Sound file doesn't exist, disable it - return "" - } else { - // Default sound file exists and wasn't overridden by environment - return defaultPath - } -} - -func init() { - var err error - - if notifySendPath, err = exec.LookPath("notify-send"); err != nil { - return - } - - for _, cmd := range tryAudioCommands { - if audioCommand, err = exec.LookPath(cmd); err == nil { - break - } - } - soundNormal = getSoundPath("GOMUKS_SOUND_NORMAL", soundNormal) - soundCritical = getSoundPath("GOMUKS_SOUND_CRITICAL", soundCritical) -} - -func Send(title, text string, critical, sound bool) error { - if len(notifySendPath) == 0 { - return nil - } - - args := []string{"-a", "gomuks"} - if !critical { - args = append(args, "-u", "low") - } - //if iconPath { - // args = append(args, "-i", iconPath) - //} - args = append(args, title, text) - if sound && len(audioCommand) > 0 && len(soundNormal) > 0 { - audioFile := soundNormal - if critical && len(soundCritical) > 0 { - audioFile = soundCritical - } - go func() { - _ = exec.Command(audioCommand, audioFile).Run() - }() - } - return exec.Command(notifySendPath, args...).Run() -} diff --git a/lib/open/doc.go b/lib/open/doc.go deleted file mode 100644 index 367ffb7..0000000 --- a/lib/open/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package open contains a simple cross-platform way to open files in the program the OS wants to use. -// -// Based on https://github.com/skratchdot/open-golang -package open diff --git a/lib/open/open.go b/lib/open/open.go deleted file mode 100644 index c128494..0000000 --- a/lib/open/open.go +++ /dev/null @@ -1,39 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package open - -import ( - "os/exec" - - "maunium.net/go/gomuks/debug" -) - -func Open(input string) error { - cmd := exec.Command(Command, append(Args, input)...) - err := cmd.Start() - if err != nil { - debug.Printf("Failed to start %s: %v", Command, err) - } else { - go func() { - waitErr := cmd.Wait() - if waitErr != nil { - debug.Printf("Failed to run %s: %v", Command, err) - } - }() - } - return err -} diff --git a/lib/open/open_darwin.go b/lib/open/open_darwin.go deleted file mode 100644 index 8947413..0000000 --- a/lib/open/open_darwin.go +++ /dev/null @@ -1,5 +0,0 @@ -package open - -const Command = "open" - -var Args []string diff --git a/lib/open/open_windows.go b/lib/open/open_windows.go deleted file mode 100644 index 1586e2b..0000000 --- a/lib/open/open_windows.go +++ /dev/null @@ -1,27 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package open - -import ( - "os" - "path/filepath" -) - -const FileProtocolHandler = "url.dll,FileProtocolHandler" - -var Command = filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "rundll32.exe") -var Args = []string{FileProtocolHandler} diff --git a/lib/open/open_xdg.go b/lib/open/open_xdg.go deleted file mode 100644 index bf0796e..0000000 --- a/lib/open/open_xdg.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows && !darwin - -package open - -const Command = "xdg-open" - -var Args []string diff --git a/lib/util/doc.go b/lib/util/doc.go deleted file mode 100644 index 8db1325..0000000 --- a/lib/util/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package util contains miscellaneous utilities -package util diff --git a/lib/util/lcp.go b/lib/util/lcp.go deleted file mode 100644 index 2e2e690..0000000 --- a/lib/util/lcp.go +++ /dev/null @@ -1,38 +0,0 @@ -// Licensed under the GNU Free Documentation License 1.2 -// https://www.gnu.org/licenses/old-licenses/fdl-1.2.en.html -// -// Source: https://rosettacode.org/wiki/Longest_common_prefix#Go - -package util - -func LongestCommonPrefix(list []string) string { - // Special cases first - switch len(list) { - case 0: - return "" - case 1: - return list[0] - } - - // LCP of min and max (lexigraphically) - // is the LCP of the whole set. - min, max := list[0], list[0] - for _, s := range list[1:] { - switch { - case s < min: - min = s - case s > max: - max = s - } - } - - for i := 0; i < len(min) && i < len(max); i++ { - if min[i] != max[i] { - return min[:i] - } - } - - // In the case where lengths are not equal but all bytes - // are equal, min is the answer ("foo" < "foobar"). - return min -} diff --git a/main.go b/main.go deleted file mode 100644 index 58401b5..0000000 --- a/main.go +++ /dev/null @@ -1,203 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "time" - - flag "maunium.net/go/mauflag" - - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/matrix" - "maunium.net/go/gomuks/ui" -) - -var MainUIProvider ifc.UIProvider = ui.NewGomuksUI - -var wantVersion = flag.MakeFull("v", "version", "Show the version of gomuks", "false").Bool() -var clearCache = flag.MakeFull("c", "clear-cache", "Clear the cache directory instead of starting", "false").Bool() -var clearData = flag.Make().LongKey("clear-all-data").Usage("Clear all data instead of starting").Default("false").Bool() -var skipVersionCheck = flag.MakeFull("s", "skip-version-check", "Skip the homeserver version checks at startup and login", "false").Bool() -var wantHelp, _ = flag.MakeHelpFlag() - -func main() { - flag.SetHelpTitles( - "gomuks - A terminal Matrix client written in Go.", - "gomuks [-vch] [--clear-all-data]", - ) - err := flag.Parse() - if err != nil { - fmt.Println(err) - os.Exit(1) - } else if *wantHelp { - flag.PrintHelp() - return - } else if *wantVersion { - fmt.Println(VersionString) - return - } - debugDir := os.Getenv("DEBUG_DIR") - if len(debugDir) > 0 { - debug.LogDirectory = debugDir - } - debugLevel := strings.ToLower(os.Getenv("DEBUG")) - if debugLevel == "1" || debugLevel == "t" || debugLevel == "true" { - debug.RecoverPrettyPanic = false - debug.DeadlockDetection = true - debug.WriteLogs = true - } - debug.Initialize() - defer debug.Recover() - - var configDir, dataDir, cacheDir, downloadDir string - - configDir, err = UserConfigDir() - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Failed to get config directory:", err) - os.Exit(3) - } - dataDir, err = UserDataDir() - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Failed to get data directory:", err) - os.Exit(3) - } - cacheDir, err = UserCacheDir() - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Failed to get cache directory:", err) - os.Exit(3) - } - downloadDir, err = UserDownloadDir() - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, "Failed to get download directory:", err) - os.Exit(3) - } - - debug.Print("Config directory:", configDir) - debug.Print("Data directory:", dataDir) - debug.Print("Cache directory:", cacheDir) - debug.Print("Download directory:", downloadDir) - - matrix.SkipVersionCheck = *skipVersionCheck - gmx := NewGomuks(MainUIProvider, configDir, dataDir, cacheDir, downloadDir) - - if *clearCache { - debug.Print("Clearing cache as requested by CLI flag") - gmx.config.Clear() - fmt.Printf("Cleared cache at %s\n", gmx.config.CacheDir) - return - } else if *clearData { - debug.Print("Clearing all data as requested by CLI flag") - gmx.config.Clear() - gmx.config.ClearData() - _ = os.RemoveAll(gmx.config.Dir) - fmt.Printf("Cleared cache at %s, data at %s and config at %s\n", gmx.config.CacheDir, gmx.config.DataDir, gmx.config.Dir) - return - } - - gmx.Start() - - // We use os.Exit() everywhere, so exiting by returning from Start() shouldn't happen. - time.Sleep(5 * time.Second) - fmt.Println("Unexpected exit by return from gmx.Start().") - os.Exit(2) -} - -func getRootDir(subdir string) string { - rootDir := os.Getenv("GOMUKS_ROOT") - if rootDir == "" { - return "" - } - return filepath.Join(rootDir, subdir) -} - -func UserCacheDir() (dir string, err error) { - dir = os.Getenv("GOMUKS_CACHE_HOME") - if dir == "" { - dir = getRootDir("cache") - } - if dir == "" { - dir, err = os.UserCacheDir() - dir = filepath.Join(dir, "gomuks") - } - return -} - -func UserDataDir() (dir string, err error) { - dir = os.Getenv("GOMUKS_DATA_HOME") - if dir != "" { - return - } - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { - return UserConfigDir() - } - dir = getRootDir("data") - if dir == "" { - dir = os.Getenv("XDG_DATA_HOME") - } - if dir == "" { - dir = os.Getenv("HOME") - if dir == "" { - return "", errors.New("neither $XDG_DATA_HOME nor $HOME are defined") - } - dir = filepath.Join(dir, ".local", "share") - } - dir = filepath.Join(dir, "gomuks") - return -} - -func getXDGUserDir(name string) (dir string, err error) { - cmd := exec.Command("xdg-user-dir", name) - var out strings.Builder - cmd.Stdout = &out - err = cmd.Run() - dir = strings.TrimSpace(out.String()) - return -} - -func UserDownloadDir() (dir string, err error) { - dir = os.Getenv("GOMUKS_DOWNLOAD_HOME") - if dir != "" { - return - } - dir, _ = getXDGUserDir("DOWNLOAD") - if dir != "" { - return - } - dir, err = os.UserHomeDir() - dir = filepath.Join(dir, "Downloads") - return -} - -func UserConfigDir() (dir string, err error) { - dir = os.Getenv("GOMUKS_CONFIG_HOME") - if dir == "" { - dir = getRootDir("config") - } - if dir == "" { - dir, err = os.UserConfigDir() - dir = filepath.Join(dir, "gomuks") - } - return -} diff --git a/matrix/crypto.go b/matrix/crypto.go deleted file mode 100644 index 87406e0..0000000 --- a/matrix/crypto.go +++ /dev/null @@ -1,100 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build cgo - -package matrix - -import ( - "database/sql" - "fmt" - "os" - "path/filepath" - - _ "github.com/mattn/go-sqlite3" - - "maunium.net/go/mautrix/crypto" - - "maunium.net/go/gomuks/debug" -) - -type cryptoLogger struct { - prefix string -} - -func (c cryptoLogger) Error(message string, args ...interface{}) { - debug.Printf(fmt.Sprintf("[%s/Error] %s", c.prefix, message), args...) -} - -func (c cryptoLogger) Warn(message string, args ...interface{}) { - debug.Printf(fmt.Sprintf("[%s/Warn] %s", c.prefix, message), args...) -} - -func (c cryptoLogger) Debug(message string, args ...interface{}) { - debug.Printf(fmt.Sprintf("[%s/Debug] %s", c.prefix, message), args...) -} - -func (c cryptoLogger) Trace(message string, args ...interface{}) { - debug.Printf(fmt.Sprintf("[%s/Trace] %s", c.prefix, message), args...) -} - -func isBadEncryptError(err error) bool { - return err != crypto.SessionExpired && err != crypto.SessionNotShared && err != crypto.NoGroupSession -} - -func (c *Container) initCrypto() error { - var cryptoStore crypto.Store - var err error - legacyStorePath := filepath.Join(c.config.DataDir, "crypto.gob") - if _, err = os.Stat(legacyStorePath); err == nil { - debug.Printf("Using legacy crypto store as %s exists", legacyStorePath) - cryptoStore, err = crypto.NewGobStore(legacyStorePath) - if err != nil { - return fmt.Errorf("file open: %w", err) - } - } else { - debug.Printf("Using SQLite crypto store") - newStorePath := filepath.Join(c.config.DataDir, "crypto.db") - db, err := sql.Open("sqlite3", newStorePath) - if err != nil { - return fmt.Errorf("sql open: %w", err) - } - accID := fmt.Sprintf("%s/%s", c.config.UserID.String(), c.config.DeviceID) - sqlStore := crypto.NewSQLCryptoStore(db, "sqlite3", accID, c.config.DeviceID, []byte("fi.mau.gomuks"), cryptoLogger{"Crypto/DB"}) - err = sqlStore.CreateTables() - if err != nil { - return fmt.Errorf("create table: %w", err) - } - cryptoStore = sqlStore - } - crypt := crypto.NewOlmMachine(c.client, cryptoLogger{"Crypto"}, cryptoStore, c.config.Rooms) - crypt.AllowUnverifiedDevices = !c.config.SendToVerifiedOnly - c.crypto = crypt - err = c.crypto.Load() - if err != nil { - return fmt.Errorf("failed to create olm machine: %w", err) - } - return nil -} - -func (c *Container) cryptoOnLogin() { - sqlStore, ok := c.crypto.(*crypto.OlmMachine).CryptoStore.(*crypto.SQLCryptoStore) - if !ok { - return - } - sqlStore.DeviceID = c.config.DeviceID - sqlStore.AccountID = fmt.Sprintf("%s/%s", c.config.UserID.String(), c.config.DeviceID) -} diff --git a/matrix/doc.go b/matrix/doc.go deleted file mode 100644 index f789895..0000000 --- a/matrix/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package matrix contains wrappers for mautrix for use by the UI of gomuks. -package matrix diff --git a/matrix/history.go b/matrix/history.go deleted file mode 100644 index b1e5842..0000000 --- a/matrix/history.go +++ /dev/null @@ -1,316 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package matrix - -import ( - "bytes" - "compress/gzip" - "encoding/binary" - "encoding/gob" - "errors" - - sync "github.com/sasha-s/go-deadlock" - bolt "go.etcd.io/bbolt" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/matrix/muksevt" - "maunium.net/go/gomuks/matrix/rooms" -) - -type HistoryManager struct { - sync.Mutex - - db *bolt.DB - - historyEndPtr map[*rooms.Room]uint64 -} - -var bucketRoomStreams = []byte("room_streams") -var bucketRoomEventIDs = []byte("room_event_ids") -var bucketStreamPointers = []byte("room_stream_pointers") - -const halfUint64 = ^uint64(0) >> 1 - -func NewHistoryManager(dbPath string) (*HistoryManager, error) { - hm := &HistoryManager{ - historyEndPtr: make(map[*rooms.Room]uint64), - } - db, err := bolt.Open(dbPath, 0600, &bolt.Options{ - Timeout: 1, - NoGrowSync: false, - FreelistType: bolt.FreelistArrayType, - }) - if err != nil { - return nil, err - } - err = db.Update(func(tx *bolt.Tx) error { - _, err = tx.CreateBucketIfNotExists(bucketRoomStreams) - if err != nil { - return err - } - _, err = tx.CreateBucketIfNotExists(bucketRoomEventIDs) - if err != nil { - return err - } - _, err = tx.CreateBucketIfNotExists(bucketStreamPointers) - if err != nil { - return err - } - return nil - }) - if err != nil { - return nil, err - } - hm.db = db - return hm, nil -} - -func (hm *HistoryManager) Close() error { - return hm.db.Close() -} - -var ( - EventNotFoundError = errors.New("event not found") - RoomNotFoundError = errors.New("room not found") -) - -func (hm *HistoryManager) getStreamIndex(tx *bolt.Tx, roomID []byte, eventID []byte) (*bolt.Bucket, []byte, error) { - eventIDs := tx.Bucket(bucketRoomEventIDs).Bucket(roomID) - if eventIDs == nil { - return nil, nil, RoomNotFoundError - } - index := eventIDs.Get(eventID) - if index == nil { - return nil, nil, EventNotFoundError - } - stream := tx.Bucket(bucketRoomStreams).Bucket(roomID) - return stream, index, nil -} - -func (hm *HistoryManager) getEvent(tx *bolt.Tx, stream *bolt.Bucket, index []byte) (*muksevt.Event, error) { - eventData := stream.Get(index) - if eventData == nil || len(eventData) == 0 { - return nil, EventNotFoundError - } - return unmarshalEvent(eventData) -} - -func (hm *HistoryManager) Get(room *rooms.Room, eventID id.EventID) (evt *muksevt.Event, err error) { - err = hm.db.View(func(tx *bolt.Tx) error { - if stream, index, err := hm.getStreamIndex(tx, []byte(room.ID), []byte(eventID)); err != nil { - return err - } else if evt, err = hm.getEvent(tx, stream, index); err != nil { - return err - } - return nil - }) - return -} - -func (hm *HistoryManager) Update(room *rooms.Room, eventID id.EventID, update func(evt *muksevt.Event) error) error { - return hm.db.Update(func(tx *bolt.Tx) error { - if stream, index, err := hm.getStreamIndex(tx, []byte(room.ID), []byte(eventID)); err != nil { - return err - } else if evt, err := hm.getEvent(tx, stream, index); err != nil { - return err - } else if err = update(evt); err != nil { - return err - } else if eventData, err := marshalEvent(evt); err != nil { - return err - } else if err := stream.Put(index, eventData); err != nil { - return err - } - return nil - }) -} - -func (hm *HistoryManager) Append(room *rooms.Room, events []*event.Event) ([]*muksevt.Event, error) { - muksEvts, _, err := hm.store(room, events, true) - return muksEvts, err -} - -func (hm *HistoryManager) Prepend(room *rooms.Room, events []*event.Event) ([]*muksevt.Event, uint64, error) { - return hm.store(room, events, false) -} - -func (hm *HistoryManager) store(room *rooms.Room, events []*event.Event, append bool) (newEvents []*muksevt.Event, newPtrStart uint64, err error) { - hm.Lock() - defer hm.Unlock() - newEvents = make([]*muksevt.Event, len(events)) - err = hm.db.Update(func(tx *bolt.Tx) error { - streamPointers := tx.Bucket(bucketStreamPointers) - rid := []byte(room.ID) - stream, err := tx.Bucket(bucketRoomStreams).CreateBucketIfNotExists(rid) - if err != nil { - return err - } - eventIDs, err := tx.Bucket(bucketRoomEventIDs).CreateBucketIfNotExists(rid) - if err != nil { - return err - } - if stream.Sequence() < halfUint64 { - // The sequence counter (i.e. the future) the part after 2^63, i.e. the second half of uint64 - // We set it to -1 because NextSequence will increment it by one. - err = stream.SetSequence(halfUint64 - 1) - if err != nil { - return err - } - } - if append { - ptrStart, err := stream.NextSequence() - if err != nil { - return err - } - for i, evt := range events { - newEvents[i] = muksevt.Wrap(evt) - if err := put(stream, eventIDs, newEvents[i], ptrStart+uint64(i)); err != nil { - return err - } - } - err = stream.SetSequence(ptrStart + uint64(len(events)) - 1) - if err != nil { - return err - } - } else { - ptrStart, ok := hm.historyEndPtr[room] - if !ok { - ptrStartRaw := streamPointers.Get(rid) - if ptrStartRaw != nil { - ptrStart = btoi(ptrStartRaw) - } else { - ptrStart = halfUint64 - 1 - } - } - eventCount := uint64(len(events)) - for i, evt := range events { - newEvents[i] = muksevt.Wrap(evt) - if err := put(stream, eventIDs, newEvents[i], -ptrStart-uint64(i)); err != nil { - return err - } - } - hm.historyEndPtr[room] = ptrStart + eventCount - // TODO this is not the correct value for newPtrStart, figure out what the f*ck is going on here - newPtrStart = ptrStart + eventCount - err := streamPointers.Put(rid, itob(ptrStart+eventCount)) - if err != nil { - return err - } - } - - return nil - }) - return -} - -func (hm *HistoryManager) Load(room *rooms.Room, num int, ptrStart uint64) (events []*muksevt.Event, newPtrStart uint64, err error) { - hm.Lock() - defer hm.Unlock() - err = hm.db.View(func(tx *bolt.Tx) error { - stream := tx.Bucket(bucketRoomStreams).Bucket([]byte(room.ID)) - if stream == nil { - return nil - } - if ptrStart == 0 { - ptrStart = stream.Sequence() + 1 - } - c := stream.Cursor() - k, v := c.Seek(itob(ptrStart - uint64(num))) - ptrStartFound := btoi(k) - if k == nil || ptrStartFound >= ptrStart { - return nil - } - newPtrStart = ptrStartFound - for ; k != nil && btoi(k) < ptrStart; k, v = c.Next() { - evt, parseError := unmarshalEvent(v) - if parseError != nil { - return parseError - } - events = append(events, evt) - } - return nil - }) - // Reverse array because we read/append the history in reverse order. - i := 0 - j := len(events) - 1 - for i < j { - events[i], events[j] = events[j], events[i] - i++ - j-- - } - return -} - -func itob(v uint64) []byte { - b := make([]byte, 8) - binary.BigEndian.PutUint64(b, v) - return b -} - -func btoi(b []byte) uint64 { - return binary.BigEndian.Uint64(b) -} - -func stripRaw(evt *muksevt.Event) { - evtCopy := *evt.Event - evtCopy.Content = event.Content{ - Parsed: evt.Content.Parsed, - } - evt.Event = &evtCopy -} - -func marshalEvent(evt *muksevt.Event) ([]byte, error) { - stripRaw(evt) - var buf bytes.Buffer - enc, _ := gzip.NewWriterLevel(&buf, gzip.BestSpeed) - if err := gob.NewEncoder(enc).Encode(evt); err != nil { - _ = enc.Close() - return nil, err - } else if err := enc.Close(); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -func unmarshalEvent(data []byte) (*muksevt.Event, error) { - evt := &muksevt.Event{} - if cmpReader, err := gzip.NewReader(bytes.NewReader(data)); err != nil { - return nil, err - } else if err := gob.NewDecoder(cmpReader).Decode(evt); err != nil { - _ = cmpReader.Close() - return nil, err - } else if err := cmpReader.Close(); err != nil { - return nil, err - } - return evt, nil -} - -func put(streams, eventIDs *bolt.Bucket, evt *muksevt.Event, key uint64) error { - data, err := marshalEvent(evt) - if err != nil { - return err - } - keyBytes := itob(key) - if err = streams.Put(keyBytes, data); err != nil { - return err - } - if err = eventIDs.Put([]byte(evt.ID), keyBytes); err != nil { - return err - } - return nil -} diff --git a/matrix/matrix.go b/matrix/matrix.go deleted file mode 100644 index 8ecf145..0000000 --- a/matrix/matrix.go +++ /dev/null @@ -1,1418 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package matrix - -import ( - "context" - "crypto/tls" - "encoding/gob" - "encoding/json" - "errors" - "fmt" - "io" - "io/ioutil" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path" - "path/filepath" - "reflect" - "runtime" - dbg "runtime/debug" - "strconv" - "strings" - "time" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/lib/open" - "maunium.net/go/gomuks/matrix/muksevt" - "maunium.net/go/gomuks/matrix/rooms" -) - -// Container is a wrapper for a mautrix Client and some other stuff. -// -// It is used for all Matrix calls from the UI and Matrix event handlers. -type Container struct { - client *mautrix.Client - crypto ifc.Crypto - syncer *GomuksSyncer - gmx ifc.Gomuks - ui ifc.GomuksUI - config *config.Config - history *HistoryManager - running bool - stop chan bool - - mediaProxyURL string - - typing int64 -} - -// NewContainer creates a new Container for the given Gomuks instance. -func NewContainer(gmx ifc.Gomuks) *Container { - c := &Container{ - config: gmx.Config(), - ui: gmx.UI(), - gmx: gmx, - } - - return c -} - -// Client returns the underlying mautrix Client. -func (c *Container) Client() *mautrix.Client { - return c.client -} - -type mxLogger struct{} - -func (log mxLogger) Debugfln(message string, args ...interface{}) { - debug.Printf("[Matrix] "+message, args...) -} - -func (c *Container) Crypto() ifc.Crypto { - return c.crypto -} - -var ( - ErrNoHomeserver = errors.New("no homeserver entered") - ErrServerOutdated = errors.New("homeserver is outdated") -) - -var MinSpecVersion = mautrix.SpecV11 -var SkipVersionCheck = false - -// InitClient initializes the mautrix client and connects to the homeserver specified in the config. -func (c *Container) InitClient(isStartup bool) error { - if len(c.config.HS) == 0 { - if isStartup { - return nil - } - return ErrNoHomeserver - } - - if c.client != nil { - c.Stop() - c.client = nil - c.crypto = nil - } - - var mxid id.UserID - var accessToken string - if len(c.config.AccessToken) > 0 { - accessToken = c.config.AccessToken - mxid = c.config.UserID - } - - var err error - c.client, err = mautrix.NewClient(c.config.HS, mxid, accessToken) - if err != nil { - return fmt.Errorf("failed to create mautrix client: %w", err) - } - c.client.UserAgent = fmt.Sprintf("gomuks/%s %s", c.gmx.Version(), mautrix.DefaultUserAgent) - c.client.Logger = mxLogger{} - c.client.DeviceID = c.config.DeviceID - - err = c.initCrypto() - if err != nil { - return fmt.Errorf("failed to initialize crypto: %w", err) - } - - if c.history == nil { - c.history, err = NewHistoryManager(c.config.HistoryPath) - if err != nil { - return fmt.Errorf("failed to initialize history: %w", err) - } - } - - allowInsecure := len(os.Getenv("GOMUKS_ALLOW_INSECURE_CONNECTIONS")) > 0 - if allowInsecure { - c.client.Client = &http.Client{ - Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, - } - } - - if !SkipVersionCheck && (!isStartup || len(c.client.AccessToken) > 0) { - debug.Printf("Checking versions that %s supports", c.client.HomeserverURL) - resp, err := c.client.Versions() - if err != nil { - debug.Print("Error checking supported versions:", err) - return fmt.Errorf("failed to check server versions: %w", err) - } else if !resp.ContainsGreaterOrEqual(MinSpecVersion) { - debug.Print("Server doesn't support modern spec versions") - bestVersionStr := "nothing" - bestVersion := mautrix.MustParseSpecVersion("r0.0.0") - for _, ver := range resp.Versions { - if ver.GreaterThan(bestVersion) { - bestVersion = ver - bestVersionStr = ver.String() - } - } - return fmt.Errorf("%w (it only supports %s, while gomuks requires %s)", ErrServerOutdated, bestVersionStr, MinSpecVersion.String()) - } else { - debug.Print("Server supports modern spec versions") - } - } - - if c.mediaProxyURL == "" { - server := httptest.NewServer(http.HandlerFunc(c.doMediaProxy)) - c.mediaProxyURL = server.URL - debug.Print("Started media proxy server at", c.mediaProxyURL) - } - - c.stop = make(chan bool, 1) - - if len(accessToken) > 0 { - go c.Start() - } - return nil -} - -func (c *Container) doMediaProxy(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - if len(parts) != 2 { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte("Invalid path\n")) - return - } - uri := id.ContentURI{ - Homeserver: parts[0], - FileID: parts[1], - } - key := r.URL.Query().Get("k") - iv := r.URL.Query().Get("iv") - hash := r.URL.Query().Get("hash") - var file *attachment.EncryptedFile - if key != "" && iv != "" && hash != "" { - file = &attachment.EncryptedFile{ - Key: attachment.JSONWebKey{ - Key: key, - Algorithm: "A256CTR", - Extractable: true, - KeyType: "oct", - KeyOps: []string{"encrypt", "decrypt"}, - }, - InitVector: iv, - Hashes: attachment.EncryptedFileHashes{ - SHA256: hash, - }, - Version: "v2", - } - } - data, err := c.Download(uri, file) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(fmt.Sprintf("Failed to download media: %v\n", err))) - return - } - mime := http.DetectContentType(data) - w.Header().Add("Content-Length", strconv.Itoa(len(data))) - w.Header().Add("Content-Type", mime) - switch mime { - case "text/css", "text/plain", "text/csv", - "application/json", "application/ld+json", - "image/jpeg", "image/gif", "image/png", "image/apng", "image/webp", "image/avif", - "video/mp4", "video/webm", "video/ogg", "video/quicktime", - "audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", - "audio/wav", "audio/x-wav", "audio/x-pn-wav", "audio/flac", "audio/x-flac": - w.Header().Add("Content-Disposition", "inline") - default: - w.Header().Add("Content-Disposition", "attachment") - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write(data) -} - -// Initialized returns whether or not the mautrix client is initialized (see InitClient()) -func (c *Container) Initialized() bool { - return c.client != nil -} - -func (c *Container) PasswordLogin(user, password string) error { - resp, err := c.client.Login(&mautrix.ReqLogin{ - Type: "m.login.password", - Identifier: mautrix.UserIdentifier{ - Type: "m.id.user", - User: user, - }, - Password: password, - InitialDeviceDisplayName: "gomuks", - - StoreCredentials: true, - StoreHomeserverURL: true, - }) - if err != nil { - return err - } - c.finishLogin(resp) - return nil -} - -func (c *Container) finishLogin(resp *mautrix.RespLogin) { - c.config.UserID = resp.UserID - c.config.DeviceID = resp.DeviceID - c.config.AccessToken = resp.AccessToken - if resp.WellKnown != nil && len(resp.WellKnown.Homeserver.BaseURL) > 0 { - c.config.HS = resp.WellKnown.Homeserver.BaseURL - } - c.config.Save() - - go c.Start() -} - -func respondHTML(w http.ResponseWriter, status int, message string) { - w.Header().Add("Content-Type", "text/html") - w.WriteHeader(status) - _, _ = w.Write([]byte(fmt.Sprintf(` - - - gomuks single-sign on - - - -
-

%s

-
- -`, message))) -} - -func (c *Container) SingleSignOn() error { - loginURL := c.client.BuildURLWithQuery(mautrix.ClientURLPath{"v3", "login", "sso", "redirect"}, map[string]string{ - "redirectUrl": "http://localhost:29325", - }) - err := open.Open(loginURL) - if err != nil { - return err - } - errChan := make(chan error, 1) - server := &http.Server{Addr: ":29325"} - server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - loginToken := r.URL.Query().Get("loginToken") - if len(loginToken) == 0 { - respondHTML(w, http.StatusBadRequest, "Missing loginToken parameter") - return - } - resp, err := c.client.Login(&mautrix.ReqLogin{ - Type: "m.login.token", - Token: loginToken, - InitialDeviceDisplayName: "gomuks", - - StoreCredentials: true, - StoreHomeserverURL: true, - }) - if err != nil { - respondHTML(w, http.StatusForbidden, err.Error()) - errChan <- err - return - } - respondHTML(w, http.StatusOK, fmt.Sprintf("Successfully logged in as %s", resp.UserID)) - c.finishLogin(resp) - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - err = server.Shutdown(ctx) - if err != nil { - debug.Printf("Failed to shut down SSO server: %v\n", err) - } - errChan <- err - }() - }) - err = server.ListenAndServe() - if err != nil { - return err - } - err = <-errChan - return err -} - -// Login sends a password login request with the given username and password. -func (c *Container) Login(user, password string) error { - resp, err := c.client.GetLoginFlows() - if err != nil { - return err - } - ssoSkippedBecausePassword := false - for _, flow := range resp.Flows { - if flow.Type == "m.login.password" { - return c.PasswordLogin(user, password) - } else if flow.Type == "m.login.sso" { - if len(password) == 0 { - return c.SingleSignOn() - } else { - ssoSkippedBecausePassword = true - } - } - } - if ssoSkippedBecausePassword { - return fmt.Errorf("password login is not supported\nleave the password field blank to use SSO") - } - return fmt.Errorf("no supported login flows") -} - -// Logout revokes the access token, stops the syncer and calls the OnLogout() method of the UI. -func (c *Container) Logout() { - c.client.Logout() - c.Stop() - c.config.DeleteSession() - c.client = nil - c.crypto = nil - c.ui.OnLogout() -} - -// Stop stops the Matrix syncer. -func (c *Container) Stop() { - if c.running { - debug.Print("Stopping Matrix container...") - select { - case c.stop <- true: - default: - } - c.client.StopSync() - debug.Print("Closing history manager...") - err := c.history.Close() - if err != nil { - debug.Print("Error closing history manager:", err) - } - c.history = nil - if c.crypto != nil { - debug.Print("Flushing crypto store") - err = c.crypto.FlushStore() - if err != nil { - debug.Print("Error flushing crypto store:", err) - } - } - } -} - -// UpdatePushRules fetches the push notification rules from the server and stores them in the current Session object. -func (c *Container) UpdatePushRules() { - debug.Print("Updating push rules...") - resp, err := c.client.GetPushRules() - if err != nil { - debug.Print("Failed to fetch push rules:", err) - c.config.PushRules = &pushrules.PushRuleset{} - } else { - c.config.PushRules = resp - } - c.config.SavePushRules() -} - -// PushRules returns the push notification rules. If no push rules are cached, UpdatePushRules() will be called first. -func (c *Container) PushRules() *pushrules.PushRuleset { - if c.config.PushRules == nil { - c.UpdatePushRules() - } - return c.config.PushRules -} - -var AccountDataGomuksPreferences = event.Type{ - Type: "net.maunium.gomuks.preferences", - Class: event.AccountDataEventType, -} - -func init() { - event.TypeMap[AccountDataGomuksPreferences] = reflect.TypeOf(config.UserPreferences{}) - gob.Register(&config.UserPreferences{}) -} - -type StubSyncingModal struct{} - -func (s StubSyncingModal) SetIndeterminate() {} -func (s StubSyncingModal) SetMessage(s2 string) {} -func (s StubSyncingModal) SetSteps(i int) {} -func (s StubSyncingModal) Step() {} -func (s StubSyncingModal) Close() {} - -// OnLogin initializes the syncer and updates the room list. -func (c *Container) OnLogin() { - c.cryptoOnLogin() - c.ui.OnLogin() - - c.client.Store = c.config - - debug.Print("Initializing syncer") - c.syncer = NewGomuksSyncer(c.config.Rooms) - if c.crypto != nil { - c.syncer.OnSync(c.crypto.ProcessSyncResponse) - c.syncer.OnEventType(event.StateMember, func(source mautrix.EventSource, evt *event.Event) { - // Don't spam the crypto module with member events of an initial sync - // TODO invalidate all group sessions when clearing cache? - if c.config.AuthCache.InitialSyncDone { - c.crypto.HandleMemberEvent(evt) - } - }) - c.syncer.OnEventType(event.EventEncrypted, c.HandleEncrypted) - } else { - c.syncer.OnEventType(event.EventEncrypted, c.HandleEncryptedUnsupported) - } - c.syncer.OnEventType(event.EventMessage, c.HandleMessage) - c.syncer.OnEventType(event.EventSticker, c.HandleMessage) - c.syncer.OnEventType(event.EventReaction, c.HandleMessage) - c.syncer.OnEventType(event.EventRedaction, c.HandleRedaction) - c.syncer.OnEventType(event.StateAliases, c.HandleMessage) - c.syncer.OnEventType(event.StateCanonicalAlias, c.HandleMessage) - c.syncer.OnEventType(event.StateTopic, c.HandleMessage) - c.syncer.OnEventType(event.StateRoomName, c.HandleMessage) - c.syncer.OnEventType(event.StateMember, c.HandleMembership) - c.syncer.OnEventType(event.EphemeralEventReceipt, c.HandleReadReceipt) - c.syncer.OnEventType(event.EphemeralEventTyping, c.HandleTyping) - c.syncer.OnEventType(event.AccountDataDirectChats, c.HandleDirectChatInfo) - c.syncer.OnEventType(event.AccountDataPushRules, c.HandlePushRules) - c.syncer.OnEventType(event.AccountDataRoomTags, c.HandleTag) - c.syncer.OnEventType(AccountDataGomuksPreferences, c.HandlePreferences) - if len(c.config.AuthCache.NextBatch) == 0 { - c.syncer.Progress = c.ui.MainView().OpenSyncingModal() - c.syncer.Progress.SetMessage("Waiting for /sync response from server") - c.syncer.Progress.SetIndeterminate() - c.syncer.FirstDoneCallback = func() { - c.syncer.Progress.Close() - c.syncer.Progress = StubSyncingModal{} - c.syncer.FirstDoneCallback = nil - } - } - c.syncer.InitDoneCallback = func() { - debug.Print("Initial sync done") - c.config.AuthCache.InitialSyncDone = true - debug.Print("Updating title caches") - for _, room := range c.config.Rooms.Map { - room.GetTitle() - } - debug.Print("Cleaning cached rooms from memory") - c.config.Rooms.ForceClean() - debug.Print("Saving all data") - c.config.SaveAll() - debug.Print("Adding rooms to UI") - c.ui.MainView().SetRooms(c.config.Rooms) - c.ui.Render() - // The initial sync can be a bit heavy, so we force run the GC here - // after cleaning up rooms from memory above. - debug.Print("Running GC") - runtime.GC() - dbg.FreeOSMemory() - } - c.client.Syncer = c.syncer - - debug.Print("Setting existing rooms") - c.ui.MainView().SetRooms(c.config.Rooms) - - debug.Print("OnLogin() done.") -} - -// Start moves the UI to the main view, calls OnLogin() and runs the syncer forever until stopped with Stop() -func (c *Container) Start() { - defer debug.Recover() - - c.OnLogin() - - if c.client == nil { - return - } - - debug.Print("Starting sync...") - c.running = true - c.client.StreamSyncMinAge = 30 * time.Minute - for { - select { - case <-c.stop: - debug.Print("Stopping sync...") - c.running = false - return - default: - if err := c.client.Sync(); err != nil { - if errors.Is(err, mautrix.MUnknownToken) { - debug.Print("Sync() errored with ", err, " -> logging out") - // TODO support soft logout - c.Logout() - } else { - debug.Print("Sync() errored", err) - } - } else { - debug.Print("Sync() returned without error") - } - } - } -} - -func (c *Container) HandlePreferences(source mautrix.EventSource, evt *event.Event) { - if source&mautrix.EventSourceAccountData == 0 { - return - } - orig := c.config.Preferences - err := json.Unmarshal(evt.Content.VeryRaw, &c.config.Preferences) - if err != nil { - debug.Print("Failed to parse updated preferences:", err) - return - } - debug.Printf("Updated preferences: %#v -> %#v", orig, c.config.Preferences) - if c.config.AuthCache.InitialSyncDone { - c.ui.HandleNewPreferences() - } -} - -func (c *Container) Preferences() *config.UserPreferences { - return &c.config.Preferences -} - -func (c *Container) SendPreferencesToMatrix() { - defer debug.Recover() - debug.Printf("Sending updated preferences: %#v", c.config.Preferences) - err := c.client.SetAccountData(AccountDataGomuksPreferences.Type, &c.config.Preferences) - if err != nil { - debug.Print("Failed to update preferences:", err) - } -} - -func (c *Container) HandleRedaction(source mautrix.EventSource, evt *event.Event) { - room := c.GetOrCreateRoom(evt.RoomID) - var redactedEvt *muksevt.Event - err := c.history.Update(room, evt.Redacts, func(redacted *muksevt.Event) error { - redacted.Unsigned.RedactedBecause = evt - redactedEvt = redacted - return nil - }) - if err != nil { - debug.Print("Failed to mark", evt.Redacts, "as redacted:", err) - return - } else if !c.config.AuthCache.InitialSyncDone || !room.Loaded() { - return - } - - roomView := c.ui.MainView().GetRoom(evt.RoomID) - if roomView == nil { - debug.Printf("Failed to handle event %v: No room view found.", evt) - return - } - - roomView.AddRedaction(redactedEvt) - if c.syncer.FirstSyncDone { - c.ui.Render() - } -} - -var ErrCantEditOthersMessage = errors.New("can't edit message sent by someone else") - -func (c *Container) HandleEdit(room *rooms.Room, editsID id.EventID, editEvent *muksevt.Event) { - var origEvt *muksevt.Event - err := c.history.Update(room, editsID, func(evt *muksevt.Event) error { - if editEvent.Sender != evt.Sender { - return ErrCantEditOthersMessage - } - evt.Gomuks.Edits = append(evt.Gomuks.Edits, editEvent) - origEvt = evt - return nil - }) - if err == ErrCantEditOthersMessage { - debug.Printf("Ignoring edit %s of %s by %s in %s: original event was sent by someone else", editEvent.ID, editsID, editEvent.Sender, editEvent.RoomID) - return - } else if err != nil { - debug.Print("Failed to store edit in history db:", err) - return - } else if !c.config.AuthCache.InitialSyncDone || !room.Loaded() { - return - } - - roomView := c.ui.MainView().GetRoom(editEvent.RoomID) - if roomView == nil { - debug.Printf("Failed to handle edit event %v: No room view found.", editEvent) - return - } - - roomView.AddEdit(origEvt) - if c.syncer.FirstSyncDone { - c.ui.Render() - } -} - -func (c *Container) HandleReaction(room *rooms.Room, reactsTo id.EventID, reactEvent *muksevt.Event) { - rel := reactEvent.Content.AsReaction().RelatesTo - var origEvt *muksevt.Event - err := c.history.Update(room, reactsTo, func(evt *muksevt.Event) error { - if evt.Unsigned.Relations.Annotations.Map == nil { - evt.Unsigned.Relations.Annotations.Map = make(map[string]int) - } - val, _ := evt.Unsigned.Relations.Annotations.Map[rel.Key] - evt.Unsigned.Relations.Annotations.Map[rel.Key] = val + 1 - origEvt = evt - return nil - }) - if err != nil { - debug.Print("Failed to store reaction in history db:", err) - return - } else if !c.config.AuthCache.InitialSyncDone || !room.Loaded() { - return - } - - roomView := c.ui.MainView().GetRoom(reactEvent.RoomID) - if roomView == nil { - debug.Printf("Failed to handle edit event %v: No room view found.", reactEvent) - return - } - - roomView.AddReaction(origEvt, rel.Key) - if c.syncer.FirstSyncDone { - c.ui.Render() - } -} - -func (c *Container) HandleEncryptedUnsupported(source mautrix.EventSource, mxEvent *event.Event) { - mxEvent.Type = muksevt.EventEncryptionUnsupported - origContent, _ := mxEvent.Content.Parsed.(*event.EncryptedEventContent) - mxEvent.Content.Parsed = muksevt.EncryptionUnsupportedContent{Original: origContent} - c.HandleMessage(source, mxEvent) -} - -func (c *Container) HandleEncrypted(source mautrix.EventSource, mxEvent *event.Event) { - evt, err := c.crypto.DecryptMegolmEvent(mxEvent) - if err != nil { - debug.Printf("Failed to decrypt event %s: %v", mxEvent.ID, err) - mxEvent.Type = muksevt.EventBadEncrypted - origContent, _ := mxEvent.Content.Parsed.(*event.EncryptedEventContent) - mxEvent.Content.Parsed = &muksevt.BadEncryptedContent{ - Original: origContent, - Reason: err.Error(), - } - c.HandleMessage(source, mxEvent) - return - } - if evt.Type.IsInRoomVerification() { - err := c.crypto.ProcessInRoomVerification(evt) - if err != nil { - debug.Printf("[Crypto/Error] Failed to process in-room verification event %s of type %s: %v", evt.ID, evt.Type.String(), err) - } else { - debug.Printf("[Crypto/Debug] Processed in-room verification event %s of type %s", evt.ID, evt.Type.String()) - } - } else { - c.HandleMessage(source, evt) - } -} - -// HandleMessage is the event handler for the m.room.message timeline event. -func (c *Container) HandleMessage(source mautrix.EventSource, mxEvent *event.Event) { - room := c.GetOrCreateRoom(mxEvent.RoomID) - if source&mautrix.EventSourceLeave != 0 { - room.HasLeft = true - return - } else if source&mautrix.EventSourceState != 0 { - return - } - - relatable, ok := mxEvent.Content.Parsed.(event.Relatable) - if ok { - rel := relatable.GetRelatesTo() - if editID := rel.GetReplaceID(); len(editID) > 0 { - c.HandleEdit(room, editID, muksevt.Wrap(mxEvent)) - return - } else if reactionID := rel.GetAnnotationID(); mxEvent.Type == event.EventReaction && len(reactionID) > 0 { - c.HandleReaction(room, reactionID, muksevt.Wrap(mxEvent)) - return - } - } - - events, err := c.history.Append(room, []*event.Event{mxEvent}) - if err != nil { - debug.Printf("Failed to add event %s to history: %v", mxEvent.ID, err) - } - evt := events[0] - - if !c.config.AuthCache.InitialSyncDone { - room.LastReceivedMessage = time.Unix(evt.Timestamp/1000, evt.Timestamp%1000*1000) - return - } - - mainView := c.ui.MainView() - - roomView := mainView.GetRoom(evt.RoomID) - if roomView == nil { - debug.Printf("Failed to handle event %v: No room view found.", evt) - return - } - - if !room.Loaded() { - pushRules := c.PushRules().GetActions(room, evt.Event).Should() - if !pushRules.Notify { - room.LastReceivedMessage = time.Unix(evt.Timestamp/1000, evt.Timestamp%1000*1000) - room.AddUnread(evt.ID, pushRules.Notify, pushRules.Highlight) - mainView.Bump(room) - return - } - } - - message := roomView.AddEvent(evt) - if message != nil { - roomView.MxRoom().LastReceivedMessage = message.Time() - if c.syncer.FirstSyncDone && evt.Sender != c.config.UserID { - pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt.Event).Should() - mainView.NotifyMessage(roomView.MxRoom(), message, pushRules) - c.ui.Render() - } - } else { - debug.Printf("Parsing event %s type %s %v from %s in %s failed (ParseEvent() returned nil).", evt.ID, evt.Type.Repr(), evt.Content.Raw, evt.Sender, evt.RoomID) - } -} - -// HandleMembership is the event handler for the m.room.member state event. -func (c *Container) HandleMembership(source mautrix.EventSource, evt *event.Event) { - isLeave := source&mautrix.EventSourceLeave != 0 - isTimeline := source&mautrix.EventSourceTimeline != 0 - if isLeave { - c.GetOrCreateRoom(evt.RoomID).HasLeft = true - } - isNonTimelineLeave := isLeave && !isTimeline - if !c.config.AuthCache.InitialSyncDone && isNonTimelineLeave { - return - } else if evt.StateKey != nil && id.UserID(*evt.StateKey) == c.config.UserID { - c.processOwnMembershipChange(evt) - } else if !isTimeline && (!c.config.AuthCache.InitialSyncDone || isLeave) { - // We don't care about other users' membership events in the initial sync or chats we've left. - return - } - - c.HandleMessage(source, evt) -} - -func (c *Container) processOwnMembershipChange(evt *event.Event) { - membership := evt.Content.AsMember().Membership - prevMembership := event.MembershipLeave - if evt.Unsigned.PrevContent != nil { - prevMembership = evt.Unsigned.PrevContent.AsMember().Membership - } - debug.Printf("Processing own membership change: %s->%s in %s", prevMembership, membership, evt.RoomID) - if membership == prevMembership { - return - } - room := c.GetRoom(evt.RoomID) - switch membership { - case "join": - room.HasLeft = false - if c.config.AuthCache.InitialSyncDone { - c.ui.MainView().UpdateTags(room) - } - fallthrough - case "invite": - if c.config.AuthCache.InitialSyncDone { - c.ui.MainView().AddRoom(room) - } - case "leave": - case "ban": - if c.config.AuthCache.InitialSyncDone { - c.ui.MainView().RemoveRoom(room) - } - room.HasLeft = true - room.Unload() - default: - return - } - c.ui.Render() -} - -func (c *Container) parseReadReceipt(evt *event.Event) (largestTimestampEvent id.EventID) { - var largestTimestamp int64 - - for eventID, receipts := range *evt.Content.AsReceipt() { - myInfo, ok := receipts.Read[c.config.UserID] - if !ok { - continue - } - - if myInfo.Timestamp > largestTimestamp { - largestTimestamp = myInfo.Timestamp - largestTimestampEvent = eventID - } - } - return -} - -func (c *Container) HandleReadReceipt(source mautrix.EventSource, evt *event.Event) { - if source&mautrix.EventSourceLeave != 0 { - return - } - - lastReadEvent := c.parseReadReceipt(evt) - if len(lastReadEvent) == 0 { - return - } - - room := c.GetRoom(evt.RoomID) - if room != nil { - room.MarkRead(lastReadEvent) - if c.config.AuthCache.InitialSyncDone { - c.ui.Render() - } - } -} - -func (c *Container) parseDirectChatInfo(evt *event.Event) map[*rooms.Room]id.UserID { - directChats := make(map[*rooms.Room]id.UserID) - for userID, roomIDList := range *evt.Content.AsDirectChats() { - for _, roomID := range roomIDList { - // TODO we shouldn't create direct chat rooms that we aren't in - room := c.GetOrCreateRoom(roomID) - if room != nil && !room.HasLeft { - directChats[room] = userID - } - } - } - return directChats -} - -func (c *Container) HandleDirectChatInfo(_ mautrix.EventSource, evt *event.Event) { - directChats := c.parseDirectChatInfo(evt) - for _, room := range c.config.Rooms.Map { - userID, isDirect := directChats[room] - if isDirect != room.IsDirect { - room.IsDirect = isDirect - room.OtherUser = userID - if c.config.AuthCache.InitialSyncDone { - c.ui.MainView().UpdateTags(room) - } - } - } -} - -// HandlePushRules is the event handler for the m.push_rules account data event. -func (c *Container) HandlePushRules(_ mautrix.EventSource, evt *event.Event) { - debug.Print("Received updated push rules") - var err error - c.config.PushRules, err = pushrules.EventToPushRules(evt) - if err != nil { - debug.Print("Failed to convert event to push rules:", err) - return - } - c.config.SavePushRules() -} - -// HandleTag is the event handler for the m.tag account data event. -func (c *Container) HandleTag(_ mautrix.EventSource, evt *event.Event) { - room := c.GetOrCreateRoom(evt.RoomID) - - tags := evt.Content.AsTag().Tags - - newTags := make([]rooms.RoomTag, len(tags)) - index := 0 - for tag, info := range tags { - order := json.Number("0.5") - if len(info.Order) > 0 { - order = info.Order - } - newTags[index] = rooms.RoomTag{ - Tag: tag, - Order: order, - } - index++ - } - room.RawTags = newTags - - if c.config.AuthCache.InitialSyncDone { - mainView := c.ui.MainView() - mainView.UpdateTags(room) - } -} - -// HandleTyping is the event handler for the m.typing event. -func (c *Container) HandleTyping(_ mautrix.EventSource, evt *event.Event) { - if !c.config.AuthCache.InitialSyncDone { - return - } - c.ui.MainView().SetTyping(evt.RoomID, evt.Content.AsTyping().UserIDs) -} - -func (c *Container) MarkRead(roomID id.RoomID, eventID id.EventID) { - go func() { - defer debug.Recover() - err := c.client.MarkRead(roomID, eventID) - if err != nil { - debug.Printf("Failed to mark %s in %s as read: %v", eventID, roomID, err) - } - }() -} - -func (c *Container) PrepareMediaMessage(room *rooms.Room, path string, rel *ifc.Relation) (*muksevt.Event, error) { - resp, err := c.UploadMedia(path, room.Encrypted) - if err != nil { - return nil, err - } - content := event.MessageEventContent{ - MsgType: resp.MsgType, - Body: resp.Name, - Info: resp.Info, - } - if resp.EncryptionInfo != nil { - content.File = &event.EncryptedFileInfo{ - EncryptedFile: *resp.EncryptionInfo, - URL: resp.ContentURI.CUString(), - } - } else { - content.URL = resp.ContentURI.CUString() - } - - return c.prepareEvent(room.ID, &content, rel), nil -} - -func (c *Container) PrepareMarkdownMessage(roomID id.RoomID, msgtype event.MessageType, text, html string, rel *ifc.Relation) *muksevt.Event { - var content event.MessageEventContent - if html != "" { - content = event.MessageEventContent{ - FormattedBody: html, - Format: event.FormatHTML, - Body: text, - MsgType: msgtype, - } - } else { - content = format.RenderMarkdown(text, !c.config.Preferences.DisableMarkdown, !c.config.Preferences.DisableHTML) - content.MsgType = msgtype - } - - return c.prepareEvent(roomID, &content, rel) -} - -func (c *Container) prepareEvent(roomID id.RoomID, content *event.MessageEventContent, rel *ifc.Relation) *muksevt.Event { - if rel != nil && rel.Type == event.RelReplace { - contentCopy := *content - content.NewContent = &contentCopy - content.Body = "* " + content.Body - if len(content.FormattedBody) > 0 { - content.FormattedBody = "* " + content.FormattedBody - } - content.RelatesTo = &event.RelatesTo{ - Type: event.RelReplace, - EventID: rel.Event.ID, - } - } else if rel != nil && rel.Type == event.RelReply { - content.SetReply(rel.Event.Event) - } - - txnID := c.client.TxnID() - localEcho := muksevt.Wrap(&event.Event{ - ID: id.EventID(txnID), - Sender: c.config.UserID, - Type: event.EventMessage, - Timestamp: time.Now().UnixNano() / 1e6, - RoomID: roomID, - Content: event.Content{Parsed: content}, - Unsigned: event.Unsigned{TransactionID: txnID}, - }) - localEcho.Gomuks.OutgoingState = muksevt.StateLocalEcho - if rel != nil && rel.Type == event.RelReplace { - localEcho.ID = rel.Event.ID - localEcho.Gomuks.Edits = []*muksevt.Event{localEcho} - } - return localEcho -} - -func (c *Container) Redact(roomID id.RoomID, eventID id.EventID, reason string) error { - defer debug.Recover() - _, err := c.client.RedactEvent(roomID, eventID, mautrix.ReqRedact{Reason: reason}) - return err -} - -// SendMessage sends the given event. -func (c *Container) SendEvent(evt *muksevt.Event) (id.EventID, error) { - defer debug.Recover() - - _, _ = c.client.UserTyping(evt.RoomID, false, 0) - c.typing = 0 - room := c.GetRoom(evt.RoomID) - if room != nil && room.Encrypted && c.crypto != nil && evt.Type != event.EventReaction { - encrypted, err := c.crypto.EncryptMegolmEvent(evt.RoomID, evt.Type, &evt.Content) - if err != nil { - if isBadEncryptError(err) { - return "", err - } - debug.Print("Got", err, "while trying to encrypt message, sharing group session and trying again...") - err = c.crypto.ShareGroupSession(room.ID, room.GetMemberList()) - if err != nil { - return "", err - } - encrypted, err = c.crypto.EncryptMegolmEvent(evt.RoomID, evt.Type, &evt.Content) - if err != nil { - return "", err - } - } - evt.Type = event.EventEncrypted - evt.Content = event.Content{Parsed: encrypted} - } - resp, err := c.client.SendMessageEvent(evt.RoomID, evt.Type, &evt.Content, mautrix.ReqSendEvent{TransactionID: evt.Unsigned.TransactionID}) - if err != nil { - return "", err - } - return resp.EventID, nil -} - -func (c *Container) UploadMedia(path string, encrypt bool) (*ifc.UploadedMediaInfo, error) { - var err error - path, err = filepath.Abs(path) - if err != nil { - return nil, fmt.Errorf("failed to get absolute path: %w", err) - } - - msgtype, info, err := getMediaInfo(path) - if err != nil { - return nil, err - } - - file, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("failed to open file: %w", err) - } - - stat, err := file.Stat() - if err != nil { - return nil, fmt.Errorf("failed to get file info: %w", err) - } - - uploadFileName := stat.Name() - uploadMimeType := info.MimeType - - var content io.Reader - var encryptionInfo *attachment.EncryptedFile - if encrypt { - uploadMimeType = "application/octet-stream" - uploadFileName = "" - encryptionInfo = attachment.NewEncryptedFile() - content = encryptionInfo.EncryptStream(file) - } else { - content = file - } - - resp, err := c.client.UploadMedia(mautrix.ReqUploadMedia{ - Content: content, - ContentLength: stat.Size(), - ContentType: uploadMimeType, - FileName: uploadFileName, - }) - - if err != nil { - return nil, err - } - - return &ifc.UploadedMediaInfo{ - RespMediaUpload: resp, - EncryptionInfo: encryptionInfo, - Name: stat.Name(), - MsgType: msgtype, - Info: &info, - }, nil -} - -func (c *Container) sendTypingAsync(roomID id.RoomID, typing bool, timeout int64) { - defer debug.Recover() - _, _ = c.client.UserTyping(roomID, typing, timeout) -} - -// SendTyping sets whether or not the user is typing in the given room. -func (c *Container) SendTyping(roomID id.RoomID, typing bool) { - ts := time.Now().Unix() - if (c.typing > ts && typing) || (c.typing == 0 && !typing) { - return - } - - if typing { - go c.sendTypingAsync(roomID, true, 20000) - c.typing = ts + 15 - } else { - go c.sendTypingAsync(roomID, false, 0) - c.typing = 0 - } -} - -// CreateRoom attempts to create a new room and join the user. -func (c *Container) CreateRoom(req *mautrix.ReqCreateRoom) (*rooms.Room, error) { - resp, err := c.client.CreateRoom(req) - if err != nil { - return nil, err - } - room := c.GetOrCreateRoom(resp.RoomID) - return room, nil -} - -// JoinRoom makes the current user try to join the given room. -func (c *Container) JoinRoom(roomID id.RoomID, server string) (*rooms.Room, error) { - resp, err := c.client.JoinRoom(string(roomID), server, nil) - if err != nil { - return nil, err - } - - room := c.GetOrCreateRoom(resp.RoomID) - room.HasLeft = false - return room, nil -} - -// LeaveRoom makes the current user leave the given room. -func (c *Container) LeaveRoom(roomID id.RoomID) error { - _, err := c.client.LeaveRoom(roomID) - if err != nil { - return err - } - - node := c.GetOrCreateRoom(roomID) - node.HasLeft = true - node.Unload() - return nil -} - -func (c *Container) FetchMembers(room *rooms.Room) error { - debug.Print("Fetching member list for", room.ID) - members, err := c.client.Members(room.ID, mautrix.ReqMembers{At: room.LastPrevBatch}) - if err != nil { - return err - } - debug.Printf("Fetched %d members for %s", len(members.Chunk), room.ID) - for _, evt := range members.Chunk { - err := evt.Content.ParseRaw(evt.Type) - if err != nil { - debug.Printf("Failed to parse member event of %s: %v", evt.GetStateKey(), err) - continue - } - room.UpdateState(evt) - } - room.MembersFetched = true - return nil -} - -// GetHistory fetches room history. -func (c *Container) GetHistory(room *rooms.Room, limit int, dbPointer uint64) ([]*muksevt.Event, uint64, error) { - events, newDBPointer, err := c.history.Load(room, limit, dbPointer) - if err != nil { - return nil, dbPointer, err - } - if len(events) > 0 { - debug.Printf("Loaded %d events for %s from local cache", len(events), room.ID) - return events, newDBPointer, nil - } - resp, err := c.client.Messages(room.ID, room.PrevBatch, "", 'b', nil, limit) - if err != nil { - return nil, dbPointer, err - } - debug.Printf("Loaded %d events for %s from server from %s to %s", len(resp.Chunk), room.ID, resp.Start, resp.End) - for i, evt := range resp.Chunk { - err := evt.Content.ParseRaw(evt.Type) - if err != nil { - debug.Printf("Failed to unmarshal content of event %s (type %s) by %s in %s: %v\n%s", evt.ID, evt.Type.Repr(), evt.Sender, evt.RoomID, err, string(evt.Content.VeryRaw)) - } - - if evt.Type == event.EventEncrypted { - if c.crypto == nil { - evt.Type = muksevt.EventEncryptionUnsupported - origContent, _ := evt.Content.Parsed.(*event.EncryptedEventContent) - evt.Content.Parsed = muksevt.EncryptionUnsupportedContent{Original: origContent} - } else { - decrypted, err := c.crypto.DecryptMegolmEvent(evt) - if err != nil { - debug.Printf("Failed to decrypt event %s: %v", evt.ID, err) - evt.Type = muksevt.EventBadEncrypted - origContent, _ := evt.Content.Parsed.(*event.EncryptedEventContent) - evt.Content.Parsed = &muksevt.BadEncryptedContent{ - Original: origContent, - Reason: err.Error(), - } - } else { - resp.Chunk[i] = decrypted - } - } - } - } - for _, evt := range resp.State { - room.UpdateState(evt) - } - room.PrevBatch = resp.End - c.config.Rooms.Put(room) - if len(resp.Chunk) == 0 { - return []*muksevt.Event{}, dbPointer, nil - } - // TODO newDBPointer isn't accurate in this case yet, fix later - events, newDBPointer, err = c.history.Prepend(room, resp.Chunk) - if err != nil { - return nil, dbPointer, err - } - return events, dbPointer, nil -} - -func (c *Container) GetEvent(room *rooms.Room, eventID id.EventID) (*muksevt.Event, error) { - evt, err := c.history.Get(room, eventID) - if err != nil && err != EventNotFoundError { - debug.Printf("Failed to get event %s from local cache: %v", eventID, err) - } else if evt != nil { - debug.Printf("Found event %s in local cache", eventID) - return evt, err - } - mxEvent, err := c.client.GetEvent(room.ID, eventID) - if err != nil { - return nil, err - } - err = mxEvent.Content.ParseRaw(mxEvent.Type) - if err != nil { - return nil, err - } - debug.Printf("Loaded event %s from server", eventID) - return muksevt.Wrap(mxEvent), nil -} - -// GetOrCreateRoom gets the room instance stored in the session. -func (c *Container) GetOrCreateRoom(roomID id.RoomID) *rooms.Room { - return c.config.Rooms.GetOrCreate(roomID) -} - -// GetRoom gets the room instance stored in the session. -func (c *Container) GetRoom(roomID id.RoomID) *rooms.Room { - return c.config.Rooms.Get(roomID) -} - -func cp(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, in) - if err != nil { - return err - } - return out.Close() -} - -func (c *Container) DownloadToDisk(uri id.ContentURI, file *attachment.EncryptedFile, target string) (fullPath string, err error) { - cachePath := c.GetCachePath(uri) - if target == "" { - fullPath = cachePath - } else if !path.IsAbs(target) { - fullPath = path.Join(c.config.DownloadDir, target) - } else { - fullPath = target - } - - if _, statErr := os.Stat(cachePath); os.IsNotExist(statErr) { - var body io.ReadCloser - body, err = c.client.Download(uri) - if err != nil { - return - } - - var data []byte - data, err = ioutil.ReadAll(body) - _ = body.Close() - if err != nil { - return - } - - if file != nil { - err = file.DecryptInPlace(data) - if err != nil { - return - } - } - - err = ioutil.WriteFile(cachePath, data, 0600) - if err != nil { - return - } - } - - if fullPath != cachePath { - err = os.MkdirAll(path.Dir(fullPath), 0700) - if err != nil { - return - } - err = cp(cachePath, fullPath) - } - - return -} - -// Download fetches the given Matrix content (mxc) URL and returns the data, homeserver, file ID and potential errors. -// -// The file will be either read from the media cache (if found) or downloaded from the server. -func (c *Container) Download(uri id.ContentURI, file *attachment.EncryptedFile) (data []byte, err error) { - cacheFile := c.GetCachePath(uri) - var info os.FileInfo - if info, err = os.Stat(cacheFile); err == nil && !info.IsDir() { - data, err = ioutil.ReadFile(cacheFile) - if err == nil { - return - } - } - - data, err = c.download(uri, file, cacheFile) - return -} - -func (c *Container) GetDownloadURL(uri id.ContentURI, file *attachment.EncryptedFile) string { - addr, _ := url.Parse(c.mediaProxyURL) - addr.Path = path.Join(addr.Path, uri.Homeserver, uri.FileID) - if file != nil { - addr.RawQuery = (&url.Values{ - "k": {file.Key.Key}, - "iv": {file.InitVector}, - "hash": {file.Hashes.SHA256}, - }).Encode() - } - return addr.String() -} - -func (c *Container) download(uri id.ContentURI, file *attachment.EncryptedFile, cacheFile string) (data []byte, err error) { - var body io.ReadCloser - body, err = c.client.Download(uri) - if err != nil { - return - } - - data, err = ioutil.ReadAll(body) - _ = body.Close() - if err != nil { - return - } - - if file != nil { - err = file.DecryptInPlace(data) - if err != nil { - return - } - } - - err = ioutil.WriteFile(cacheFile, data, 0600) - return -} - -// GetCachePath gets the path to the cached version of the given homeserver:fileID combination. -// The file may or may not exist, use Download() to ensure it has been cached. -func (c *Container) GetCachePath(uri id.ContentURI) string { - dir := filepath.Join(c.config.MediaDir, uri.Homeserver) - - err := os.MkdirAll(dir, 0700) - if err != nil { - return "" - } - - return filepath.Join(dir, uri.FileID) -} diff --git a/matrix/mediainfo.go b/matrix/mediainfo.go deleted file mode 100644 index 5de4171..0000000 --- a/matrix/mediainfo.go +++ /dev/null @@ -1,106 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package matrix - -import ( - "context" - "fmt" - "image" - "os" - "strings" - "time" - - "github.com/gabriel-vasile/mimetype" - "gopkg.in/vansante/go-ffprobe.v2" - - "maunium.net/go/mautrix/event" - - "maunium.net/go/gomuks/debug" -) - -func getImageInfo(path string) (event.FileInfo, error) { - var info event.FileInfo - file, err := os.Open(path) - if err != nil { - return info, fmt.Errorf("failed to open image to get info: %w", err) - } - cfg, _, err := image.DecodeConfig(file) - if err != nil { - return info, fmt.Errorf("failed to get image info: %w", err) - } - info.Width = cfg.Width - info.Height = cfg.Height - return info, nil -} - -func getFFProbeInfo(mimeClass, path string) (msgtype event.MessageType, info event.FileInfo, err error) { - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - var probedInfo *ffprobe.ProbeData - probedInfo, err = ffprobe.ProbeURL(ctx, path) - if err != nil { - err = fmt.Errorf("failed to get %s info with ffprobe: %w", mimeClass, err) - return - } - if mimeClass == "audio" { - msgtype = event.MsgAudio - stream := probedInfo.FirstAudioStream() - if stream != nil { - info.Duration = int(stream.DurationTs) - } - } else { - msgtype = event.MsgVideo - stream := probedInfo.FirstVideoStream() - if stream != nil { - info.Duration = int(stream.DurationTs) - info.Width = stream.Width - info.Height = stream.Height - } - } - return -} - -func getMediaInfo(path string) (msgtype event.MessageType, info event.FileInfo, err error) { - var mime *mimetype.MIME - mime, err = mimetype.DetectFile(path) - if err != nil { - err = fmt.Errorf("failed to get content type: %w", err) - return - } - - mimeClass := strings.SplitN(mime.String(), "/", 2)[0] - switch mimeClass { - case "image": - msgtype = event.MsgImage - info, err = getImageInfo(path) - if err != nil { - debug.Printf("Failed to get image info for %s: %v", err) - err = nil - } - case "audio", "video": - msgtype, info, err = getFFProbeInfo(mimeClass, path) - if err != nil { - debug.Printf("Failed to get ffprobe info for %s: %v", err) - err = nil - } - default: - msgtype = event.MsgFile - } - info.MimeType = mime.String() - - return -} diff --git a/matrix/muksevt/content.go b/matrix/muksevt/content.go deleted file mode 100644 index fb78323..0000000 --- a/matrix/muksevt/content.go +++ /dev/null @@ -1,44 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package muksevt - -import ( - "encoding/gob" - "reflect" - - "maunium.net/go/mautrix/event" -) - -var EventBadEncrypted = event.Type{Type: "net.maunium.gomuks.bad_encrypted", Class: event.MessageEventType} -var EventEncryptionUnsupported = event.Type{Type: "net.maunium.gomuks.encryption_unsupported", Class: event.MessageEventType} - -type BadEncryptedContent struct { - Original *event.EncryptedEventContent `json:"-"` - - Reason string `json:"-"` -} - -type EncryptionUnsupportedContent struct { - Original *event.EncryptedEventContent `json:"-"` -} - -func init() { - gob.Register(&BadEncryptedContent{}) - gob.Register(&EncryptionUnsupportedContent{}) - event.TypeMap[EventBadEncrypted] = reflect.TypeOf(&BadEncryptedContent{}) - event.TypeMap[EventEncryptionUnsupported] = reflect.TypeOf(&EncryptionUnsupportedContent{}) -} diff --git a/matrix/muksevt/event.go b/matrix/muksevt/event.go deleted file mode 100644 index 0d3303d..0000000 --- a/matrix/muksevt/event.go +++ /dev/null @@ -1,53 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package muksevt - -import ( - "maunium.net/go/mautrix/event" -) - -type Event struct { - *event.Event - Gomuks GomuksContent `json:"-"` -} - -func (evt *Event) SomewhatDangerousCopy() *Event { - base := *evt.Event - content := *base.Content.Parsed.(*event.MessageEventContent) - evt.Content.Parsed = &content - return &Event{ - Event: &base, - Gomuks: evt.Gomuks, - } -} - -func Wrap(event *event.Event) *Event { - return &Event{Event: event} -} - -type OutgoingState int - -const ( - StateDefault OutgoingState = iota - StateLocalEcho - StateSendFail -) - -type GomuksContent struct { - OutgoingState OutgoingState - Edits []*Event -} diff --git a/matrix/nocrypto.go b/matrix/nocrypto.go deleted file mode 100644 index 90b3dd9..0000000 --- a/matrix/nocrypto.go +++ /dev/null @@ -1,15 +0,0 @@ -// This contains no-op stubs of the methods in crypto.go for non-cgo builds with crypto disabled. - -//go:build !cgo - -package matrix - -func isBadEncryptError(err error) bool { - return false -} - -func (c *Container) initCrypto() error { - return nil -} - -func (c *Container) cryptoOnLogin() {} diff --git a/matrix/rooms/doc.go b/matrix/rooms/doc.go deleted file mode 100644 index 2a4ff4b..0000000 --- a/matrix/rooms/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package rooms contains a representation for Matrix rooms and utilities to parse state events. -package rooms diff --git a/matrix/rooms/room.go b/matrix/rooms/room.go deleted file mode 100644 index da39e3e..0000000 --- a/matrix/rooms/room.go +++ /dev/null @@ -1,715 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package rooms - -import ( - "compress/gzip" - "encoding/gob" - "encoding/json" - "fmt" - "os" - "time" - - sync "github.com/sasha-s/go-deadlock" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/debug" -) - -func init() { - gob.Register(map[string]interface{}{}) - gob.Register([]interface{}{}) -} - -type RoomNameSource int - -const ( - UnknownRoomName RoomNameSource = iota - MemberRoomName - CanonicalAliasRoomName - ExplicitRoomName -) - -// RoomTag is a tag given to a specific room. -type RoomTag struct { - // The name of the tag. - Tag string - // The order of the tag. - Order json.Number -} - -type UnreadMessage struct { - EventID id.EventID - Counted bool - Highlight bool -} - -type Member struct { - event.MemberEventContent - - // The user who sent the membership event - Sender id.UserID `json:"-"` -} - -// Room represents a single Matrix room. -type Room struct { - // The room ID. - ID id.RoomID - - // Whether or not the user has left the room. - HasLeft bool - // Whether or not the room is encrypted. - Encrypted bool - - // The first batch of events that has been fetched for this room. - // Used for fetching additional history. - PrevBatch string - // The last_batch field from the most recent sync. Used for fetching member lists. - LastPrevBatch string - // The MXID of the user whose session this room was created for. - SessionUserID id.UserID - SessionMember *Member - - // The number of unread messages that were notified about. - UnreadMessages []UnreadMessage - unreadCountCache *int - highlightCache *bool - lastMarkedRead id.EventID - // Whether or not this room is marked as a direct chat. - IsDirect bool - OtherUser id.UserID - - // List of tags given to this room. - RawTags []RoomTag - // Timestamp of previously received actual message. - LastReceivedMessage time.Time - - // The lazy loading summary for this room. - Summary mautrix.LazyLoadSummary - // Whether or not the members for this room have been fetched from the server. - MembersFetched bool - // Room state cache. - state map[event.Type]map[string]*event.Event - // MXID -> Member cache calculated from membership events. - memberCache map[id.UserID]*Member - exMemberCache map[id.UserID]*Member - // The first two non-SessionUserID members in the room. Calculated at - // the same time as memberCache. - firstMemberCache *Member - secondMemberCache *Member - // The name of the room. Calculated from the state event name, - // canonical_alias or alias or the member cache. - NameCache string - // The event type from which the name cache was calculated from. - nameCacheSource RoomNameSource - // The topic of the room. Directly fetched from the m.room.topic state event. - topicCache string - // The canonical alias of the room. Directly fetched from the m.room.canonical_alias state event. - CanonicalAliasCache id.RoomAlias - // Whether or not the room has been tombstoned. - replacedCache bool - // The room ID that replaced this room. - replacedByCache *id.RoomID - - // Path for state store file. - path string - // Room cache object - cache *RoomCache - // Lock for state and other room stuff. - lock sync.RWMutex - // Pre/post un/load hooks - preUnload func() bool - preLoad func() bool - postUnload func() - postLoad func() - // Whether or not the room state has changed - changed bool - - // Room state cache linked list. - prev *Room - next *Room - touch int64 -} - -func debugPrintError(fn func() error, message string) { - if err := fn(); err != nil { - debug.Printf("%s: %v", message, err) - } -} - -func (room *Room) Loaded() bool { - return room.state != nil -} - -func (room *Room) Load() { - room.cache.TouchNode(room) - if room.Loaded() { - return - } - if room.preLoad != nil && !room.preLoad() { - return - } - room.lock.Lock() - room.load() - room.lock.Unlock() - if room.postLoad != nil { - room.postLoad() - } -} - -func (room *Room) load() { - if room.Loaded() { - return - } - debug.Print("Loading state for room", room.ID, "from disk") - room.state = make(map[event.Type]map[string]*event.Event) - file, err := os.OpenFile(room.path, os.O_RDONLY, 0600) - if err != nil { - if !os.IsNotExist(err) { - debug.Print("Failed to open room state file for reading:", err) - } else { - debug.Print("Room state file for", room.ID, "does not exist") - } - return - } - defer debugPrintError(file.Close, "Failed to close room state file after reading") - cmpReader, err := gzip.NewReader(file) - if err != nil { - debug.Print("Failed to open room state gzip reader:", err) - return - } - defer debugPrintError(cmpReader.Close, "Failed to close room state gzip reader") - dec := gob.NewDecoder(cmpReader) - if err = dec.Decode(&room.state); err != nil { - debug.Print("Failed to decode room state:", err) - } - room.changed = false -} - -func (room *Room) Touch() { - room.cache.TouchNode(room) -} - -func (room *Room) Unload() bool { - if room.preUnload != nil && !room.preUnload() { - return false - } - debug.Print("Unloading", room.ID) - room.Save() - room.state = nil - room.memberCache = nil - room.exMemberCache = nil - room.firstMemberCache = nil - room.secondMemberCache = nil - if room.postUnload != nil { - room.postUnload() - } - return true -} - -func (room *Room) SetPreUnload(fn func() bool) { - room.preUnload = fn -} - -func (room *Room) SetPreLoad(fn func() bool) { - room.preLoad = fn -} - -func (room *Room) SetPostUnload(fn func()) { - room.postUnload = fn -} - -func (room *Room) SetPostLoad(fn func()) { - room.postLoad = fn -} - -func (room *Room) Save() { - if !room.Loaded() { - debug.Print("Failed to save room", room.ID, "state: room not loaded") - return - } - if !room.changed { - debug.Print("Not saving", room.ID, "as state hasn't changed") - return - } - debug.Print("Saving state for room", room.ID, "to disk") - file, err := os.OpenFile(room.path, os.O_WRONLY|os.O_CREATE, 0600) - if err != nil { - debug.Print("Failed to open room state file for writing:", err) - return - } - defer debugPrintError(file.Close, "Failed to close room state file after writing") - cmpWriter := gzip.NewWriter(file) - defer debugPrintError(cmpWriter.Close, "Failed to close room state gzip writer") - enc := gob.NewEncoder(cmpWriter) - room.lock.RLock() - defer room.lock.RUnlock() - if err := enc.Encode(&room.state); err != nil { - debug.Print("Failed to encode room state:", err) - } -} - -// MarkRead clears the new message statuses on this room. -func (room *Room) MarkRead(eventID id.EventID) bool { - room.lock.Lock() - defer room.lock.Unlock() - if room.lastMarkedRead == eventID { - return false - } - room.lastMarkedRead = eventID - readToIndex := -1 - for index, unreadMessage := range room.UnreadMessages { - if unreadMessage.EventID == eventID { - readToIndex = index - } - } - if readToIndex >= 0 { - room.UnreadMessages = room.UnreadMessages[readToIndex+1:] - room.highlightCache = nil - room.unreadCountCache = nil - } - return true -} - -func (room *Room) UnreadCount() int { - room.lock.Lock() - defer room.lock.Unlock() - if room.unreadCountCache == nil { - room.unreadCountCache = new(int) - for _, unreadMessage := range room.UnreadMessages { - if unreadMessage.Counted { - *room.unreadCountCache++ - } - } - } - return *room.unreadCountCache -} - -func (room *Room) Highlighted() bool { - room.lock.Lock() - defer room.lock.Unlock() - if room.highlightCache == nil { - room.highlightCache = new(bool) - for _, unreadMessage := range room.UnreadMessages { - if unreadMessage.Highlight { - *room.highlightCache = true - break - } - } - } - return *room.highlightCache -} - -func (room *Room) HasNewMessages() bool { - return len(room.UnreadMessages) > 0 -} - -func (room *Room) AddUnread(eventID id.EventID, counted, highlight bool) { - room.lock.Lock() - defer room.lock.Unlock() - room.UnreadMessages = append(room.UnreadMessages, UnreadMessage{ - EventID: eventID, - Counted: counted, - Highlight: highlight, - }) - if counted { - if room.unreadCountCache == nil { - room.unreadCountCache = new(int) - } - *room.unreadCountCache++ - } - if highlight { - if room.highlightCache == nil { - room.highlightCache = new(bool) - } - *room.highlightCache = true - } -} - -var ( - tagDirect = RoomTag{"net.maunium.gomuks.fake.direct", "0.5"} - tagInvite = RoomTag{"net.maunium.gomuks.fake.invite", "0.5"} - tagDefault = RoomTag{"", "0.5"} - tagLeave = RoomTag{"net.maunium.gomuks.fake.leave", "0.5"} -) - -func (room *Room) Tags() []RoomTag { - room.lock.RLock() - defer room.lock.RUnlock() - if len(room.RawTags) == 0 { - if room.IsDirect { - return []RoomTag{tagDirect} - } else if room.SessionMember != nil && room.SessionMember.Membership == event.MembershipInvite { - return []RoomTag{tagInvite} - } else if room.SessionMember != nil && room.SessionMember.Membership != event.MembershipJoin { - return []RoomTag{tagLeave} - } - return []RoomTag{tagDefault} - } - return room.RawTags -} - -func (room *Room) UpdateSummary(summary mautrix.LazyLoadSummary) { - if summary.JoinedMemberCount != nil { - room.Summary.JoinedMemberCount = summary.JoinedMemberCount - } - if summary.InvitedMemberCount != nil { - room.Summary.InvitedMemberCount = summary.InvitedMemberCount - } - if summary.Heroes != nil { - room.Summary.Heroes = summary.Heroes - } - if room.nameCacheSource <= MemberRoomName { - room.NameCache = "" - } -} - -// UpdateState updates the room's current state with the given Event. This will clobber events based -// on the type/state_key combination. -func (room *Room) UpdateState(evt *event.Event) { - if evt.StateKey == nil { - panic("Tried to UpdateState() event with no state key.") - } - room.Load() - room.lock.Lock() - defer room.lock.Unlock() - room.changed = true - _, exists := room.state[evt.Type] - if !exists { - room.state[evt.Type] = make(map[string]*event.Event) - } - switch content := evt.Content.Parsed.(type) { - case *event.RoomNameEventContent: - room.NameCache = content.Name - room.nameCacheSource = ExplicitRoomName - case *event.CanonicalAliasEventContent: - if room.nameCacheSource <= CanonicalAliasRoomName { - room.NameCache = string(content.Alias) - room.nameCacheSource = CanonicalAliasRoomName - } - room.CanonicalAliasCache = content.Alias - case *event.MemberEventContent: - if room.nameCacheSource <= MemberRoomName { - room.NameCache = "" - } - room.updateMemberState(id.UserID(evt.GetStateKey()), evt.Sender, content) - case *event.TopicEventContent: - room.topicCache = content.Topic - case *event.EncryptionEventContent: - if content.Algorithm == id.AlgorithmMegolmV1 { - room.Encrypted = true - } - } - - if evt.Type != event.StateMember { - debug.Printf("Updating state %s#%s for %s", evt.Type.String(), evt.GetStateKey(), room.ID) - } - - room.state[evt.Type][*evt.StateKey] = evt -} - -func (room *Room) updateMemberState(userID, sender id.UserID, content *event.MemberEventContent) { - if userID == room.SessionUserID { - debug.Print("Updating session user state:", content) - room.SessionMember = room.eventToMember(userID, sender, content) - } - if room.memberCache != nil { - member := room.eventToMember(userID, sender, content) - if member.Membership.IsInviteOrJoin() { - existingMember, ok := room.memberCache[userID] - if ok { - *existingMember = *member - } else { - delete(room.exMemberCache, userID) - room.memberCache[userID] = member - room.updateNthMemberCache(userID, member) - } - } else { - existingExMember, ok := room.exMemberCache[userID] - if ok { - *existingExMember = *member - } else { - delete(room.memberCache, userID) - room.exMemberCache[userID] = member - } - } - } -} - -// GetStateEvent returns the state event for the given type/state_key combo, or nil. -func (room *Room) GetStateEvent(eventType event.Type, stateKey string) *event.Event { - room.Load() - room.lock.RLock() - defer room.lock.RUnlock() - stateEventMap, _ := room.state[eventType] - evt, _ := stateEventMap[stateKey] - return evt -} - -// getStateEvents returns the state events for the given type. -func (room *Room) getStateEvents(eventType event.Type) map[string]*event.Event { - stateEventMap, _ := room.state[eventType] - return stateEventMap -} - -// GetTopic returns the topic of the room. -func (room *Room) GetTopic() string { - if len(room.topicCache) == 0 { - topicEvt := room.GetStateEvent(event.StateTopic, "") - if topicEvt != nil { - room.topicCache = topicEvt.Content.AsTopic().Topic - } - } - return room.topicCache -} - -func (room *Room) GetCanonicalAlias() id.RoomAlias { - if len(room.CanonicalAliasCache) == 0 { - canonicalAliasEvt := room.GetStateEvent(event.StateCanonicalAlias, "") - if canonicalAliasEvt != nil { - room.CanonicalAliasCache = canonicalAliasEvt.Content.AsCanonicalAlias().Alias - } else { - room.CanonicalAliasCache = "-" - } - } - if room.CanonicalAliasCache == "-" { - return "" - } - return room.CanonicalAliasCache -} - -// updateNameFromNameEvent updates the room display name to be the name set in the name event. -func (room *Room) updateNameFromNameEvent() { - nameEvt := room.GetStateEvent(event.StateRoomName, "") - if nameEvt != nil { - room.NameCache = nameEvt.Content.AsRoomName().Name - } -} - -// updateNameFromMembers updates the room display name based on the members in this room. -// -// The room name depends on the number of users: -// -// Less than two users -> "Empty room" -// Exactly two users -> The display name of the other user. -// More than two users -> The display name of one of the other users, followed -// by "and X others", where X is the number of users -// excluding the local user and the named user. -func (room *Room) updateNameFromMembers() { - members := room.GetMembers() - if len(members) <= 1 { - room.NameCache = "Empty room" - } else if room.firstMemberCache == nil { - room.NameCache = "Room" - } else if len(members) == 2 { - room.NameCache = room.firstMemberCache.Displayname - } else if len(members) == 3 && room.secondMemberCache != nil { - room.NameCache = fmt.Sprintf("%s and %s", room.firstMemberCache.Displayname, room.secondMemberCache.Displayname) - } else { - members := room.firstMemberCache.Displayname - count := len(members) - 2 - if room.secondMemberCache != nil { - members += ", " + room.secondMemberCache.Displayname - count-- - } - room.NameCache = fmt.Sprintf("%s and %d others", members, count) - } -} - -// updateNameCache updates the room display name based on the room state in the order -// specified in spec section 11.2.2.5. -func (room *Room) updateNameCache() { - if len(room.NameCache) == 0 { - room.updateNameFromNameEvent() - room.nameCacheSource = ExplicitRoomName - } - if len(room.NameCache) == 0 { - room.NameCache = string(room.GetCanonicalAlias()) - room.nameCacheSource = CanonicalAliasRoomName - } - if len(room.NameCache) == 0 { - room.updateNameFromMembers() - room.nameCacheSource = MemberRoomName - } -} - -// GetTitle returns the display name of the room. -// -// The display name is returned from the cache. -// If the cache is empty, it is updated first. -func (room *Room) GetTitle() string { - room.updateNameCache() - return room.NameCache -} - -func (room *Room) IsReplaced() bool { - if room.replacedByCache == nil { - evt := room.GetStateEvent(event.StateTombstone, "") - var replacement id.RoomID - if evt != nil { - content, ok := evt.Content.Parsed.(*event.TombstoneEventContent) - if ok { - replacement = content.ReplacementRoom - } - } - room.replacedCache = evt != nil - room.replacedByCache = &replacement - } - return room.replacedCache -} - -func (room *Room) ReplacedBy() id.RoomID { - if room.replacedByCache == nil { - room.IsReplaced() - } - return *room.replacedByCache -} - -func (room *Room) eventToMember(userID, sender id.UserID, member *event.MemberEventContent) *Member { - if len(member.Displayname) == 0 { - member.Displayname = string(userID) - } - return &Member{ - MemberEventContent: *member, - Sender: sender, - } -} - -func (room *Room) updateNthMemberCache(userID id.UserID, member *Member) { - if userID != room.SessionUserID { - if room.firstMemberCache == nil { - room.firstMemberCache = member - } else if room.secondMemberCache == nil { - room.secondMemberCache = member - } - } -} - -// createMemberCache caches all member events into a easily processable MXID -> *Member map. -func (room *Room) createMemberCache() map[id.UserID]*Member { - if len(room.memberCache) > 0 { - return room.memberCache - } - cache := make(map[id.UserID]*Member) - exCache := make(map[id.UserID]*Member) - room.lock.RLock() - memberEvents := room.getStateEvents(event.StateMember) - room.firstMemberCache = nil - room.secondMemberCache = nil - if memberEvents != nil { - for userIDStr, evt := range memberEvents { - userID := id.UserID(userIDStr) - member := room.eventToMember(userID, evt.Sender, evt.Content.AsMember()) - if member.Membership.IsInviteOrJoin() { - cache[userID] = member - room.updateNthMemberCache(userID, member) - } else { - exCache[userID] = member - } - if userID == room.SessionUserID { - room.SessionMember = member - } - } - } - if len(room.Summary.Heroes) > 1 { - room.firstMemberCache, _ = cache[room.Summary.Heroes[0]] - } - if len(room.Summary.Heroes) > 2 { - room.secondMemberCache, _ = cache[room.Summary.Heroes[1]] - } - room.lock.RUnlock() - room.lock.Lock() - room.memberCache = cache - room.exMemberCache = exCache - room.lock.Unlock() - return cache -} - -// GetMembers returns the members in this room. -// -// The members are returned from the cache. -// If the cache is empty, it is updated first. -func (room *Room) GetMembers() map[id.UserID]*Member { - room.Load() - room.createMemberCache() - return room.memberCache -} - -func (room *Room) GetMemberList() []id.UserID { - members := room.GetMembers() - memberList := make([]id.UserID, len(members)) - index := 0 - for userID, _ := range members { - memberList[index] = userID - index++ - } - return memberList -} - -// GetMember returns the member with the given MXID. -// If the member doesn't exist, nil is returned. -func (room *Room) GetMember(userID id.UserID) *Member { - if userID == room.SessionUserID && room.SessionMember != nil { - return room.SessionMember - } - room.Load() - room.createMemberCache() - room.lock.RLock() - member, ok := room.memberCache[userID] - if ok { - room.lock.RUnlock() - return member - } - exMember, ok := room.exMemberCache[userID] - if ok { - room.lock.RUnlock() - return exMember - } - room.lock.RUnlock() - return nil -} - -func (room *Room) GetMemberCount() int { - if room.memberCache == nil && room.Summary.JoinedMemberCount != nil { - return *room.Summary.JoinedMemberCount - } - return len(room.GetMembers()) -} - -// GetSessionOwner returns the ID of the user whose session this room was created for. -func (room *Room) GetOwnDisplayname() string { - member := room.GetMember(room.SessionUserID) - if member != nil { - return member.Displayname - } - return "" -} - -// NewRoom creates a new Room with the given ID -func NewRoom(roomID id.RoomID, cache *RoomCache) *Room { - return &Room{ - ID: roomID, - state: make(map[event.Type]map[string]*event.Event), - path: cache.roomPath(roomID), - cache: cache, - - SessionUserID: cache.getOwner(), - } -} diff --git a/matrix/rooms/roomcache.go b/matrix/rooms/roomcache.go deleted file mode 100644 index cafa262..0000000 --- a/matrix/rooms/roomcache.go +++ /dev/null @@ -1,376 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package rooms - -import ( - "compress/gzip" - "encoding/gob" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - sync "github.com/sasha-s/go-deadlock" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/debug" -) - -// RoomCache contains room state info in a hashmap and linked list. -type RoomCache struct { - sync.Mutex - - listPath string - directory string - maxSize int - maxAge int64 - getOwner func() id.UserID - noUnload bool - - Map map[id.RoomID]*Room - head *Room - tail *Room - size int -} - -func NewRoomCache(listPath, directory string, maxSize int, maxAge int64, getOwner func() id.UserID) *RoomCache { - return &RoomCache{ - listPath: listPath, - directory: directory, - maxSize: maxSize, - maxAge: maxAge, - getOwner: getOwner, - - Map: make(map[id.RoomID]*Room), - } -} - -func (cache *RoomCache) DisableUnloading() { - cache.noUnload = true -} - -func (cache *RoomCache) EnableUnloading() { - cache.noUnload = false -} - -func (cache *RoomCache) IsEncrypted(roomID id.RoomID) bool { - room := cache.Get(roomID) - return room != nil && room.Encrypted -} - -func (cache *RoomCache) GetEncryptionEvent(roomID id.RoomID) *event.EncryptionEventContent { - room := cache.Get(roomID) - evt := room.GetStateEvent(event.StateEncryption, "") - if evt == nil { - return nil - } - content, ok := evt.Content.Parsed.(*event.EncryptionEventContent) - if !ok { - return nil - } - return content -} - -func (cache *RoomCache) FindSharedRooms(userID id.UserID) (shared []id.RoomID) { - // FIXME this disables unloading so TouchNode wouldn't try to double-lock - cache.DisableUnloading() - cache.Lock() - for _, room := range cache.Map { - if !room.Encrypted { - continue - } - member, ok := room.GetMembers()[userID] - if ok && member.Membership == event.MembershipJoin { - shared = append(shared, room.ID) - } - } - cache.Unlock() - cache.EnableUnloading() - return -} - -func (cache *RoomCache) LoadList() error { - cache.Lock() - defer cache.Unlock() - - // Open room list file - file, err := os.OpenFile(cache.listPath, os.O_RDONLY, 0600) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("failed to open room list file for reading: %w", err) - } - defer debugPrintError(file.Close, "Failed to close room list file after reading") - - // Open gzip reader for room list file - cmpReader, err := gzip.NewReader(file) - if err != nil { - return fmt.Errorf("failed to read gzip room list: %w", err) - } - defer debugPrintError(cmpReader.Close, "Failed to close room list gzip reader") - - // Open gob decoder for gzip reader - dec := gob.NewDecoder(cmpReader) - // Read number of items in list - var size int - err = dec.Decode(&size) - if err != nil { - return fmt.Errorf("failed to read size of room list: %w", err) - } - - // Read list - cache.Map = make(map[id.RoomID]*Room, size) - for i := 0; i < size; i++ { - room := &Room{} - err = dec.Decode(room) - if err != nil { - debug.Printf("Failed to decode %dth room list entry: %v", i+1, err) - continue - } - room.path = cache.roomPath(room.ID) - room.cache = cache - cache.Map[room.ID] = room - } - return nil -} - -func (cache *RoomCache) SaveLoadedRooms() { - cache.Lock() - cache.clean(false) - for node := cache.head; node != nil; node = node.prev { - node.Save() - } - cache.Unlock() -} - -func (cache *RoomCache) SaveList() error { - cache.Lock() - defer cache.Unlock() - - debug.Print("Saving room list...") - // Open room list file - file, err := os.OpenFile(cache.listPath, os.O_WRONLY|os.O_CREATE, 0600) - if err != nil { - return fmt.Errorf("failed to open room list file for writing: %w", err) - } - defer debugPrintError(file.Close, "Failed to close room list file after writing") - - // Open gzip writer for room list file - cmpWriter := gzip.NewWriter(file) - defer debugPrintError(cmpWriter.Close, "Failed to close room list gzip writer") - - // Open gob encoder for gzip writer - enc := gob.NewEncoder(cmpWriter) - // Write number of items in list - err = enc.Encode(len(cache.Map)) - if err != nil { - return fmt.Errorf("failed to write size of room list: %w", err) - } - - // Write list - for _, node := range cache.Map { - err = enc.Encode(node) - if err != nil { - debug.Printf("Failed to encode room list entry of %s: %v", node.ID, err) - } - } - debug.Print("Room list saved to", cache.listPath, len(cache.Map), cache.size) - return nil -} - -func (cache *RoomCache) Touch(roomID id.RoomID) { - cache.Lock() - node, ok := cache.Map[roomID] - if !ok || node == nil { - cache.Unlock() - return - } - cache.touch(node) - cache.Unlock() -} - -func (cache *RoomCache) TouchNode(node *Room) { - if cache.noUnload || node.touch+2 > time.Now().Unix() { - return - } - cache.Lock() - cache.touch(node) - cache.Unlock() -} - -func (cache *RoomCache) touch(node *Room) { - if node == cache.head { - return - } - debug.Print("Touching", node.ID) - cache.llPop(node) - cache.llPush(node) - node.touch = time.Now().Unix() -} - -func (cache *RoomCache) Get(roomID id.RoomID) *Room { - cache.Lock() - node := cache.get(roomID) - cache.Unlock() - return node -} - -func (cache *RoomCache) GetOrCreate(roomID id.RoomID) *Room { - cache.Lock() - node := cache.get(roomID) - if node == nil { - node = cache.newRoom(roomID) - cache.llPush(node) - } - cache.Unlock() - return node -} - -func (cache *RoomCache) get(roomID id.RoomID) *Room { - node, ok := cache.Map[roomID] - if ok && node != nil { - return node - } - return nil -} - -func (cache *RoomCache) Put(room *Room) { - cache.Lock() - node := cache.get(room.ID) - if node != nil { - cache.touch(node) - } else { - cache.Map[room.ID] = room - if room.Loaded() { - cache.llPush(room) - } - node = room - } - cache.Unlock() - node.Save() -} - -func (cache *RoomCache) roomPath(roomID id.RoomID) string { - escapedRoomID := strings.ReplaceAll(strings.ReplaceAll(string(roomID), "%", "%25"), "/", "%2F") - return filepath.Join(cache.directory, escapedRoomID+".gob.gz") -} - -func (cache *RoomCache) Load(roomID id.RoomID) *Room { - cache.Lock() - defer cache.Unlock() - node, ok := cache.Map[roomID] - if ok { - return node - } - - node = NewRoom(roomID, cache) - node.Load() - return node -} - -func (cache *RoomCache) llPop(node *Room) { - if node.prev == nil && node.next == nil { - return - } - if node.prev != nil { - node.prev.next = node.next - } - if node.next != nil { - node.next.prev = node.prev - } - if node == cache.tail { - cache.tail = node.next - } - if node == cache.head { - cache.head = node.prev - } - node.next = nil - node.prev = nil - cache.size-- -} - -func (cache *RoomCache) llPush(node *Room) { - if node.next != nil || node.prev != nil { - debug.PrintStack() - debug.Print("Tried to llPush node that is already in stack") - return - } - if node == cache.head { - return - } - if cache.head != nil { - cache.head.next = node - } - node.prev = cache.head - node.next = nil - cache.head = node - if cache.tail == nil { - cache.tail = node - } - cache.size++ - cache.clean(false) -} - -func (cache *RoomCache) ForceClean() { - cache.Lock() - cache.clean(true) - cache.Unlock() -} - -func (cache *RoomCache) clean(force bool) { - if cache.noUnload && !force { - return - } - origSize := cache.size - maxTS := time.Now().Unix() - cache.maxAge - for cache.size > cache.maxSize { - if cache.tail.touch > maxTS && !force { - break - } - ok := cache.tail.Unload() - node := cache.tail - cache.llPop(node) - if !ok { - debug.Print("Unload returned false, pushing node back") - cache.llPush(node) - } - } - if cleaned := origSize - cache.size; cleaned > 0 { - debug.Print("Cleaned", cleaned, "rooms") - } -} - -func (cache *RoomCache) Unload(node *Room) { - cache.Lock() - defer cache.Unlock() - cache.llPop(node) - ok := node.Unload() - if !ok { - debug.Print("Unload returned false, pushing node back") - cache.llPush(node) - } -} - -func (cache *RoomCache) newRoom(roomID id.RoomID) *Room { - node := NewRoom(roomID, cache) - cache.Map[node.ID] = node - return node -} diff --git a/matrix/sync.go b/matrix/sync.go deleted file mode 100644 index 7c1d879..0000000 --- a/matrix/sync.go +++ /dev/null @@ -1,267 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -// Based on https://github.com/matrix-org/mautrix/blob/master/sync.go - -package matrix - -import ( - "sync" - "time" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/matrix/rooms" -) - -type GomuksSyncer struct { - rooms *rooms.RoomCache - globalListeners []mautrix.SyncHandler - listeners map[event.Type][]mautrix.EventHandler // event type to listeners array - FirstSyncDone bool - InitDoneCallback func() - FirstDoneCallback func() - Progress ifc.SyncingModal -} - -// NewGomuksSyncer returns an instantiated GomuksSyncer -func NewGomuksSyncer(rooms *rooms.RoomCache) *GomuksSyncer { - return &GomuksSyncer{ - rooms: rooms, - globalListeners: []mautrix.SyncHandler{}, - listeners: make(map[event.Type][]mautrix.EventHandler), - FirstSyncDone: false, - Progress: StubSyncingModal{}, - } -} - -// ProcessResponse processes a Matrix sync response. -func (s *GomuksSyncer) ProcessResponse(res *mautrix.RespSync, since string) (err error) { - if since == "" { - s.rooms.DisableUnloading() - } - debug.Print("Received sync response") - s.Progress.SetMessage("Processing sync response") - steps := len(res.Rooms.Join) + len(res.Rooms.Invite) + len(res.Rooms.Leave) - s.Progress.SetSteps(steps + 2 + len(s.globalListeners)) - - wait := &sync.WaitGroup{} - callback := func() { - wait.Done() - s.Progress.Step() - } - wait.Add(len(s.globalListeners)) - s.notifyGlobalListeners(res, since, callback) - wait.Wait() - - s.processSyncEvents(nil, res.Presence.Events, mautrix.EventSourcePresence) - s.Progress.Step() - s.processSyncEvents(nil, res.AccountData.Events, mautrix.EventSourceAccountData) - s.Progress.Step() - - wait.Add(steps) - - for roomID, roomData := range res.Rooms.Join { - go s.processJoinedRoom(roomID, roomData, callback) - } - - for roomID, roomData := range res.Rooms.Invite { - go s.processInvitedRoom(roomID, roomData, callback) - } - - for roomID, roomData := range res.Rooms.Leave { - go s.processLeftRoom(roomID, roomData, callback) - } - - wait.Wait() - s.Progress.SetMessage("Finishing sync") - - if since == "" && s.InitDoneCallback != nil { - s.InitDoneCallback() - s.rooms.EnableUnloading() - } - if !s.FirstSyncDone && s.FirstDoneCallback != nil { - s.FirstDoneCallback() - } - s.FirstSyncDone = true - return -} - -func (s *GomuksSyncer) notifyGlobalListeners(res *mautrix.RespSync, since string, callback func()) { - for _, listener := range s.globalListeners { - go func(listener mautrix.SyncHandler) { - listener(res, since) - callback() - }(listener) - } -} - -func (s *GomuksSyncer) processJoinedRoom(roomID id.RoomID, roomData mautrix.SyncJoinedRoom, callback func()) { - defer debug.Recover() - room := s.rooms.GetOrCreate(roomID) - room.UpdateSummary(roomData.Summary) - s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceJoin|mautrix.EventSourceState) - s.processSyncEvents(room, roomData.Timeline.Events, mautrix.EventSourceJoin|mautrix.EventSourceTimeline) - s.processSyncEvents(room, roomData.Ephemeral.Events, mautrix.EventSourceJoin|mautrix.EventSourceEphemeral) - s.processSyncEvents(room, roomData.AccountData.Events, mautrix.EventSourceJoin|mautrix.EventSourceAccountData) - - if len(room.PrevBatch) == 0 { - room.PrevBatch = roomData.Timeline.PrevBatch - } - room.LastPrevBatch = roomData.Timeline.PrevBatch - callback() -} - -func (s *GomuksSyncer) processInvitedRoom(roomID id.RoomID, roomData mautrix.SyncInvitedRoom, callback func()) { - defer debug.Recover() - room := s.rooms.GetOrCreate(roomID) - room.UpdateSummary(roomData.Summary) - s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceInvite|mautrix.EventSourceState) - callback() -} - -func (s *GomuksSyncer) processLeftRoom(roomID id.RoomID, roomData mautrix.SyncLeftRoom, callback func()) { - defer debug.Recover() - room := s.rooms.GetOrCreate(roomID) - room.HasLeft = true - room.UpdateSummary(roomData.Summary) - s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceLeave|mautrix.EventSourceState) - s.processSyncEvents(room, roomData.Timeline.Events, mautrix.EventSourceLeave|mautrix.EventSourceTimeline) - - if len(room.PrevBatch) == 0 { - room.PrevBatch = roomData.Timeline.PrevBatch - } - room.LastPrevBatch = roomData.Timeline.PrevBatch - callback() -} - -func (s *GomuksSyncer) processSyncEvents(room *rooms.Room, events []*event.Event, source mautrix.EventSource) { - for _, evt := range events { - s.processSyncEvent(room, evt, source) - } -} - -func (s *GomuksSyncer) processSyncEvent(room *rooms.Room, evt *event.Event, source mautrix.EventSource) { - if room != nil { - evt.RoomID = room.ID - } - // Ensure the type class is correct. It's safe to mutate since it's not a pointer. - // Listeners are keyed by type structs, which means only the correct class will pass. - switch { - case evt.StateKey != nil: - evt.Type.Class = event.StateEventType - case source == mautrix.EventSourcePresence, source&mautrix.EventSourceEphemeral != 0: - evt.Type.Class = event.EphemeralEventType - case source&mautrix.EventSourceAccountData != 0: - evt.Type.Class = event.AccountDataEventType - case source == mautrix.EventSourceToDevice: - evt.Type.Class = event.ToDeviceEventType - default: - evt.Type.Class = event.MessageEventType - } - - err := evt.Content.ParseRaw(evt.Type) - if err != nil { - debug.Printf("Failed to unmarshal content of event %s (type %s) by %s in %s: %v\n%s", evt.ID, evt.Type.Repr(), evt.Sender, evt.RoomID, err, string(evt.Content.VeryRaw)) - // TODO might be good to let these pass to allow handling invalid events too - return - } - - if room != nil && evt.Type.IsState() { - room.UpdateState(evt) - } - s.notifyListeners(source, evt) -} - -// OnEventType allows callers to be notified when there are new events for the given event type. -// There are no duplicate checks. -func (s *GomuksSyncer) OnEventType(eventType event.Type, callback mautrix.EventHandler) { - _, exists := s.listeners[eventType] - if !exists { - s.listeners[eventType] = []mautrix.EventHandler{} - } - s.listeners[eventType] = append(s.listeners[eventType], callback) -} - -func (s *GomuksSyncer) OnSync(callback mautrix.SyncHandler) { - s.globalListeners = append(s.globalListeners, callback) -} - -func (s *GomuksSyncer) notifyListeners(source mautrix.EventSource, evt *event.Event) { - listeners, exists := s.listeners[evt.Type] - if !exists { - return - } - for _, fn := range listeners { - fn(source, evt) - } -} - -// OnFailedSync always returns a 10 second wait period between failed /syncs, never a fatal error. -func (s *GomuksSyncer) OnFailedSync(res *mautrix.RespSync, err error) (time.Duration, error) { - debug.Printf("Sync failed: %v", err) - return 10 * time.Second, nil -} - -// GetFilterJSON returns a filter with a timeline limit of 50. -func (s *GomuksSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter { - stateEvents := []event.Type{ - event.StateMember, - event.StateRoomName, - event.StateTopic, - event.StateCanonicalAlias, - event.StatePowerLevels, - event.StateTombstone, - event.StateEncryption, - } - messageEvents := []event.Type{ - event.EventMessage, - event.EventRedaction, - event.EventEncrypted, - event.EventSticker, - event.EventReaction, - } - return &mautrix.Filter{ - Room: mautrix.RoomFilter{ - IncludeLeave: false, - State: mautrix.FilterPart{ - LazyLoadMembers: true, - Types: stateEvents, - }, - Timeline: mautrix.FilterPart{ - LazyLoadMembers: true, - Types: append(messageEvents, stateEvents...), - Limit: 50, - }, - Ephemeral: mautrix.FilterPart{ - Types: []event.Type{event.EphemeralEventTyping, event.EphemeralEventReceipt}, - }, - AccountData: mautrix.FilterPart{ - Types: []event.Type{event.AccountDataRoomTags}, - }, - }, - AccountData: mautrix.FilterPart{ - Types: []event.Type{event.AccountDataPushRules, event.AccountDataDirectChats, AccountDataGomuksPreferences}, - }, - Presence: mautrix.FilterPart{ - NotTypes: []event.Type{event.NewEventType("*")}, - }, - } -} diff --git a/matrix/uia-fallback.go b/matrix/uia-fallback.go deleted file mode 100644 index f958ba1..0000000 --- a/matrix/uia-fallback.go +++ /dev/null @@ -1,115 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package matrix - -import ( - "context" - "errors" - "net/http" - "net/url" - "time" - - "maunium.net/go/mautrix" - - "maunium.net/go/gomuks/debug" - "maunium.net/go/gomuks/lib/open" -) - -const uiaFallbackPage = ` - - - gomuks user-interactive auth - - - - -

Please complete the login in the popup window

-

Keep this page open while logging in, it will close automatically after the login finishes.

- - - - - -` - -func (c *Container) UIAFallback(loginType mautrix.AuthType, sessionID string) error { - errChan := make(chan error, 1) - server := &http.Server{Addr: ":29325"} - server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" { - w.Header().Add("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(uiaFallbackPage)) - } else if r.Method == "POST" || r.Method == "DELETE" { - w.Header().Add("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - err := server.Shutdown(ctx) - if err != nil { - debug.Printf("Failed to shut down SSO server: %v\n", err) - } - if r.Method == "DELETE" { - errChan <- errors.New("login cancelled") - } else { - errChan <- nil - } - }() - } else { - w.WriteHeader(http.StatusMethodNotAllowed) - } - }) - go server.ListenAndServe() - defer server.Close() - authURL := c.client.BuildURLWithQuery(mautrix.ClientURLPath{"v3", "auth", loginType, "fallback", "web"}, map[string]string{ - "session": sessionID, - }) - link := url.URL{ - Scheme: "http", - Host: "localhost:29325", - Path: "/", - Fragment: authURL, - } - err := open.Open(link.String()) - if err != nil { - return err - } - err = <-errChan - return err -} diff --git a/ui/autocomplete.go b/ui/autocomplete.go deleted file mode 100644 index 5cacace..0000000 --- a/ui/autocomplete.go +++ /dev/null @@ -1,88 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "fmt" - "io/ioutil" - "path/filepath" - "strings" -) - -func autocompleteFile(cmd *CommandAutocomplete) (completions []string, newText string) { - inputPath, err := filepath.Abs(cmd.RawArgs) - if err != nil { - return - } - - var searchNamePrefix, searchDir string - if strings.HasSuffix(cmd.RawArgs, "/") { - searchDir = inputPath - } else { - searchNamePrefix = filepath.Base(inputPath) - searchDir = filepath.Dir(inputPath) - } - files, err := ioutil.ReadDir(searchDir) - if err != nil { - return - } - for _, file := range files { - name := file.Name() - if !strings.HasPrefix(name, searchNamePrefix) || (name[0] == '.' && searchNamePrefix == "") { - continue - } - fullPath := filepath.Join(searchDir, name) - if file.IsDir() { - fullPath += "/" - } - completions = append(completions, fullPath) - } - if len(completions) == 1 { - newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0]) - } - return -} - -func autocompleteToggle(cmd *CommandAutocomplete) (completions []string, newText string) { - completions = make([]string, 0, len(toggleMsg)) - for k := range toggleMsg { - if strings.HasPrefix(k, cmd.RawArgs) { - completions = append(completions, k) - } - } - if len(completions) == 1 { - newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0]) - } - return -} - -var staticPowerLevelKeys = []string{"ban", "kick", "redact", "invite", "state_default", "events_default", "users_default"} - -func autocompletePowerLevel(cmd *CommandAutocomplete) (completions []string, newText string) { - if len(cmd.Args) > 1 { - return - } - for _, staticKey := range staticPowerLevelKeys { - if strings.HasPrefix(staticKey, cmd.RawArgs) { - completions = append(completions, staticKey) - } - } - for _, cpl := range cmd.Room.AutocompleteUser(cmd.RawArgs) { - completions = append(completions, cpl.id) - } - return -} diff --git a/ui/command-processor.go b/ui/command-processor.go deleted file mode 100644 index f0591f7..0000000 --- a/ui/command-processor.go +++ /dev/null @@ -1,290 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "fmt" - "strings" - - "github.com/mattn/go-runewidth" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" -) - -type gomuksPointerContainer struct { - MainView *MainView - UI *GomuksUI - Matrix ifc.MatrixContainer - Config *config.Config - Gomuks ifc.Gomuks -} - -type Command struct { - gomuksPointerContainer - Handler *CommandProcessor - - Room *RoomView - Command string - OrigCommand string - Args []string - RawArgs string - OrigText string -} - -type CommandAutocomplete Command - -func (cmd *Command) Reply(message string, args ...interface{}) { - if len(args) > 0 { - message = fmt.Sprintf(message, args...) - } - cmd.Room.AddServiceMessage(message) - cmd.UI.Render() -} - -type Alias struct { - NewCommand string -} - -func (alias *Alias) Process(cmd *Command) *Command { - cmd.Command = alias.NewCommand - return cmd -} - -type CommandHandler func(cmd *Command) -type CommandAutocompleter func(cmd *CommandAutocomplete) (completions []string, newText string) - -type CommandProcessor struct { - gomuksPointerContainer - - aliases map[string]*Alias - commands map[string]CommandHandler - - autocompleters map[string]CommandAutocompleter -} - -func NewCommandProcessor(parent *MainView) *CommandProcessor { - return &CommandProcessor{ - gomuksPointerContainer: gomuksPointerContainer{ - MainView: parent, - UI: parent.parent, - Matrix: parent.matrix, - Config: parent.config, - Gomuks: parent.gmx, - }, - aliases: map[string]*Alias{ - "part": {"leave"}, - "send": {"sendevent"}, - "msend": {"msendevent"}, - "state": {"setstate"}, - "mstate": {"msetstate"}, - "rb": {"rainbow"}, - "rbme": {"rainbowme"}, - "rbn": {"rainbownotice"}, - "myroomnick": {"roomnick"}, - "createroom": {"create"}, - "dm": {"pm"}, - "query": {"pm"}, - "r": {"reply"}, - "delete": {"redact"}, - "remove": {"redact"}, - "rm": {"redact"}, - "del": {"redact"}, - "e": {"edit"}, - "dl": {"download"}, - "o": {"open"}, - "4s": {"ssss"}, - "s4": {"ssss"}, - "cs": {"cross-signing"}, - "power": {"powerlevel"}, - "pl": {"powerlevel"}, - }, - autocompleters: map[string]CommandAutocompleter{ - "devices": autocompleteUser, - "device": autocompleteDevice, - "verify": autocompleteUser, - "verify-device": autocompleteDevice, - "unverify": autocompleteDevice, - "blacklist": autocompleteDevice, - "upload": autocompleteFile, - "download": autocompleteFile, - "open": autocompleteFile, - "import": autocompleteFile, - "export": autocompleteFile, - "export-room": autocompleteFile, - "toggle": autocompleteToggle, - "powerlevel": autocompletePowerLevel, - }, - commands: map[string]CommandHandler{ - "unknown-command": cmdUnknownCommand, - - "id": cmdID, - "help": cmdHelp, - "me": cmdMe, - "quit": cmdQuit, - "clearcache": cmdClearCache, - "leave": cmdLeave, - "create": cmdCreateRoom, - "pm": cmdPrivateMessage, - "join": cmdJoin, - "kick": cmdKick, - "ban": cmdBan, - "unban": cmdUnban, - "powerlevel": cmdPowerLevel, - "toggle": cmdToggle, - "logout": cmdLogout, - "accept": cmdAccept, - "reject": cmdReject, - "reply": cmdReply, - "redact": cmdRedact, - "react": cmdReact, - "edit": cmdEdit, - "external": cmdExternalEditor, - "download": cmdDownload, - "upload": cmdUpload, - "open": cmdOpen, - "copy": cmdCopy, - "sendevent": cmdSendEvent, - "msendevent": cmdMSendEvent, - "setstate": cmdSetState, - "msetstate": cmdMSetState, - "roomnick": cmdRoomNick, - "rainbow": cmdRainbow, - "rainbowme": cmdRainbowMe, - "notice": cmdNotice, - "alias": cmdAlias, - "tags": cmdTags, - "tag": cmdTag, - "untag": cmdUntag, - "invite": cmdInvite, - "hprof": cmdHeapProfile, - "cprof": cmdCPUProfile, - "trace": cmdTrace, - "panic": func(cmd *Command) { - panic("hello world") - }, - - "rainbownotice": cmdRainbowNotice, - - "fingerprint": cmdFingerprint, - "devices": cmdDevices, - "verify-device": cmdVerifyDevice, - "verify": cmdVerify, - "device": cmdDevice, - "unverify": cmdUnverify, - "blacklist": cmdBlacklist, - "reset-session": cmdResetSession, - "import": cmdImportKeys, - "export": cmdExportKeys, - "export-room": cmdExportRoomKeys, - "ssss": cmdSSSS, - "cross-signing": cmdCrossSigning, - }, - } -} - -func (ch *CommandProcessor) ParseCommand(roomView *RoomView, text string) *Command { - if text[0] != '/' || len(text) < 2 { - return nil - } - text = text[1:] - split := strings.Fields(text) - command := split[0] - args := split[1:] - var rawArgs string - if len(text) > len(command)+1 { - rawArgs = text[len(command)+1:] - } - return &Command{ - gomuksPointerContainer: ch.gomuksPointerContainer, - Handler: ch, - - Room: roomView, - Command: strings.ToLower(command), - OrigCommand: command, - Args: args, - RawArgs: rawArgs, - OrigText: text, - } -} - -func (ch *CommandProcessor) Autocomplete(roomView *RoomView, text string, cursorOffset int) ([]string, string, bool) { - var completions []string - if cursorOffset != runewidth.StringWidth(text) { - return completions, text, false - } - - var cmd *Command - if cmd = ch.ParseCommand(roomView, text); cmd == nil { - return completions, text, false - } else if alias, ok := ch.aliases[cmd.Command]; ok { - cmd = alias.Process(cmd) - } - - handler, ok := ch.autocompleters[cmd.Command] - if ok { - var newText string - completions, newText = handler((*CommandAutocomplete)(cmd)) - if newText != "" { - text = newText - } - } - return completions, text, ok -} - -func (ch *CommandProcessor) AutocompleteCommand(word string) (completions []string) { - if word[0] != '/' { - return - } - word = word[1:] - for alias := range ch.aliases { - if alias == word { - return []string{"/" + alias} - } - if strings.HasPrefix(alias, word) { - completions = append(completions, "/"+alias) - } - } - for command := range ch.commands { - if command == word { - return []string{"/" + command} - } - if strings.HasPrefix(command, word) { - completions = append(completions, "/"+command) - } - } - return -} - -func (ch *CommandProcessor) HandleCommand(cmd *Command) { - defer debug.Recover() - if cmd == nil { - return - } - if alias, ok := ch.aliases[cmd.Command]; ok { - cmd = alias.Process(cmd) - } - if cmd == nil { - return - } - if handler, ok := ch.commands[cmd.Command]; ok { - handler(cmd) - return - } - cmdUnknownCommand(cmd) -} diff --git a/ui/commands.go b/ui/commands.go deleted file mode 100644 index 37eb4e3..0000000 --- a/ui/commands.go +++ /dev/null @@ -1,1046 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "math" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - dbg "runtime/debug" - "runtime/pprof" - "runtime/trace" - "strconv" - "strings" - "time" - "unicode" - - "github.com/lucasb-eyer/go-colorful" - "github.com/yuin/goldmark" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/debug" - "maunium.net/go/gomuks/lib/filepicker" -) - -func cmdMe(cmd *Command) { - text := strings.Join(cmd.Args, " ") - go cmd.Room.SendMessage(event.MsgEmote, text) -} - -// GradientTable from https://github.com/lucasb-eyer/go-colorful/blob/master/doc/gradientgen/gradientgen.go -type GradientTable []struct { - Col colorful.Color - Pos float64 -} - -func (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color { - for i := 0; i < len(gt)-1; i++ { - c1 := gt[i] - c2 := gt[i+1] - if c1.Pos <= t && t <= c2.Pos { - t := (t - c1.Pos) / (c2.Pos - c1.Pos) - return c1.Col.BlendHcl(c2.Col, t).Clamped() - } - } - return gt[len(gt)-1].Col -} - -var rainbow = GradientTable{ - {colorful.LinearRgb(1, 0, 0), 0 / 11.0}, - {colorful.LinearRgb(1, 0.5, 0), 1 / 11.0}, - {colorful.LinearRgb(1, 1, 0), 2 / 11.0}, - {colorful.LinearRgb(0.5, 1, 0), 3 / 11.0}, - {colorful.LinearRgb(0, 1, 0), 4 / 11.0}, - {colorful.LinearRgb(0, 1, 0.5), 5 / 11.0}, - {colorful.LinearRgb(0, 1, 1), 6 / 11.0}, - {colorful.LinearRgb(0, 0.5, 1), 7 / 11.0}, - {colorful.LinearRgb(0, 0, 1), 8 / 11.0}, - {colorful.LinearRgb(0.5, 0, 1), 9 / 11.0}, - {colorful.LinearRgb(1, 0, 1), 10 / 11.0}, - {colorful.LinearRgb(1, 0, 0.5), 11 / 11.0}, -} - -var rainbowMark = goldmark.New(format.Extensions, format.HTMLOptions, goldmark.WithExtensions(ExtensionRainbow)) - -// TODO this command definitely belongs in a plugin once we have a plugin system. -func makeRainbow(cmd *Command, msgtype event.MessageType) { - text := strings.Join(cmd.Args, " ") - - var buf strings.Builder - _ = rainbowMark.Convert([]byte(text), &buf) - - htmlBody := strings.TrimRight(buf.String(), "\n") - htmlBody = format.AntiParagraphRegex.ReplaceAllString(htmlBody, "$1") - text = format.HTMLToText(htmlBody) - - count := strings.Count(htmlBody, defaultRB.ColorID) - i := -1 - htmlBody = regexp.MustCompile(defaultRB.ColorID).ReplaceAllStringFunc(htmlBody, func(match string) string { - i++ - return rainbow.GetInterpolatedColorFor(float64(i) / float64(count)).Hex() - }) - - go cmd.Room.SendMessageHTML(msgtype, text, htmlBody) -} - -func cmdRainbow(cmd *Command) { - makeRainbow(cmd, event.MsgText) -} - -func cmdRainbowMe(cmd *Command) { - makeRainbow(cmd, event.MsgEmote) -} - -func cmdRainbowNotice(cmd *Command) { - makeRainbow(cmd, event.MsgNotice) -} - -func cmdNotice(cmd *Command) { - go cmd.Room.SendMessage(event.MsgNotice, strings.Join(cmd.Args, " ")) -} - -func cmdAccept(cmd *Command) { - room := cmd.Room.MxRoom() - if room.SessionMember.Membership != "invite" { - cmd.Reply("/accept can only be used in rooms you're invited to") - return - } - _, server, _ := room.SessionMember.Sender.Parse() - _, err := cmd.Matrix.JoinRoom(room.ID, server) - if err != nil { - cmd.Reply("Failed to accept invite: %v", err) - } else { - cmd.Reply("Successfully accepted invite") - } - cmd.MainView.UpdateTags(room) - go cmd.MainView.LoadHistory(room.ID) -} - -func cmdReject(cmd *Command) { - room := cmd.Room.MxRoom() - if room.SessionMember.Membership != "invite" { - cmd.Reply("/reject can only be used in rooms you're invited to") - return - } - err := cmd.Matrix.LeaveRoom(room.ID) - if err != nil { - cmd.Reply("Failed to reject invite: %v", err) - } else { - cmd.Reply("Successfully rejected invite") - } - cmd.MainView.RemoveRoom(room) -} - -func cmdID(cmd *Command) { - cmd.Reply("The internal ID of this room is %s", cmd.Room.MxRoom().ID) -} - -type SelectReason string - -const ( - SelectReply SelectReason = "reply to" - SelectReact = "react to" - SelectRedact = "redact" - SelectEdit = "edit" - SelectDownload = "download" - SelectOpen = "open" - SelectCopy = "copy" -) - -func cmdReply(cmd *Command) { - cmd.Room.StartSelecting(SelectReply, strings.Join(cmd.Args, " ")) -} - -func cmdEdit(cmd *Command) { - cmd.Room.StartSelecting(SelectEdit, "") -} - -func findEditorExecutable() (string, string, error) { - if editor := os.Getenv("VISUAL"); len(editor) > 0 { - if path, err := exec.LookPath(editor); err != nil { - return "", "", fmt.Errorf("$VISUAL ('%s') not found in $PATH", editor) - } else { - return editor, path, nil - } - } else if editor = os.Getenv("EDITOR"); len(editor) > 0 { - if path, err := exec.LookPath(editor); err != nil { - return "", "", fmt.Errorf("$EDITOR ('%s') not found in $PATH", editor) - } else { - return editor, path, nil - } - } else if path, _ := exec.LookPath("nano"); len(path) > 0 { - return "nano", path, nil - } else if path, _ = exec.LookPath("vi"); len(path) > 0 { - return "vi", path, nil - } else { - return "", "", fmt.Errorf("$VISUAL and $EDITOR not set, nano and vi not found in $PATH") - } -} - -func cmdExternalEditor(cmd *Command) { - var file *os.File - defer func() { - if file != nil { - _ = file.Close() - _ = os.Remove(file.Name()) - } - }() - - fileExtension := "md" - if cmd.Config.Preferences.DisableMarkdown { - if cmd.Config.Preferences.DisableHTML { - fileExtension = "txt" - } else { - fileExtension = "html" - } - } - - if editorName, executablePath, err := findEditorExecutable(); err != nil { - cmd.Reply("Couldn't find editor to use: %v", err) - return - } else if file, err = os.CreateTemp("", fmt.Sprintf("gomuks-draft-*.%s", fileExtension)); err != nil { - cmd.Reply("Failed to create temp file: %v", err) - return - } else if _, err = file.WriteString(cmd.RawArgs); err != nil { - cmd.Reply("Failed to write to temp file: %v", err) - } else if err = file.Close(); err != nil { - cmd.Reply("Failed to close temp file: %v", err) - } else if err = cmd.UI.RunExternal(executablePath, file.Name()); err != nil { - var exitErr *exec.ExitError - if isExit := errors.As(err, &exitErr); isExit { - cmd.Reply("%s exited with non-zero status %d", editorName, exitErr.ExitCode()) - } else { - cmd.Reply("Failed to run %s: %v", editorName, err) - } - } else if data, err := os.ReadFile(file.Name()); err != nil { - cmd.Reply("Failed to read temp file: %v", err) - } else if len(bytes.TrimSpace(data)) > 0 { - cmd.Room.InputSubmit(string(data)) - } else { - cmd.Reply("Temp file was blank, sending cancelled") - if cmd.Room.editing != nil { - cmd.Room.SetEditing(nil) - } - } -} - -func cmdRedact(cmd *Command) { - cmd.Room.StartSelecting(SelectRedact, strings.Join(cmd.Args, " ")) -} - -func cmdDownload(cmd *Command) { - cmd.Room.StartSelecting(SelectDownload, strings.Join(cmd.Args, " ")) -} - -func cmdUpload(cmd *Command) { - var path string - var err error - if len(cmd.Args) == 0 { - if filepicker.IsSupported() { - path, err = filepicker.Open() - if err != nil { - cmd.Reply("Failed to open file picker: %v", err) - return - } else if len(path) == 0 { - cmd.Reply("File picking cancelled") - return - } - } else { - cmd.Reply("Usage: /upload ") - return - } - } else { - path, err = filepath.Abs(cmd.RawArgs) - if err != nil { - cmd.Reply("Failed to get absolute path: %v", err) - return - } - } - - go cmd.Room.SendMessageMedia(path) -} - -func cmdOpen(cmd *Command) { - cmd.Room.StartSelecting(SelectOpen, strings.Join(cmd.Args, " ")) -} - -func cmdCopy(cmd *Command) { - register := strings.Join(cmd.Args, " ") - if len(register) == 0 { - register = "clipboard" - } - if register == "clipboard" || register == "primary" { - cmd.Room.StartSelecting(SelectCopy, register) - } else { - cmd.Reply("Usage: /copy [register], where register is either \"clipboard\" or \"primary\". Defaults to \"clipboard\".") - } -} - -func cmdReact(cmd *Command) { - if len(cmd.Args) == 0 { - cmd.Reply("Usage: /react ") - return - } - - cmd.Room.StartSelecting(SelectReact, strings.Join(cmd.Args, " ")) -} - -func readRoomAlias(cmd *Command) (alias id.RoomAlias, err error) { - param := strings.Join(cmd.Args[1:], " ") - if strings.ContainsRune(param, ':') { - if param[0] != '#' { - return "", errors.New("full aliases must start with #") - } - - alias = id.RoomAlias(param) - } else { - _, homeserver, _ := cmd.Matrix.Client().UserID.Parse() - alias = id.NewRoomAlias(param, homeserver) - } - return -} - -func cmdAlias(cmd *Command) { - if len(cmd.Args) < 2 { - cmd.Reply("Usage: /alias ") - return - } - - alias, err := readRoomAlias(cmd) - if err != nil { - cmd.Reply(err.Error()) - return - } - - subcmd := strings.ToLower(cmd.Args[0]) - switch subcmd { - case "add", "create": - cmdAddAlias(cmd, alias) - case "remove", "delete", "del", "rm": - cmdRemoveAlias(cmd, alias) - case "resolve", "get": - cmdResolveAlias(cmd, alias) - default: - cmd.Reply("Usage: /alias ") - } -} - -func niceError(err error) string { - httpErr, ok := err.(mautrix.HTTPError) - if ok && httpErr.RespError != nil { - return httpErr.RespError.Error() - } - return err.Error() -} - -func cmdAddAlias(cmd *Command, alias id.RoomAlias) { - _, err := cmd.Matrix.Client().CreateAlias(alias, cmd.Room.MxRoom().ID) - if err != nil { - cmd.Reply("Failed to create alias: %v", niceError(err)) - } else { - cmd.Reply("Created alias %s", alias) - } -} - -func cmdRemoveAlias(cmd *Command, alias id.RoomAlias) { - _, err := cmd.Matrix.Client().DeleteAlias(alias) - if err != nil { - cmd.Reply("Failed to delete alias: %v", niceError(err)) - } else { - cmd.Reply("Deleted alias %s", alias) - } -} - -func cmdResolveAlias(cmd *Command, alias id.RoomAlias) { - resp, err := cmd.Matrix.Client().ResolveAlias(alias) - if err != nil { - cmd.Reply("Failed to resolve alias: %v", niceError(err)) - } else { - roomIDText := string(resp.RoomID) - if resp.RoomID == cmd.Room.MxRoom().ID { - roomIDText += " (this room)" - } - cmd.Reply("Alias %s points to room %s\nThere are %d servers in the room.", alias, roomIDText, len(resp.Servers)) - } -} - -func cmdTags(cmd *Command) { - tags := cmd.Room.MxRoom().RawTags - if len(cmd.Args) > 0 && cmd.Args[0] == "--internal" { - tags = cmd.Room.MxRoom().Tags() - } - if len(tags) == 0 { - if cmd.Room.MxRoom().IsDirect { - cmd.Reply("This room has no tags, but it's marked as a direct chat.") - } else { - cmd.Reply("This room has no tags.") - } - return - } - var resp strings.Builder - resp.WriteString("Tags in this room:\n") - for _, tag := range tags { - if tag.Order != "" { - _, _ = fmt.Fprintf(&resp, "%s (order: %s)\n", tag.Tag, tag.Order) - } else { - _, _ = fmt.Fprintf(&resp, "%s (no order)\n", tag.Tag) - } - } - cmd.Reply(strings.TrimSpace(resp.String())) -} - -func cmdTag(cmd *Command) { - if len(cmd.Args) == 0 { - cmd.Reply("Usage: /tag [order]") - return - } - order := math.NaN() - if len(cmd.Args) > 1 { - var err error - order, err = strconv.ParseFloat(cmd.Args[1], 64) - if err != nil { - cmd.Reply("%s is not a valid order: %v", cmd.Args[1], err) - return - } - } - var err error - if len(cmd.Args) > 2 && cmd.Args[2] == "--reset" { - tags := event.Tags{ - cmd.Args[0]: {Order: json.Number(fmt.Sprintf("%f", order))}, - } - for _, tag := range cmd.Room.MxRoom().RawTags { - tags[tag.Tag] = event.Tag{Order: tag.Order} - } - err = cmd.Matrix.Client().SetTags(cmd.Room.MxRoom().ID, tags) - } else { - err = cmd.Matrix.Client().AddTag(cmd.Room.MxRoom().ID, cmd.Args[0], order) - } - if err != nil { - cmd.Reply("Failed to add tag: %v", err) - } -} - -func cmdUntag(cmd *Command) { - if len(cmd.Args) == 0 { - cmd.Reply("Usage: /untag ") - return - } - err := cmd.Matrix.Client().RemoveTag(cmd.Room.MxRoom().ID, cmd.Args[0]) - if err != nil { - cmd.Reply("Failed to remove tag: %v", err) - } -} - -func cmdRoomNick(cmd *Command) { - room := cmd.Room.MxRoom() - member := room.GetMember(room.SessionUserID) - member.Displayname = strings.Join(cmd.Args, " ") - _, err := cmd.Matrix.Client().SendStateEvent(room.ID, event.StateMember, string(room.SessionUserID), member) - if err != nil { - cmd.Reply("Failed to set room nick: %v", err) - } -} - -func cmdFingerprint(cmd *Command) { - c := cmd.Matrix.Crypto() - if c == nil { - cmd.Reply("Encryption support is not enabled") - } else { - cmd.Reply("Device ID: %s\nFingerprint: %s", cmd.Matrix.Client().DeviceID, c.Fingerprint()) - } -} - -func cmdHeapProfile(cmd *Command) { - if len(cmd.Args) == 0 || cmd.Args[0] != "nogc" { - runtime.GC() - dbg.FreeOSMemory() - } - memProfile, err := os.Create("gomuks.heap.prof") - if err != nil { - debug.Print("Failed to open gomuks.heap.prof:", err) - return - } - defer func() { - err := memProfile.Close() - if err != nil { - debug.Print("Failed to close gomuks.heap.prof:", err) - } - }() - if err := pprof.WriteHeapProfile(memProfile); err != nil { - debug.Print("Heap profile error:", err) - } -} - -func runTimedProfile(cmd *Command, start func(writer io.Writer) error, stop func(), task, file string) { - if len(cmd.Args) == 0 { - cmd.Reply("Usage: /%s ", cmd.Command) - } else if dur, err := strconv.Atoi(cmd.Args[0]); err != nil || dur < 0 { - cmd.Reply("Usage: /%s ", cmd.Command) - } else if cpuProfile, err := os.Create(file); err != nil { - debug.Printf("Failed to open %s: %v", file, err) - } else if err = start(cpuProfile); err != nil { - _ = cpuProfile.Close() - debug.Print(task, "error:", err) - } else { - cmd.Reply("Started %s for %d seconds", task, dur) - go func() { - time.Sleep(time.Duration(dur) * time.Second) - stop() - cmd.Reply("%s finished.", task) - - err := cpuProfile.Close() - if err != nil { - debug.Print("Failed to close gomuks.cpu.prof:", err) - } - }() - } -} - -func cmdCPUProfile(cmd *Command) { - runTimedProfile(cmd, pprof.StartCPUProfile, pprof.StopCPUProfile, "CPU profiling", "gomuks.cpu.prof") -} - -func cmdTrace(cmd *Command) { - runTimedProfile(cmd, trace.Start, trace.Stop, "Call tracing", "gomuks.trace") -} - -func cmdQuit(cmd *Command) { - cmd.Gomuks.Stop(true) -} - -func cmdClearCache(cmd *Command) { - cmd.Config.Clear() - cmd.Gomuks.Stop(false) -} - -func cmdUnknownCommand(cmd *Command) { - cmd.Reply(`Unknown command "/%s". Try "/help" for help.`, cmd.Command) -} - -func cmdHelp(cmd *Command) { - view := cmd.MainView - view.ShowModal(NewHelpModal(view)) -} - -func cmdLeave(cmd *Command) { - err := cmd.Matrix.LeaveRoom(cmd.Room.MxRoom().ID) - debug.Print("Leave room error:", err) - if err == nil { - cmd.MainView.RemoveRoom(cmd.Room.MxRoom()) - } -} - -func cmdInvite(cmd *Command) { - if len(cmd.Args) != 1 { - cmd.Reply("Usage: /invite ") - return - } - _, err := cmd.Matrix.Client().InviteUser(cmd.Room.MxRoom().ID, &mautrix.ReqInviteUser{UserID: id.UserID(cmd.Args[0])}) - if err != nil { - debug.Print("Error in invite call:", err) - cmd.Reply("Failed to invite user: %v", err) - } -} - -func cmdBan(cmd *Command) { - if len(cmd.Args) < 1 { - cmd.Reply("Usage: /ban [reason]") - return - } - reason := "you are the weakest link, goodbye!" - if len(cmd.Args) >= 2 { - reason = strings.Join(cmd.Args[1:], " ") - } - _, err := cmd.Matrix.Client().BanUser(cmd.Room.MxRoom().ID, &mautrix.ReqBanUser{Reason: reason, UserID: id.UserID(cmd.Args[0])}) - if err != nil { - debug.Print("Error in ban call:", err) - cmd.Reply("Failed to ban user: %v", err) - } - -} - -func cmdUnban(cmd *Command) { - if len(cmd.Args) != 1 { - cmd.Reply("Usage: /unban ") - return - } - _, err := cmd.Matrix.Client().UnbanUser(cmd.Room.MxRoom().ID, &mautrix.ReqUnbanUser{UserID: id.UserID(cmd.Args[0])}) - if err != nil { - debug.Print("Error in unban call:", err) - cmd.Reply("Failed to unban user: %v", err) - } -} - -func cmdKick(cmd *Command) { - if len(cmd.Args) < 1 { - cmd.Reply("Usage: /kick [reason]") - return - } - reason := "you are the weakest link, goodbye!" - if len(cmd.Args) >= 2 { - reason = strings.Join(cmd.Args[1:], " ") - } - _, err := cmd.Matrix.Client().KickUser(cmd.Room.MxRoom().ID, &mautrix.ReqKickUser{Reason: reason, UserID: id.UserID(cmd.Args[0])}) - if err != nil { - debug.Print("Error in kick call:", err) - debug.Print("Failed to kick user:", err) - } -} - -func formatPowerLevels(pl *event.PowerLevelsEventContent) string { - var buf strings.Builder - buf.WriteString("Membership actions:\n") - _, _ = fmt.Fprintf(&buf, " Invite: %d\n", pl.Invite()) - _, _ = fmt.Fprintf(&buf, " Kick: %d\n", pl.Kick()) - _, _ = fmt.Fprintf(&buf, " Ban: %d\n", pl.Ban()) - buf.WriteString("Events:\n") - _, _ = fmt.Fprintf(&buf, " Redact: %d\n", pl.Redact()) - _, _ = fmt.Fprintf(&buf, " State default: %d\n", pl.StateDefault()) - _, _ = fmt.Fprintf(&buf, " Event default: %d\n", pl.EventsDefault) - for evtType, level := range pl.Events { - _, _ = fmt.Fprintf(&buf, " %s: %d\n", evtType, level) - } - buf.WriteString("Users:\n") - _, _ = fmt.Fprintf(&buf, " Default: %d\n", pl.UsersDefault) - for userID, level := range pl.Users { - _, _ = fmt.Fprintf(&buf, " %s: %d\n", userID, level) - } - return strings.TrimSpace(buf.String()) -} - -func copyPtr(ptr *int) *int { - if ptr == nil { - return nil - } - val := *ptr - return &val -} - -func copyMap[Key comparable](m map[Key]int) map[Key]int { - if m == nil { - return nil - } - copied := make(map[Key]int, len(m)) - for k, v := range m { - copied[k] = v - } - return copied -} - -func copyPowerLevels(pl *event.PowerLevelsEventContent) *event.PowerLevelsEventContent { - return &event.PowerLevelsEventContent{ - Users: copyMap(pl.Users), - Events: copyMap(pl.Events), - InvitePtr: copyPtr(pl.InvitePtr), - KickPtr: copyPtr(pl.KickPtr), - BanPtr: copyPtr(pl.BanPtr), - RedactPtr: copyPtr(pl.RedactPtr), - StateDefaultPtr: copyPtr(pl.StateDefaultPtr), - EventsDefault: pl.EventsDefault, - UsersDefault: pl.UsersDefault, - } -} - -var things = ` -[thing] can be one of the following - -Literals: -* invite, kick, ban, redact - special moderation action levels -* state_default, events_default - default level for state and non-state events -* users_default - default level for users - -Patterns: -* user ID - specific user level -* event type - specific event type level - -The default levels are 0 for users, 50 for moderators and 100 for admins.` - -func cmdPowerLevel(cmd *Command) { - evt := cmd.Room.MxRoom().GetStateEvent(event.StatePowerLevels, "") - pl := copyPowerLevels(evt.Content.AsPowerLevels()) - if len(cmd.Args) == 0 { - // TODO open in modal? - cmd.Reply(formatPowerLevels(pl)) - return - } else if len(cmd.Args) < 2 { - cmd.Reply("Usage: /%s [thing] [level]\n%s", cmd.Command, things) - return - } - - value, err := strconv.Atoi(cmd.Args[1]) - if err != nil { - cmd.Reply("Invalid power level %q: %v", cmd.Args[1], err) - return - } - - ownLevel := pl.GetUserLevel(cmd.Matrix.Client().UserID) - plChangeLevel := pl.GetEventLevel(event.StatePowerLevels) - if ownLevel < plChangeLevel { - cmd.Reply("Can't modify power levels (own level is %d, modifying requires %d)", ownLevel, plChangeLevel) - return - } else if value > ownLevel { - cmd.Reply("Can't set level to be higher than own level (%d > %d)", value, ownLevel) - return - } - - var oldValue int - var thing string - switch cmd.Args[0] { - case "invite": - oldValue = pl.Invite() - pl.InvitePtr = &value - thing = "invite level" - case "kick": - oldValue = pl.Kick() - pl.KickPtr = &value - thing = "kick level" - case "ban": - oldValue = pl.Ban() - pl.BanPtr = &value - thing = "ban level" - case "redact": - oldValue = pl.Redact() - pl.RedactPtr = &value - thing = "level for redacting other users' events" - case "state_default": - oldValue = pl.StateDefault() - pl.StateDefaultPtr = &value - thing = "default level for state events" - case "events_default": - oldValue = pl.EventsDefault - pl.EventsDefault = value - thing = "default level for normal events" - case "users_default": - oldValue = pl.UsersDefault - pl.UsersDefault = value - thing = "default level for users" - default: - userID := id.UserID(cmd.Args[0]) - if _, _, err = userID.Parse(); err == nil { - if pl.Users == nil { - pl.Users = make(map[id.UserID]int) - } - oldValue = pl.Users[userID] - if oldValue == ownLevel && userID != cmd.Matrix.Client().UserID { - cmd.Reply("Can't change level of another user which is equal to own level (%d)", ownLevel) - return - } - pl.Users[userID] = value - thing = fmt.Sprintf("level of user %s", userID) - } else { - if pl.Events == nil { - pl.Events = make(map[string]int) - } - oldValue = pl.Events[cmd.Args[0]] - pl.Events[cmd.Args[0]] = value - thing = fmt.Sprintf("level for event %s", cmd.Args[0]) - } - } - - if oldValue == value { - cmd.Reply("%s is already %d", strings.ToUpper(thing[0:1])+thing[1:], value) - } else if oldValue > ownLevel { - cmd.Reply("Can't change level which is higher than own level (%d > %d)", oldValue, ownLevel) - } else if resp, err := cmd.Matrix.Client().SendStateEvent(cmd.Room.MxRoom().ID, event.StatePowerLevels, "", pl); err != nil { - if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.RespError != nil { - err = httpErr.RespError - } - cmd.Reply("Failed to set %s to %d: %v", thing, value, err) - } else { - cmd.Reply("Successfully set %s to %d\n(event ID: %s)", thing, value, resp.EventID) - } -} - -func cmdCreateRoom(cmd *Command) { - req := &mautrix.ReqCreateRoom{} - if len(cmd.Args) > 0 { - req.Name = strings.Join(cmd.Args, " ") - } - room, err := cmd.Matrix.CreateRoom(req) - if err != nil { - cmd.Reply("Failed to create room: %v", err) - return - } - cmd.MainView.SwitchRoom("", room) -} - -func cmdPrivateMessage(cmd *Command) { - if len(cmd.Args) == 0 { - cmd.Reply("Usage: /pm [more user ids...]") - } - invites := make([]id.UserID, len(cmd.Args)) - for i, userID := range cmd.Args { - invites[i] = id.UserID(userID) - _, _, err := invites[i].Parse() - if err != nil { - cmd.Reply("%s isn't a valid user ID", userID) - return - } - } - req := &mautrix.ReqCreateRoom{ - Preset: "trusted_private_chat", - Invite: invites, - IsDirect: true, - } - room, err := cmd.Matrix.CreateRoom(req) - if err != nil { - cmd.Reply("Failed to create room: %v", err) - return - } - cmd.MainView.SwitchRoom("", room) -} - -func cmdJoin(cmd *Command) { - if len(cmd.Args) == 0 { - cmd.Reply("Usage: /join ") - return - } - identifer := id.RoomID(cmd.Args[0]) - server := "" - if len(cmd.Args) > 1 { - server = cmd.Args[1] - } - room, err := cmd.Matrix.JoinRoom(identifer, server) - debug.Print("Join room error:", err) - if err == nil { - cmd.MainView.AddRoom(room) - } -} - -func cmdMSendEvent(cmd *Command) { - if len(cmd.Args) < 2 { - cmd.Reply("Usage: /msend ") - return - } - cmd.Args = append([]string{string(cmd.Room.MxRoom().ID)}, cmd.Args...) - cmdSendEvent(cmd) -} - -func cmdSendEvent(cmd *Command) { - if len(cmd.Args) < 3 { - cmd.Reply("Usage: /send ") - return - } - roomID := id.RoomID(cmd.Args[0]) - eventType := event.NewEventType(cmd.Args[1]) - rawContent := strings.Join(cmd.Args[2:], " ") - - var content interface{} - err := json.Unmarshal([]byte(rawContent), &content) - debug.Print(err) - if err != nil { - cmd.Reply("Failed to parse content: %v", err) - return - } - debug.Print("Sending event to", roomID, eventType, content) - - resp, err := cmd.Matrix.Client().SendMessageEvent(roomID, eventType, content) - debug.Print(resp, err) - if err != nil { - cmd.Reply("Error from server: %v", err) - } else { - cmd.Reply("Event sent, ID: %s", resp.EventID) - } -} - -func cmdMSetState(cmd *Command) { - if len(cmd.Args) < 2 { - cmd.Reply("Usage: /msetstate ") - return - } - cmd.Args = append([]string{string(cmd.Room.MxRoom().ID)}, cmd.Args...) - cmdSetState(cmd) -} - -func cmdSetState(cmd *Command) { - if len(cmd.Args) < 4 { - cmd.Reply("Usage: /setstate ") - return - } - - roomID := id.RoomID(cmd.Args[0]) - eventType := event.NewEventType(cmd.Args[1]) - stateKey := cmd.Args[2] - if stateKey == "-" { - stateKey = "" - } - rawContent := strings.Join(cmd.Args[3:], " ") - - var content interface{} - err := json.Unmarshal([]byte(rawContent), &content) - if err != nil { - cmd.Reply("Failed to parse content: %v", err) - return - } - debug.Print("Sending state event to", roomID, eventType, stateKey, content) - resp, err := cmd.Matrix.Client().SendStateEvent(roomID, eventType, stateKey, content) - if err != nil { - cmd.Reply("Error from server: %v", err) - } else { - cmd.Reply("State event sent, ID: %s", resp.EventID) - } -} - -type ToggleMessage interface { - Name() string - Format(state bool) string -} - -type HideMessage string - -func (hm HideMessage) Format(state bool) string { - if state { - return string(hm) + " is now hidden" - } else { - return string(hm) + " is now visible" - } -} - -func (hm HideMessage) Name() string { - return string(hm) -} - -type SimpleToggleMessage string - -func (stm SimpleToggleMessage) Format(state bool) string { - if state { - return "Disabled " + string(stm) - } else { - return "Enabled " + string(stm) - } -} - -func (stm SimpleToggleMessage) Name() string { - return string(unicode.ToUpper(rune(stm[0]))) + string(stm[1:]) -} - -type InvertedToggleMessage string - -func (itm InvertedToggleMessage) Format(state bool) string { - if state { - return "Enabled " + string(itm) - } else { - return "Disabled " + string(itm) - } -} - -func (itm InvertedToggleMessage) Name() string { - return string(unicode.ToUpper(rune(itm[0]))) + string(itm[1:]) -} - -var toggleMsg = map[string]ToggleMessage{ - "rooms": HideMessage("Room list sidebar"), - "users": HideMessage("User list sidebar"), - "timestamps": HideMessage("message timestamps"), - "baremessages": InvertedToggleMessage("bare message view"), - "images": SimpleToggleMessage("image rendering"), - "typingnotif": SimpleToggleMessage("typing notifications"), - "emojis": SimpleToggleMessage("emoji shortcode conversion"), - "html": SimpleToggleMessage("HTML input"), - "markdown": SimpleToggleMessage("markdown input"), - "downloads": SimpleToggleMessage("automatic downloads"), - "notifications": SimpleToggleMessage("desktop notifications"), - "unverified": SimpleToggleMessage("sending messages to unverified devices"), - "showurls": SimpleToggleMessage("show URLs in text format"), - "inlineurls": InvertedToggleMessage("use fancy terminal features to render URLs inside text"), -} - -func makeUsage() string { - var buf strings.Builder - buf.WriteString("Usage: /toggle \n\n") - buf.WriteString("List of Things:\n") - for key, value := range toggleMsg { - _, _ = fmt.Fprintf(&buf, "* %s - %s\n", key, value.Name()) - } - return buf.String()[:buf.Len()-1] -} - -func cmdToggle(cmd *Command) { - if len(cmd.Args) == 0 { - cmd.Reply(makeUsage()) - return - } - for _, thing := range cmd.Args { - var val *bool - switch thing { - case "rooms": - val = &cmd.Config.Preferences.HideRoomList - case "users": - val = &cmd.Config.Preferences.HideUserList - case "timestamps": - val = &cmd.Config.Preferences.HideTimestamp - case "baremessages": - val = &cmd.Config.Preferences.BareMessageView - case "images": - val = &cmd.Config.Preferences.DisableImages - case "typingnotif": - val = &cmd.Config.Preferences.DisableTypingNotifs - case "emojis": - val = &cmd.Config.Preferences.DisableEmojis - case "html": - val = &cmd.Config.Preferences.DisableHTML - case "markdown": - val = &cmd.Config.Preferences.DisableMarkdown - case "downloads": - val = &cmd.Config.Preferences.DisableDownloads - case "notifications": - val = &cmd.Config.Preferences.DisableNotifications - case "unverified": - val = &cmd.Config.SendToVerifiedOnly - case "showurls": - val = &cmd.Config.Preferences.DisableShowURLs - case "inlineurls": - switch cmd.Config.Preferences.InlineURLMode { - case "enable": - cmd.Config.Preferences.InlineURLMode = "disable" - cmd.Reply("Force-disabled using fancy terminal features to render URLs inside text. Restart gomuks to apply changes.") - default: - cmd.Config.Preferences.InlineURLMode = "enable" - cmd.Reply("Force-enabled using fancy terminal features to render URLs inside text. Restart gomuks to apply changes.") - } - continue - default: - cmd.Reply("Unknown toggle %s. Use /toggle without arguments for a list of togglable things.", thing) - return - } - *val = !(*val) - debug.Print(thing, *val) - cmd.Reply(toggleMsg[thing].Format(*val)) - if thing == "rooms" { - // Update topic string to include or not include room name - cmd.Room.Update() - } - } - cmd.UI.Render() - go cmd.Matrix.SendPreferencesToMatrix() -} - -func cmdLogout(cmd *Command) { - cmd.Matrix.Logout() -} diff --git a/ui/crypto-commands.go b/ui/crypto-commands.go deleted file mode 100644 index 9722dcc..0000000 --- a/ui/crypto-commands.go +++ /dev/null @@ -1,698 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build cgo - -package ui - -import ( - "errors" - "fmt" - "io/ioutil" - "path/filepath" - "strings" - "time" - "unicode" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/crypto/ssss" - "maunium.net/go/mautrix/id" - - ifc "maunium.net/go/gomuks/interface" -) - -func autocompleteDeviceUserID(cmd *CommandAutocomplete) (completions []string, newText string) { - userCompletions := cmd.Room.AutocompleteUser(cmd.Args[0]) - if len(userCompletions) == 1 { - newText = fmt.Sprintf("/%s %s ", cmd.OrigCommand, userCompletions[0].id) - } else { - completions = make([]string, len(userCompletions)) - for i, completion := range userCompletions { - completions[i] = completion.id - } - } - return -} - -func autocompleteDeviceDeviceID(cmd *CommandAutocomplete) (completions []string, newText string) { - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - devices, err := mach.CryptoStore.GetDevices(id.UserID(cmd.Args[0])) - if len(devices) == 0 || err != nil { - return - } - var completedDeviceID id.DeviceID - if len(cmd.Args) > 1 { - existingID := strings.ToUpper(cmd.Args[1]) - for _, device := range devices { - deviceIDStr := string(device.DeviceID) - if deviceIDStr == existingID { - // We don't want to do any autocompletion if there's already a full device ID there. - return []string{}, "" - } else if strings.HasPrefix(strings.ToUpper(device.Name), existingID) || strings.HasPrefix(deviceIDStr, existingID) { - completedDeviceID = device.DeviceID - completions = append(completions, fmt.Sprintf("%s (%s)", device.DeviceID, device.Name)) - } - } - } else { - completions = make([]string, len(devices)) - i := 0 - for _, device := range devices { - completedDeviceID = device.DeviceID - completions[i] = fmt.Sprintf("%s (%s)", device.DeviceID, device.Name) - i++ - } - } - if len(completions) == 1 { - newText = fmt.Sprintf("/%s %s %s ", cmd.OrigCommand, cmd.Args[0], completedDeviceID) - } - return -} - -func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) { - if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) { - return autocompleteDeviceUserID(cmd) - } - return []string{}, "" -} - -func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) { - if len(cmd.Args) == 0 { - return []string{}, "" - } else if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) { - return autocompleteDeviceUserID(cmd) - } - return autocompleteDeviceDeviceID(cmd) -} - -func getDevice(cmd *Command) *crypto.DeviceIdentity { - if len(cmd.Args) < 2 { - cmd.Reply("Usage: /%s [fingerprint]", cmd.Command) - return nil - } - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - device, err := mach.GetOrFetchDevice(id.UserID(cmd.Args[0]), id.DeviceID(cmd.Args[1])) - if err != nil { - cmd.Reply("Failed to get device: %v", err) - return nil - } - return device -} - -func putDevice(cmd *Command, device *crypto.DeviceIdentity, action string) { - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - err := mach.CryptoStore.PutDevice(device.UserID, device) - if err != nil { - cmd.Reply("Failed to save device: %v", err) - } else { - cmd.Reply("Successfully %s %s/%s (%s)", action, device.UserID, device.DeviceID, device.Name) - } - mach.OnDevicesChanged(device.UserID) -} - -func cmdDevices(cmd *Command) { - if len(cmd.Args) == 0 { - cmd.Reply("Usage: /devices ") - return - } - userID := id.UserID(cmd.Args[0]) - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - devices, err := mach.CryptoStore.GetDevices(userID) - if err != nil { - cmd.Reply("Failed to get device list: %v", err) - } - if len(devices) == 0 { - cmd.Reply("Fetching device list from server...") - devices = mach.LoadDevices(userID) - } - if len(devices) == 0 { - cmd.Reply("No devices found for %s", userID) - return - } - var buf strings.Builder - for _, device := range devices { - trust := device.Trust.String() - if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) { - trust = "verified (transitive)" - } - _, _ = fmt.Fprintf(&buf, "%s (%s) - %s\n Fingerprint: %s\n", device.DeviceID, device.Name, trust, device.Fingerprint()) - } - resp := buf.String() - cmd.Reply("%s", resp[:len(resp)-1]) -} - -func cmdDevice(cmd *Command) { - device := getDevice(cmd) - if device == nil { - return - } - deviceType := "Device" - if device.Deleted { - deviceType = "Deleted device" - } - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - trustState := device.Trust.String() - if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) { - trustState = "verified (transitive)" - } - cmd.Reply("%s %s of %s\nFingerprint: %s\nIdentity key: %s\nDevice name: %s\nTrust state: %s", - deviceType, device.DeviceID, device.UserID, - device.Fingerprint(), device.IdentityKey, - device.Name, trustState) -} - -func crossSignDevice(cmd *Command, device *crypto.DeviceIdentity) { - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - err := mach.SignOwnDevice(device) - if err != nil { - cmd.Reply("Failed to upload cross-signing signature: %v", err) - } else { - cmd.Reply("Successfully cross-signed %s (%s)", device.DeviceID, device.Name) - } -} - -func cmdVerifyDevice(cmd *Command) { - device := getDevice(cmd) - if device == nil { - return - } - if device.Trust == crypto.TrustStateVerified { - cmd.Reply("That device is already verified") - return - } - if len(cmd.Args) == 2 { - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - mach.DefaultSASTimeout = 120 * time.Second - modal := NewVerificationModal(cmd.MainView, device, mach.DefaultSASTimeout) - cmd.MainView.ShowModal(modal) - _, err := mach.NewSimpleSASVerificationWith(device, modal) - if err != nil { - cmd.Reply("Failed to start interactive verification: %v", err) - return - } - } else { - fingerprint := strings.Join(cmd.Args[2:], "") - if string(device.SigningKey) != fingerprint { - cmd.Reply("Mismatching fingerprint") - return - } - action := "verified" - if device.Trust == crypto.TrustStateBlacklisted { - action = "unblacklisted and verified" - } - if device.UserID == cmd.Matrix.Client().UserID { - crossSignDevice(cmd, device) - device.Trust = crypto.TrustStateVerified - putDevice(cmd, device, action) - } else { - putDevice(cmd, device, action) - cmd.Reply("Warning: verifying individual devices of other users is not synced with cross-signing") - } - } -} - -func cmdVerify(cmd *Command) { - if len(cmd.Args) < 1 { - cmd.Reply("Usage: /%s [--force]", cmd.OrigCommand) - return - } - force := len(cmd.Args) >= 2 && strings.ToLower(cmd.Args[1]) == "--force" - userID := id.UserID(cmd.Args[0]) - room := cmd.Room.Room - if !room.Encrypted { - cmd.Reply("In-room verification is only supported in encrypted rooms") - return - } - if (!room.IsDirect || room.OtherUser != userID) && !force { - cmd.Reply("This doesn't seem to be a direct chat. Either switch to a direct chat with %s, "+ - "or use `--force` to start the verification anyway.", userID) - return - } - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - if mach.CrossSigningKeys == nil && !force { - cmd.Reply("Cross-signing private keys not cached. Generate or fetch cross-signing keys with `/cross-signing`, " + - "or use `--force` to start the verification anyway") - return - } - modal := NewVerificationModal(cmd.MainView, &crypto.DeviceIdentity{UserID: userID}, mach.DefaultSASTimeout) - _, err := mach.NewInRoomSASVerificationWith(cmd.Room.Room.ID, userID, modal, 120*time.Second) - if err != nil { - cmd.Reply("Failed to start in-room verification: %v", err) - return - } - cmd.MainView.ShowModal(modal) -} - -func cmdUnverify(cmd *Command) { - device := getDevice(cmd) - if device == nil { - return - } - if device.Trust == crypto.TrustStateUnset { - cmd.Reply("That device is already not verified") - return - } - action := "unverified" - if device.Trust == crypto.TrustStateBlacklisted { - action = "unblacklisted" - } - device.Trust = crypto.TrustStateUnset - putDevice(cmd, device, action) -} - -func cmdBlacklist(cmd *Command) { - device := getDevice(cmd) - if device == nil { - return - } - if device.Trust == crypto.TrustStateBlacklisted { - cmd.Reply("That device is already blacklisted") - return - } - action := "blacklisted" - if device.Trust == crypto.TrustStateVerified { - action = "unverified and blacklisted" - } - device.Trust = crypto.TrustStateBlacklisted - putDevice(cmd, device, action) -} - -func cmdResetSession(cmd *Command) { - err := cmd.Matrix.Crypto().(*crypto.OlmMachine).CryptoStore.RemoveOutboundGroupSession(cmd.Room.Room.ID) - if err != nil { - cmd.Reply("Failed to remove outbound group session: %v", err) - } else { - cmd.Reply("Removed outbound group session for this room") - } -} - -func cmdImportKeys(cmd *Command) { - path, err := filepath.Abs(cmd.RawArgs) - if err != nil { - cmd.Reply("Failed to get absolute path: %v", err) - return - } - data, err := ioutil.ReadFile(path) - if err != nil { - cmd.Reply("Failed to read %s: %v", path, err) - return - } - passphrase, ok := cmd.MainView.AskPassword("Key import", "passphrase", "", false) - if !ok { - cmd.Reply("Passphrase entry cancelled") - return - } - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - imported, total, err := mach.ImportKeys(passphrase, data) - if err != nil { - cmd.Reply("Failed to import sessions: %v", err) - } else { - cmd.Reply("Successfully imported %d/%d sessions", imported, total) - } -} - -func exportKeys(cmd *Command, sessions []*crypto.InboundGroupSession) { - path, err := filepath.Abs(cmd.RawArgs) - if err != nil { - cmd.Reply("Failed to get absolute path: %v", err) - return - } - passphrase, ok := cmd.MainView.AskPassword("Key export", "passphrase", "", true) - if !ok { - cmd.Reply("Passphrase entry cancelled") - return - } - export, err := crypto.ExportKeys(passphrase, sessions) - if err != nil { - cmd.Reply("Failed to export sessions: %v", err) - } - err = ioutil.WriteFile(path, export, 0400) - if err != nil { - cmd.Reply("Failed to write sessions to %s: %v", path, err) - } else { - cmd.Reply("Successfully exported %d sessions to %s", len(sessions), path) - } -} - -func cmdExportKeys(cmd *Command) { - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - sessions, err := mach.CryptoStore.GetAllGroupSessions() - if err != nil { - cmd.Reply("Failed to get sessions to export: %v", err) - return - } - exportKeys(cmd, sessions) -} - -func cmdExportRoomKeys(cmd *Command) { - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - sessions, err := mach.CryptoStore.GetGroupSessionsForRoom(cmd.Room.MxRoom().ID) - if err != nil { - cmd.Reply("Failed to get sessions to export: %v", err) - return - } - exportKeys(cmd, sessions) -} - -const ssssHelp = `Usage: /%s [...] - -Subcommands: -* status [key ID] - Check the status of your SSSS. -* generate [--set-default] - Generate a SSSS key and optionally set it as the default. -* set-default - Set a SSSS key as the default.` - -func cmdSSSS(cmd *Command) { - if len(cmd.Args) == 0 { - cmd.Reply(ssssHelp, cmd.OrigCommand) - return - } - - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - - switch strings.ToLower(cmd.Args[0]) { - case "status": - keyID := "" - if len(cmd.Args) > 1 { - keyID = cmd.Args[1] - } - cmdS4Status(cmd, mach, keyID) - case "generate": - setDefault := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--set-default" - cmdS4Generate(cmd, mach, setDefault) - case "set-default": - if len(cmd.Args) < 2 { - cmd.Reply("Usage: /%s set-default ", cmd.OrigCommand) - return - } - cmdS4SetDefault(cmd, mach, cmd.Args[1]) - default: - cmd.Reply(ssssHelp, cmd.OrigCommand) - } -} - -func cmdS4Status(cmd *Command, mach *crypto.OlmMachine, keyID string) { - var keyData *ssss.KeyMetadata - var err error - if len(keyID) == 0 { - keyID, keyData, err = mach.SSSS.GetDefaultKeyData() - } else { - keyData, err = mach.SSSS.GetKeyData(keyID) - } - if errors.Is(err, ssss.ErrNoDefaultKeyAccountDataEvent) { - cmd.Reply("SSSS is not set up: no default key set") - return - } else if err != nil { - cmd.Reply("Failed to get key data: %v", err) - return - } - hasPassphrase := "no" - if keyData.Passphrase != nil { - hasPassphrase = fmt.Sprintf("yes (alg=%s,bits=%d,iter=%d)", keyData.Passphrase.Algorithm, keyData.Passphrase.Bits, keyData.Passphrase.Iterations) - } - algorithm := keyData.Algorithm - if algorithm != ssss.AlgorithmAESHMACSHA2 { - algorithm += " (not supported!)" - } - cmd.Reply("Default key is set.\n Key ID: %s\n Has passphrase: %s\n Algorithm: %s", keyID, hasPassphrase, algorithm) -} - -func cmdS4Generate(cmd *Command, mach *crypto.OlmMachine, setDefault bool) { - passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "", true) - if !ok { - return - } - - key, err := ssss.NewKey(passphrase) - if err != nil { - cmd.Reply("Failed to generate new key: %v", err) - return - } - - err = mach.SSSS.SetKeyData(key.ID, key.Metadata) - if err != nil { - cmd.Reply("Failed to upload key metadata: %v", err) - return - } - - // TODO if we start persisting command replies, the recovery key needs to be moved into a popup - cmd.Reply("Successfully generated key %s\nRecovery key: %s", key.ID, key.RecoveryKey()) - - if setDefault { - err = mach.SSSS.SetDefaultKeyID(key.ID) - if err != nil { - cmd.Reply("Failed to set key as default: %v", err) - } - } else { - cmd.Reply("You can use `/%s set-default %s` to set it as the default", cmd.OrigCommand, key.ID) - } -} - -func cmdS4SetDefault(cmd *Command, mach *crypto.OlmMachine, keyID string) { - _, err := mach.SSSS.GetKeyData(keyID) - if err != nil { - if errors.Is(err, mautrix.MNotFound) { - cmd.Reply("Couldn't find key data on server") - } else { - cmd.Reply("Failed to fetch key data: %v", err) - } - return - } - - err = mach.SSSS.SetDefaultKeyID(keyID) - if err != nil { - cmd.Reply("Failed to set key as default: %v", err) - } else { - cmd.Reply("Successfully set key %s as default", keyID) - } -} - -const crossSigningHelp = `Usage: /%s [...] - -Subcommands: -* status - Check the status of your own cross-signing keys. -* generate [--force] - Generate and upload new cross-signing keys. - This will prompt you to enter your account password. - If you already have existing keys, --force is required. -* self-sign - Sign the current device with cached cross-signing keys. -* fetch [--save-to-disk] - Fetch your cross-signing keys from SSSS and decrypt them. - If --save-to-disk is specified, the keys are saved to disk. -* upload - Upload your cross-signing keys to SSSS.` - -func cmdCrossSigning(cmd *Command) { - if len(cmd.Args) == 0 { - cmd.Reply(crossSigningHelp, cmd.OrigCommand) - return - } - - client := cmd.Matrix.Client() - mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) - - switch strings.ToLower(cmd.Args[0]) { - case "status": - cmdCrossSigningStatus(cmd, mach) - case "generate": - force := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--force" - cmdCrossSigningGenerate(cmd, cmd.Matrix, mach, client, force) - case "fetch": - saveToDisk := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--save-to-disk" - cmdCrossSigningFetch(cmd, mach, saveToDisk) - case "upload": - cmdCrossSigningUpload(cmd, mach) - case "self-sign": - cmdCrossSigningSelfSign(cmd, mach) - default: - cmd.Reply(crossSigningHelp, cmd.OrigCommand) - } -} - -func cmdCrossSigningStatus(cmd *Command, mach *crypto.OlmMachine) { - keys := mach.GetOwnCrossSigningPublicKeys() - if keys == nil { - if mach.CrossSigningKeys != nil { - cmd.Reply("Cross-signing keys are cached, but not published") - } else { - cmd.Reply("Didn't find published cross-signing keys") - } - return - } - if mach.CrossSigningKeys != nil { - cmd.Reply("Cross-signing keys are published and private keys are cached") - } else { - cmd.Reply("Cross-signing keys are published, but private keys are not cached") - } - cmd.Reply("Master key: %s", keys.MasterKey) - cmd.Reply("User signing key: %s", keys.UserSigningKey) - cmd.Reply("Self-signing key: %s", keys.SelfSigningKey) -} - -func cmdCrossSigningFetch(cmd *Command, mach *crypto.OlmMachine, saveToDisk bool) { - key := getSSSS(cmd, mach) - if key == nil { - return - } - - err := mach.FetchCrossSigningKeysFromSSSS(key) - if err != nil { - cmd.Reply("Error fetching cross-signing keys: %v", err) - return - } - if saveToDisk { - cmd.Reply("Saving keys to disk is not yet implemented") - } - cmd.Reply("Successfully unlocked cross-signing keys") -} - -func cmdCrossSigningGenerate(cmd *Command, container ifc.MatrixContainer, mach *crypto.OlmMachine, client *mautrix.Client, force bool) { - if !force { - existingKeys := mach.GetOwnCrossSigningPublicKeys() - if existingKeys != nil { - cmd.Reply("Found existing cross-signing keys. Use `--force` if you want to overwrite them.") - return - } - } - - keys, err := mach.GenerateCrossSigningKeys() - if err != nil { - cmd.Reply("Failed to generate cross-signing keys: %v", err) - return - } - - err = mach.PublishCrossSigningKeys(keys, func(uia *mautrix.RespUserInteractive) interface{} { - if !uia.HasSingleStageFlow(mautrix.AuthTypePassword) { - for _, flow := range uia.Flows { - if len(flow.Stages) != 1 { - return nil - } - cmd.Reply("Opening browser for authentication") - err := container.UIAFallback(flow.Stages[0], uia.Session) - if err != nil { - cmd.Reply("Authentication failed: %v", err) - return nil - } - return &mautrix.ReqUIAuthFallback{ - Session: uia.Session, - User: mach.Client.UserID.String(), - } - } - cmd.Reply("No supported authentication mechanisms found") - return nil - } - password, ok := cmd.MainView.AskPassword("Account password", "", "correct horse battery staple", false) - if !ok { - return nil - } - return &mautrix.ReqUIAuthLogin{ - BaseAuthData: mautrix.BaseAuthData{ - Type: mautrix.AuthTypePassword, - Session: uia.Session, - }, - User: mach.Client.UserID.String(), - Password: password, - } - }) - if err != nil { - cmd.Reply("Failed to publish cross-signing keys: %v", err) - return - } - cmd.Reply("Successfully generated and published cross-signing keys") - - err = mach.SignOwnMasterKey() - if err != nil { - cmd.Reply("Failed to sign master key with device key: %v", err) - } -} - -func getSSSS(cmd *Command, mach *crypto.OlmMachine) *ssss.Key { - _, keyData, err := mach.SSSS.GetDefaultKeyData() - if err != nil { - if errors.Is(err, mautrix.MNotFound) { - cmd.Reply("SSSS not set up, use `!ssss generate --set-default` first") - } else { - cmd.Reply("Failed to fetch default SSSS key data: %v", err) - } - return nil - } - - var key *ssss.Key - if keyData.Passphrase != nil && keyData.Passphrase.Algorithm == ssss.PassphraseAlgorithmPBKDF2 { - passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "correct horse battery staple", false) - if !ok { - return nil - } - key, err = keyData.VerifyPassphrase(passphrase) - if errors.Is(err, ssss.ErrIncorrectSSSSKey) { - cmd.Reply("Incorrect passphrase") - return nil - } - } else { - recoveryKey, ok := cmd.MainView.AskPassword("Recovery key", "", "tDAK LMRH PiYE bdzi maCe xLX5 wV6P Nmfd c5mC wLef 15Fs VVSc", false) - if !ok { - return nil - } - key, err = keyData.VerifyRecoveryKey(recoveryKey) - if errors.Is(err, ssss.ErrInvalidRecoveryKey) { - cmd.Reply("Malformed recovery key") - return nil - } else if errors.Is(err, ssss.ErrIncorrectSSSSKey) { - cmd.Reply("Incorrect recovery key") - return nil - } - } - // All the errors should already be handled above, this is just for backup - if err != nil { - cmd.Reply("Failed to get SSSS key: %v", err) - return nil - } - return key -} - -func cmdCrossSigningUpload(cmd *Command, mach *crypto.OlmMachine) { - if mach.CrossSigningKeys == nil { - cmd.Reply("Cross-signing keys not cached, use `!%s generate` first", cmd.OrigCommand) - return - } - - key := getSSSS(cmd, mach) - if key == nil { - return - } - - err := mach.UploadCrossSigningKeysToSSSS(key, mach.CrossSigningKeys) - if err != nil { - cmd.Reply("Failed to upload keys to SSSS: %v", err) - } else { - cmd.Reply("Successfully uploaded cross-signing keys to SSSS") - } -} - -func cmdCrossSigningSelfSign(cmd *Command, mach *crypto.OlmMachine) { - if mach.CrossSigningKeys == nil { - cmd.Reply("Cross-signing keys not cached") - return - } - - err := mach.SignOwnDevice(mach.OwnIdentity()) - if err != nil { - cmd.Reply("Failed to self-sign: %v", err) - } else { - cmd.Reply("Successfully self-signed. This device is now trusted by other devices") - } -} diff --git a/ui/doc.go b/ui/doc.go deleted file mode 100644 index 804b334..0000000 --- a/ui/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package ui contains the main gomuks UI. -package ui diff --git a/ui/fuzzy-search-modal.go b/ui/fuzzy-search-modal.go deleted file mode 100644 index 07f510f..0000000 --- a/ui/fuzzy-search-modal.go +++ /dev/null @@ -1,165 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "fmt" - "sort" - "strconv" - - "github.com/lithammer/fuzzysearch/fuzzy" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" - "maunium.net/go/gomuks/matrix/rooms" -) - -type FuzzySearchModal struct { - mauview.Component - - container *mauview.Box - - search *mauview.InputArea - results *mauview.TextView - - matches fuzzy.Ranks - selected int - - roomList []*rooms.Room - roomTitles []string - - parent *MainView -} - -func NewFuzzySearchModal(mainView *MainView, width int, height int) *FuzzySearchModal { - fs := &FuzzySearchModal{ - parent: mainView, - } - - fs.InitList(mainView.rooms) - - fs.results = mauview.NewTextView().SetRegions(true) - fs.search = mauview.NewInputArea(). - SetChangedFunc(fs.changeHandler). - SetTextColor(tcell.ColorWhite). - SetBackgroundColor(tcell.ColorDarkCyan) - fs.search.Focus() - - flex := mauview.NewFlex(). - SetDirection(mauview.FlexRow). - AddFixedComponent(fs.search, 1). - AddProportionalComponent(fs.results, 1) - - fs.container = mauview.NewBox(flex). - SetBorder(true). - SetTitle("Quick Room Switcher"). - SetBlurCaptureFunc(func() bool { - fs.parent.HideModal() - return true - }) - - fs.Component = mauview.Center(fs.container, width, height).SetAlwaysFocusChild(true) - - return fs -} - -func (fs *FuzzySearchModal) Focus() { - fs.container.Focus() -} - -func (fs *FuzzySearchModal) Blur() { - fs.container.Blur() -} - -func (fs *FuzzySearchModal) InitList(rooms map[id.RoomID]*RoomView) { - for _, room := range rooms { - if room.Room.IsReplaced() { - //if _, ok := rooms[room.Room.ReplacedBy()]; ok - continue - } - fs.roomList = append(fs.roomList, room.Room) - fs.roomTitles = append(fs.roomTitles, room.Room.GetTitle()) - } -} - -func (fs *FuzzySearchModal) changeHandler(str string) { - // Get matches and display in result box - fs.matches = fuzzy.RankFindFold(str, fs.roomTitles) - if len(str) > 0 && len(fs.matches) > 0 { - sort.Sort(fs.matches) - fs.results.Clear() - for _, match := range fs.matches { - fmt.Fprintf(fs.results, `["%d"]%s[""]%s`, match.OriginalIndex, match.Target, "\n") - } - //fs.parent.parent.Render() - fs.results.Highlight(strconv.Itoa(fs.matches[0].OriginalIndex)) - fs.selected = 0 - fs.results.ScrollToBeginning() - } else { - fs.results.Clear() - fs.results.Highlight() - } -} - -func (fs *FuzzySearchModal) OnKeyEvent(event mauview.KeyEvent) bool { - highlights := fs.results.GetHighlights() - kb := config.Keybind{ - Key: event.Key(), - Ch: event.Rune(), - Mod: event.Modifiers(), - } - switch fs.parent.config.Keybindings.Modal[kb] { - case "cancel": - // Close room finder - fs.parent.HideModal() - return true - case "select_next": - // Cycle highlighted area to next match - if len(highlights) > 0 { - fs.selected = (fs.selected + 1) % len(fs.matches) - fs.results.Highlight(strconv.Itoa(fs.matches[fs.selected].OriginalIndex)) - fs.results.ScrollToHighlight() - } - return true - case "select_prev": - if len(highlights) > 0 { - fs.selected = (fs.selected - 1) % len(fs.matches) - if fs.selected < 0 { - fs.selected += len(fs.matches) - } - fs.results.Highlight(strconv.Itoa(fs.matches[fs.selected].OriginalIndex)) - fs.results.ScrollToHighlight() - } - return true - case "confirm": - // Switch room to currently selected room - if len(highlights) > 0 { - debug.Print("Fuzzy Selected Room:", fs.roomList[fs.matches[fs.selected].OriginalIndex].GetTitle()) - fs.parent.SwitchRoom(fs.roomList[fs.matches[fs.selected].OriginalIndex].Tags()[0].Tag, fs.roomList[fs.matches[fs.selected].OriginalIndex]) - } - fs.parent.HideModal() - fs.results.Clear() - fs.search.SetText("") - return true - } - return fs.search.OnKeyEvent(event) -} diff --git a/ui/help-modal.go b/ui/help-modal.go deleted file mode 100644 index 8aec753..0000000 --- a/ui/help-modal.go +++ /dev/null @@ -1,117 +0,0 @@ -package ui - -import ( - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/gomuks/config" -) - -const helpText = `# General -/help - Show this help dialog. -/quit - Quit gomuks. -/clearcache - Clear cache and quit gomuks. -/logout - Log out of Matrix. -/toggle - Temporary command to toggle various UI features. - Run /toggle without arguments to see the list of toggles. - -# Media -/download [path] - Downloads file from selected message. -/open [path] - Download file from selected message and open it with xdg-open. -/upload - Upload the file at the given path to the current room. - -# Sending special messages -/me - Send an emote message. -/notice - Send a notice (generally used for bot messages). -/rainbow - Send rainbow text. -/rainbowme - Send rainbow text in an emote. -/reply [text] - Reply to the selected message. -/react - React to the selected message. -/redact [reason] - Redact the selected message. -/edit - Edit the selected message. - -# Encryption -/fingerprint - View the fingerprint of your device. - -/devices - View the device list of a user. -/device - Show info about a specific device. -/unverify - Un-verify a device. -/blacklist - Blacklist a device. -/verify - Verify a user with in-room verification. Probably broken. -/verify-device [fingerprint] - - Verify a device. If the fingerprint is not provided, - interactive emoji verification will be started. -/reset-session - Reset the outbound Megolm session in the current room. - -/import - Import encryption keys -/export - Export encryption keys -/export-room - Export encryption keys for the current room. - -/cross-signing [...] - - Cross-signing commands. Somewhat experimental. - Run without arguments for help. (alias: /cs) -/ssss [...] - - Secure Secret Storage (and Sharing) commands. Very experimental. - Run without arguments for help. - -# Rooms -/pm <...> - Create a private chat with the given user(s). -/create [room name] - Create a room. - -/join [server] - Join a room. -/accept - Accept the invite. -/reject - Reject the invite. - -/invite - Invite the given user to the room. -/roomnick - Change your per-room displayname. -/tag - Add the room to . -/untag - Remove the room from . -/tags - List the tags the room is in. -/alias - Add or remove local addresses. - -/leave - Leave the current room. -/kick [reason] - Kick a user. -/ban [reason] - Ban a user. -/unban - Unban a user.` - -type HelpModal struct { - mauview.FocusableComponent - parent *MainView -} - -func NewHelpModal(parent *MainView) *HelpModal { - hm := &HelpModal{parent: parent} - - text := mauview.NewTextView(). - SetText(helpText). - SetScrollable(true). - SetWrap(false). - SetTextColor(tcell.ColorDefault) - - box := mauview.NewBox(text). - SetBorder(true). - SetTitle("Help"). - SetBlurCaptureFunc(func() bool { - hm.parent.HideModal() - return true - }) - box.Focus() - - hm.FocusableComponent = mauview.FractionalCenter(box, 42, 10, 0.5, 0.5) - - return hm -} - -func (hm *HelpModal) OnKeyEvent(event mauview.KeyEvent) bool { - kb := config.Keybind{ - Key: event.Key(), - Ch: event.Rune(), - Mod: event.Modifiers(), - } - // TODO unhardcode q - if hm.parent.config.Keybindings.Modal[kb] == "cancel" || event.Rune() == 'q' { - hm.parent.HideModal() - return true - } - return hm.FocusableComponent.OnKeyEvent(event) -} diff --git a/ui/member-list.go b/ui/member-list.go deleted file mode 100644 index 089c156..0000000 --- a/ui/member-list.go +++ /dev/null @@ -1,128 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "math" - "sort" - "strings" - - "github.com/mattn/go-runewidth" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/widget" -) - -type MemberList struct { - list roomMemberList -} - -func NewMemberList() *MemberList { - return &MemberList{} -} - -type memberListItem struct { - rooms.Member - PowerLevel int - Sigil rune - UserID id.UserID - Color tcell.Color -} - -type roomMemberList []*memberListItem - -func (rml roomMemberList) Len() int { - return len(rml) -} - -func (rml roomMemberList) Less(i, j int) bool { - if rml[i].PowerLevel != rml[j].PowerLevel { - return rml[i].PowerLevel > rml[j].PowerLevel - } - return strings.Compare(strings.ToLower(rml[i].Displayname), strings.ToLower(rml[j].Displayname)) < 0 -} - -func (rml roomMemberList) Swap(i, j int) { - rml[i], rml[j] = rml[j], rml[i] -} - -func (ml *MemberList) Update(data map[id.UserID]*rooms.Member, levels *event.PowerLevelsEventContent) *MemberList { - ml.list = make(roomMemberList, len(data)) - i := 0 - highestLevel := math.MinInt32 - count := 0 - for _, level := range levels.Users { - if level > highestLevel { - highestLevel = level - count = 1 - } else if level == highestLevel { - count++ - } - } - for userID, member := range data { - level := levels.GetUserLevel(userID) - sigil := ' ' - if level == highestLevel && count == 1 { - sigil = '~' - } else if level > levels.StateDefault() { - sigil = '&' - } else if level >= levels.Ban() { - sigil = '@' - } else if level >= levels.Kick() || level >= levels.Redact() { - sigil = '%' - } else if level > levels.UsersDefault { - sigil = '+' - } - ml.list[i] = &memberListItem{ - Member: *member, - UserID: userID, - PowerLevel: level, - Sigil: sigil, - Color: widget.GetHashColor(userID), - } - i++ - } - sort.Sort(ml.list) - return ml -} - -func (ml *MemberList) Draw(screen mauview.Screen) { - width, _ := screen.Size() - sigilStyle := tcell.StyleDefault.Background(tcell.ColorGreen).Foreground(tcell.ColorDefault) - for y, member := range ml.list { - if member.Sigil != ' ' { - screen.SetCell(0, y, sigilStyle, member.Sigil) - } - if member.Membership == "invite" { - widget.WriteLineSimpleColor(screen, member.Displayname, 2, y, member.Color) - screen.SetCell(1, y, tcell.StyleDefault, '(') - if sw := runewidth.StringWidth(member.Displayname); sw+2 < width { - screen.SetCell(sw+2, y, tcell.StyleDefault, ')') - } else { - screen.SetCell(width-1, y, tcell.StyleDefault, ')') - } - } else { - widget.WriteLineSimpleColor(screen, member.Displayname, 1, y, member.Color) - } - } -} diff --git a/ui/message-view.go b/ui/message-view.go deleted file mode 100644 index 6d05546..0000000 --- a/ui/message-view.go +++ /dev/null @@ -1,682 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "fmt" - "math" - "strings" - "sync/atomic" - - "github.com/mattn/go-runewidth" - sync "github.com/sasha-s/go-deadlock" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/lib/open" - "maunium.net/go/gomuks/ui/messages" - "maunium.net/go/gomuks/ui/widget" -) - -type MessageView struct { - parent *RoomView - config *config.Config - - ScrollOffset int - MaxSenderWidth int - DateFormat string - TimestampFormat string - TimestampWidth int - - // Used for locking - loadingMessages int32 - historyLoadPtr uint64 - - _widestSender uint32 - _prevWidestSender uint32 - - _width uint32 - _height uint32 - _prevWidth uint32 - _prevHeight uint32 - - prevMsgCount int - prevPrefs config.UserPreferences - - messageIDLock sync.RWMutex - messageIDs map[id.EventID]*messages.UIMessage - messagesLock sync.RWMutex - messages []*messages.UIMessage - msgBufferLock sync.RWMutex - msgBuffer []*messages.UIMessage - selected *messages.UIMessage - - initialHistoryLoaded bool -} - -func NewMessageView(parent *RoomView) *MessageView { - return &MessageView{ - parent: parent, - config: parent.config, - - MaxSenderWidth: 15, - TimestampWidth: len(messages.TimeFormat), - ScrollOffset: 0, - - messages: make([]*messages.UIMessage, 0), - messageIDs: make(map[id.EventID]*messages.UIMessage), - msgBuffer: make([]*messages.UIMessage, 0), - - _widestSender: 5, - _prevWidestSender: 0, - - _width: 80, - _prevWidth: 0, - _prevHeight: 0, - prevMsgCount: -1, - } -} - -func (view *MessageView) Unload() { - debug.Print("Unloading message view", view.parent.Room.ID) - view.messagesLock.Lock() - view.msgBufferLock.Lock() - view.messageIDLock.Lock() - view.messageIDs = make(map[id.EventID]*messages.UIMessage) - view.msgBuffer = make([]*messages.UIMessage, 0) - view.messages = make([]*messages.UIMessage, 0) - view.initialHistoryLoaded = false - view.ScrollOffset = 0 - view._widestSender = 5 - view.prevMsgCount = -1 - view.historyLoadPtr = 0 - view.messagesLock.Unlock() - view.msgBufferLock.Unlock() - view.messageIDLock.Unlock() -} - -func (view *MessageView) updateWidestSender(sender string) { - if len(sender) > int(view._widestSender) { - if len(sender) > view.MaxSenderWidth { - atomic.StoreUint32(&view._widestSender, uint32(view.MaxSenderWidth)) - } else { - atomic.StoreUint32(&view._widestSender, uint32(len(sender))) - } - } -} - -type MessageDirection int - -const ( - AppendMessage MessageDirection = iota - PrependMessage - IgnoreMessage -) - -func (view *MessageView) AddMessage(ifcMessage ifc.Message, direction MessageDirection) { - if ifcMessage == nil { - return - } - message, ok := ifcMessage.(*messages.UIMessage) - if !ok || message == nil { - debug.Print("[Warning] Passed non-UIMessage ifc.Message object to AddMessage().") - debug.PrintStack() - return - } - - var oldMsg *messages.UIMessage - if oldMsg = view.getMessageByID(message.EventID); oldMsg != nil { - view.replaceMessage(oldMsg, message) - direction = IgnoreMessage - } else if oldMsg = view.getMessageByID(id.EventID(message.TxnID)); oldMsg != nil { - view.replaceMessage(oldMsg, message) - view.deleteMessageID(id.EventID(message.TxnID)) - direction = IgnoreMessage - } - - view.updateWidestSender(message.Sender()) - - width := view.width() - bare := view.config.Preferences.BareMessageView - if !bare { - width -= view.widestSender() + SenderMessageGap - if !view.config.Preferences.HideTimestamp { - width -= view.TimestampWidth + TimestampSenderGap - } - } - message.CalculateBuffer(view.config.Preferences, width) - - makeDateChange := func(msg *messages.UIMessage) *messages.UIMessage { - dateChange := messages.NewDateChangeMessage( - fmt.Sprintf("Date changed to %s", msg.FormatDate())) - dateChange.CalculateBuffer(view.config.Preferences, width) - view.appendBuffer(dateChange) - return dateChange - } - - if direction == AppendMessage { - if view.ScrollOffset > 0 { - view.ScrollOffset += message.Height() - } - view.messagesLock.Lock() - if len(view.messages) > 0 && !view.messages[len(view.messages)-1].SameDate(message) { - view.messages = append(view.messages, makeDateChange(message), message) - } else { - view.messages = append(view.messages, message) - } - view.messagesLock.Unlock() - view.appendBuffer(message) - } else if direction == PrependMessage { - view.messagesLock.Lock() - if len(view.messages) > 0 && !view.messages[0].SameDate(message) { - view.messages = append([]*messages.UIMessage{message, makeDateChange(view.messages[0])}, view.messages...) - } else { - view.messages = append([]*messages.UIMessage{message}, view.messages...) - } - view.messagesLock.Unlock() - } else if oldMsg != nil { - view.replaceBuffer(oldMsg, message) - } else { - debug.Print("Unexpected AddMessage() call: Direction is not append or prepend, but message is new.") - debug.PrintStack() - } - - if len(message.ID()) > 0 { - view.setMessageID(message) - } -} - -func (view *MessageView) replaceMessage(original *messages.UIMessage, new *messages.UIMessage) { - if len(new.ID()) > 0 { - view.setMessageID(new) - } - view.messagesLock.Lock() - for index, msg := range view.messages { - if msg == original { - view.messages[index] = new - } - } - view.messagesLock.Unlock() -} - -func (view *MessageView) getMessageByID(id id.EventID) *messages.UIMessage { - if id == "" { - return nil - } - view.messageIDLock.RLock() - defer view.messageIDLock.RUnlock() - msg, ok := view.messageIDs[id] - if !ok { - return nil - } - return msg -} - -func (view *MessageView) deleteMessageID(id id.EventID) { - if id == "" { - return - } - view.messageIDLock.Lock() - delete(view.messageIDs, id) - view.messageIDLock.Unlock() -} - -func (view *MessageView) setMessageID(message *messages.UIMessage) { - if message.ID() == "" { - return - } - view.messageIDLock.Lock() - view.messageIDs[message.ID()] = message - view.messageIDLock.Unlock() -} - -func (view *MessageView) appendBuffer(message *messages.UIMessage) { - view.msgBufferLock.Lock() - view.appendBufferUnlocked(message) - view.msgBufferLock.Unlock() -} - -func (view *MessageView) appendBufferUnlocked(message *messages.UIMessage) { - for i := 0; i < message.Height(); i++ { - view.msgBuffer = append(view.msgBuffer, message) - } - view.prevMsgCount++ -} - -func (view *MessageView) replaceBuffer(original *messages.UIMessage, new *messages.UIMessage) { - start := -1 - end := -1 - view.msgBufferLock.RLock() - for index, meta := range view.msgBuffer { - if meta == original { - if start == -1 { - start = index - } - end = index - } else if start != -1 { - break - } - } - view.msgBufferLock.RUnlock() - - if start == -1 { - debug.Print("Called replaceBuffer() with message that was not in the buffer:", original) - //debug.PrintStack() - view.appendBuffer(new) - return - } - - if len(view.msgBuffer) > end { - end++ - } - - if new.Height() == 0 { - new.CalculateBuffer(view.prevPrefs, view.prevWidth()) - } - - view.msgBufferLock.Lock() - if new.Height() != end-start { - height := new.Height() - - newBuffer := make([]*messages.UIMessage, height+len(view.msgBuffer)-end) - for i := 0; i < height; i++ { - newBuffer[i] = new - } - for i := height; i < len(newBuffer); i++ { - newBuffer[i] = view.msgBuffer[end+(i-height)] - } - view.msgBuffer = append(view.msgBuffer[0:start], newBuffer...) - } else { - for i := start; i < end; i++ { - view.msgBuffer[i] = new - } - } - view.msgBufferLock.Unlock() -} - -func (view *MessageView) recalculateBuffers() { - prefs := view.config.Preferences - recalculateMessageBuffers := view.width() != view.prevWidth() || - view.widestSender() != view.prevWidestSender() || - view.prevPrefs.BareMessageView != prefs.BareMessageView || - view.prevPrefs.DisableImages != prefs.DisableImages - view.messagesLock.RLock() - view.msgBufferLock.Lock() - if recalculateMessageBuffers || len(view.messages) != view.prevMsgCount { - width := view.width() - if !prefs.BareMessageView { - width -= view.widestSender() + SenderMessageGap - if !prefs.HideTimestamp { - width -= view.TimestampWidth + TimestampSenderGap - } - } - view.msgBuffer = []*messages.UIMessage{} - view.prevMsgCount = 0 - for i, message := range view.messages { - if message == nil { - debug.Print("O.o found nil message at", i) - break - } - if recalculateMessageBuffers { - message.CalculateBuffer(prefs, width) - } - view.appendBufferUnlocked(message) - } - } - view.msgBufferLock.Unlock() - view.messagesLock.RUnlock() - view.updatePrevSize() - view.prevPrefs = prefs -} - -func (view *MessageView) SetSelected(message *messages.UIMessage) { - if view.selected != nil { - view.selected.IsSelected = false - } - if message != nil && (view.selected == message || message.IsService) { - view.selected = nil - } else { - view.selected = message - } - if view.selected != nil { - view.selected.IsSelected = true - } -} - -func (view *MessageView) handleMessageClick(message *messages.UIMessage, mod tcell.ModMask) bool { - if msg, ok := message.Renderer.(*messages.FileMessage); ok && mod > 0 && !msg.Thumbnail.IsEmpty() { - debug.Print("Opening thumbnail", msg.ThumbnailPath()) - open.Open(msg.ThumbnailPath()) - // No need to re-render - return false - } - view.SetSelected(message) - view.parent.OnSelect(view.selected) - return true -} - -func (view *MessageView) handleUsernameClick(message *messages.UIMessage, prevMessage *messages.UIMessage) bool { - // TODO this is needed if senders are hidden for messages from the same sender (see Draw method) - //if prevMessage != nil && prevMessage.SenderName == message.SenderName { - // return false - //} - - if message.SenderName == "---" || message.SenderName == "-->" || message.SenderName == "<--" || message.Type == event.MsgEmote { - return false - } - - sender := fmt.Sprintf("[%s](https://matrix.to/#/%s)", message.SenderName, message.SenderID) - - cursorPos := view.parent.input.GetCursorOffset() - text := view.parent.input.GetText() - var buf strings.Builder - if cursorPos == 0 { - buf.WriteString(sender) - buf.WriteRune(':') - buf.WriteRune(' ') - buf.WriteString(text) - } else { - textBefore := runewidth.Truncate(text, cursorPos, "") - textAfter := text[len(textBefore):] - buf.WriteString(textBefore) - buf.WriteString(sender) - buf.WriteRune(' ') - buf.WriteString(textAfter) - } - newText := buf.String() - view.parent.input.SetText(string(newText)) - view.parent.input.SetCursorOffset(cursorPos + len(newText) - len(text)) - return true -} - -func (view *MessageView) OnMouseEvent(event mauview.MouseEvent) bool { - if event.HasMotion() { - return false - } - switch event.Buttons() { - case tcell.WheelUp: - if view.IsAtTop() { - go view.parent.parent.LoadHistory(view.parent.Room.ID) - } else { - view.AddScrollOffset(WheelScrollOffsetDiff) - return true - } - case tcell.WheelDown: - view.AddScrollOffset(-WheelScrollOffsetDiff) - view.parent.parent.MarkRead(view.parent) - return true - case tcell.Button1: - x, y := event.Position() - line := view.TotalHeight() - view.ScrollOffset - view.Height() + y - if line < 0 || line >= view.TotalHeight() { - return false - } - - view.msgBufferLock.RLock() - message := view.msgBuffer[line] - var prevMessage *messages.UIMessage - if y != 0 && line > 0 { - prevMessage = view.msgBuffer[line-1] - } - view.msgBufferLock.RUnlock() - - usernameX := 0 - if !view.config.Preferences.HideTimestamp { - usernameX += view.TimestampWidth + TimestampSenderGap - } - messageX := usernameX + view.widestSender() + SenderMessageGap - - if x >= messageX { - return view.handleMessageClick(message, event.Modifiers()) - } else if x >= usernameX { - return view.handleUsernameClick(message, prevMessage) - } - } - return false -} - -const PaddingAtTop = 5 - -func (view *MessageView) AddScrollOffset(diff int) { - totalHeight := view.TotalHeight() - height := view.Height() - if diff >= 0 && view.ScrollOffset+diff >= totalHeight-height+PaddingAtTop { - view.ScrollOffset = totalHeight - height + PaddingAtTop - } else { - view.ScrollOffset += diff - } - - if view.ScrollOffset > totalHeight-height+PaddingAtTop { - view.ScrollOffset = totalHeight - height + PaddingAtTop - } - if view.ScrollOffset < 0 { - view.ScrollOffset = 0 - } -} - -func (view *MessageView) setSize(width, height int) { - atomic.StoreUint32(&view._width, uint32(width)) - atomic.StoreUint32(&view._height, uint32(height)) -} - -func (view *MessageView) updatePrevSize() { - atomic.StoreUint32(&view._prevWidth, atomic.LoadUint32(&view._width)) - atomic.StoreUint32(&view._prevHeight, atomic.LoadUint32(&view._height)) - atomic.StoreUint32(&view._prevWidestSender, atomic.LoadUint32(&view._widestSender)) -} - -func (view *MessageView) prevHeight() int { - return int(atomic.LoadUint32(&view._prevHeight)) -} - -func (view *MessageView) prevWidth() int { - return int(atomic.LoadUint32(&view._prevWidth)) -} - -func (view *MessageView) prevWidestSender() int { - return int(atomic.LoadUint32(&view._prevWidestSender)) -} - -func (view *MessageView) widestSender() int { - return int(atomic.LoadUint32(&view._widestSender)) -} - -func (view *MessageView) Height() int { - return int(atomic.LoadUint32(&view._height)) -} - -func (view *MessageView) width() int { - return int(atomic.LoadUint32(&view._width)) -} - -func (view *MessageView) TotalHeight() int { - view.msgBufferLock.RLock() - defer view.msgBufferLock.RUnlock() - return len(view.msgBuffer) -} - -func (view *MessageView) IsAtTop() bool { - return view.ScrollOffset >= view.TotalHeight()-view.Height()+PaddingAtTop -} - -const ( - TimestampSenderGap = 1 - SenderSeparatorGap = 1 - SenderMessageGap = 3 -) - -func getScrollbarStyle(scrollbarHere, isTop, isBottom bool) (char rune, style tcell.Style) { - char = '│' - style = tcell.StyleDefault - if scrollbarHere { - style = style.Foreground(tcell.ColorGreen) - } - if isTop { - if scrollbarHere { - char = '╥' - } else { - char = '┬' - } - } else if isBottom { - if scrollbarHere { - char = '╨' - } else { - char = '┴' - } - } else if scrollbarHere { - char = '║' - } - return -} - -func (view *MessageView) calculateScrollBar(height int) (scrollBarHeight, scrollBarPos int) { - viewportHeight := float64(height) - contentHeight := float64(view.TotalHeight()) - - scrollBarHeight = int(math.Ceil(viewportHeight / (contentHeight / viewportHeight))) - - scrollBarPos = height - int(math.Round(float64(view.ScrollOffset)/contentHeight*viewportHeight)) - - return -} - -func (view *MessageView) getIndexOffset(screen mauview.Screen, height, messageX int) (indexOffset int) { - indexOffset = view.TotalHeight() - view.ScrollOffset - height - if indexOffset <= -PaddingAtTop { - message := "Scroll up to load more messages." - if atomic.LoadInt32(&view.loadingMessages) == 1 { - message = "Loading more messages..." - } - widget.WriteLineSimpleColor(screen, message, messageX, 0, tcell.ColorGreen) - } - return -} - -func (view *MessageView) CapturePlaintext(height int) string { - var buf strings.Builder - indexOffset := view.TotalHeight() - view.ScrollOffset - height - var prevMessage *messages.UIMessage - view.msgBufferLock.RLock() - for line := 0; line < height; line++ { - index := indexOffset + line - if index < 0 { - continue - } - - message := view.msgBuffer[index] - if message != prevMessage { - var sender string - if len(message.Sender()) > 0 { - sender = fmt.Sprintf(" <%s>", message.Sender()) - } else if message.Type == event.MsgEmote { - sender = fmt.Sprintf(" * %s", message.SenderName) - } - fmt.Fprintf(&buf, "%s%s %s\n", message.FormatTime(), sender, message.PlainText()) - prevMessage = message - } - } - view.msgBufferLock.RUnlock() - return buf.String() -} - -func (view *MessageView) Draw(screen mauview.Screen) { - view.setSize(screen.Size()) - view.recalculateBuffers() - - height := view.Height() - if view.TotalHeight() == 0 { - widget.WriteLineSimple(screen, "It's quite empty in here.", 0, height) - return - } - - usernameX := 0 - if !view.config.Preferences.HideTimestamp { - usernameX += view.TimestampWidth + TimestampSenderGap - } - messageX := usernameX + view.widestSender() + SenderMessageGap - - bareMode := view.config.Preferences.BareMessageView - if bareMode { - messageX = 0 - } - - indexOffset := view.getIndexOffset(screen, height, messageX) - - viewStart := 0 - if indexOffset < 0 { - viewStart = -indexOffset - } - - if !bareMode { - separatorX := usernameX + view.widestSender() + SenderSeparatorGap - scrollBarHeight, scrollBarPos := view.calculateScrollBar(height) - - for line := viewStart; line < height; line++ { - showScrollbar := line-viewStart >= scrollBarPos-scrollBarHeight && line-viewStart < scrollBarPos - isTop := line == viewStart && view.ScrollOffset+height >= view.TotalHeight() - isBottom := line == height-1 && view.ScrollOffset == 0 - - borderChar, borderStyle := getScrollbarStyle(showScrollbar, isTop, isBottom) - - screen.SetContent(separatorX, line, borderChar, nil, borderStyle) - } - } - - var prevMsg *messages.UIMessage - view.msgBufferLock.RLock() - for line := viewStart; line < height && indexOffset+line < len(view.msgBuffer); { - index := indexOffset + line - - msg := view.msgBuffer[index] - if msg == prevMsg { - debug.Print("Unexpected re-encounter of", msg, msg.Height(), "at", line, index) - line++ - continue - } - - if len(msg.FormatTime()) > 0 && !view.config.Preferences.HideTimestamp { - widget.WriteLineSimpleColor(screen, msg.FormatTime(), 0, line, msg.TimestampColor()) - } - // TODO hiding senders might not be that nice after all, maybe an option? (disabled for now) - //if !bareMode && (prevMsg == nil || meta.Sender() != prevMsg.Sender()) { - widget.WriteLineColor( - screen, mauview.AlignRight, msg.Sender(), - usernameX, line, view.widestSender(), - msg.SenderColor()) - //} - if msg.Edited { - // TODO add better indicator for edits - screen.SetCell(usernameX+view.widestSender(), line, tcell.StyleDefault.Foreground(tcell.ColorDarkRed), '*') - } - - for i := index - 1; i >= 0 && view.msgBuffer[i] == msg; i-- { - line-- - } - msg.Draw(mauview.NewProxyScreen(screen, messageX, line, view.width()-messageX, msg.Height())) - line += msg.Height() - - prevMsg = msg - } - view.msgBufferLock.RUnlock() -} diff --git a/ui/messages/base.go b/ui/messages/base.go deleted file mode 100644 index 85b1219..0000000 --- a/ui/messages/base.go +++ /dev/null @@ -1,396 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package messages - -import ( - "fmt" - "sort" - "time" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/matrix/muksevt" - - "maunium.net/go/gomuks/ui/widget" -) - -type MessageRenderer interface { - Draw(screen mauview.Screen, msg *UIMessage) - NotificationContent() string - PlainText() string - CalculateBuffer(prefs config.UserPreferences, width int, msg *UIMessage) - Height() int - Clone() MessageRenderer - String() string -} - -type ReactionItem struct { - Key string - Count int -} - -func (ri ReactionItem) String() string { - return fmt.Sprintf("%d×%s", ri.Count, ri.Key) -} - -type ReactionSlice []ReactionItem - -func (rs ReactionSlice) Len() int { - return len(rs) -} - -func (rs ReactionSlice) Less(i, j int) bool { - return rs[i].Key < rs[j].Key -} - -func (rs ReactionSlice) Swap(i, j int) { - rs[i], rs[j] = rs[j], rs[i] -} - -type UIMessage struct { - EventID id.EventID - TxnID string - Relation event.RelatesTo - Type event.MessageType - SenderID id.UserID - SenderName string - DefaultSenderColor tcell.Color - Timestamp time.Time - State muksevt.OutgoingState - IsHighlight bool - IsService bool - IsSelected bool - Edited bool - Event *muksevt.Event - ReplyTo *UIMessage - Reactions ReactionSlice - Renderer MessageRenderer -} - -func (msg *UIMessage) GetEvent() *muksevt.Event { - if msg == nil { - return nil - } - return msg.Event -} - -const DateFormat = "January _2, 2006" -const TimeFormat = "15:04:05" - -func newUIMessage(evt *muksevt.Event, displayname string, renderer MessageRenderer) *UIMessage { - msgContent := evt.Content.AsMessage() - msgtype := msgContent.MsgType - if len(msgtype) == 0 { - msgtype = event.MessageType(evt.Type.String()) - } - - reactions := make(ReactionSlice, 0, len(evt.Unsigned.Relations.Annotations.Map)) - for key, count := range evt.Unsigned.Relations.Annotations.Map { - reactions = append(reactions, ReactionItem{ - Key: key, - Count: count, - }) - } - sort.Sort(reactions) - - return &UIMessage{ - SenderID: evt.Sender, - SenderName: displayname, - Timestamp: unixToTime(evt.Timestamp), - DefaultSenderColor: widget.GetHashColor(evt.Sender), - Type: msgtype, - EventID: evt.ID, - TxnID: evt.Unsigned.TransactionID, - Relation: *msgContent.GetRelatesTo(), - State: evt.Gomuks.OutgoingState, - IsHighlight: false, - IsService: false, - Edited: len(evt.Gomuks.Edits) > 0, - Reactions: reactions, - Event: evt, - Renderer: renderer, - } -} - -func (msg *UIMessage) AddReaction(key string) { - found := false - for i, rs := range msg.Reactions { - if rs.Key == key { - rs.Count++ - msg.Reactions[i] = rs - found = true - break - } - } - if !found { - msg.Reactions = append(msg.Reactions, ReactionItem{ - Key: key, - Count: 1, - }) - } - sort.Sort(msg.Reactions) -} - -func unixToTime(unix int64) time.Time { - timestamp := time.Now() - if unix != 0 { - timestamp = time.Unix(unix/1000, unix%1000*1000) - } - return timestamp -} - -// Sender gets the string that should be displayed as the sender of this message. -// -// If the message is being sent, the sender is "Sending...". -// If sending has failed, the sender is "Error". -// If the message is an emote, the sender is blank. -// In any other case, the sender is the display name of the user who sent the message. -func (msg *UIMessage) Sender() string { - switch msg.State { - case muksevt.StateLocalEcho: - return "Sending..." - case muksevt.StateSendFail: - return "Error" - } - switch msg.Type { - case "m.emote": - // Emotes don't show a separate sender, it's included in the buffer. - return "" - default: - return msg.SenderName - } -} - -func (msg *UIMessage) NotificationSenderName() string { - return msg.SenderName -} - -func (msg *UIMessage) NotificationContent() string { - return msg.Renderer.NotificationContent() -} - -func (msg *UIMessage) getStateSpecificColor() tcell.Color { - switch msg.State { - case muksevt.StateLocalEcho: - return tcell.ColorGray - case muksevt.StateSendFail: - return tcell.ColorRed - case muksevt.StateDefault: - fallthrough - default: - return tcell.ColorDefault - } -} - -// SenderColor returns the color the name of the sender should be shown in. -// -// If the message is being sent, the color is gray. -// If sending has failed, the color is red. -// -// In any other case, the color is whatever is specified in the Message struct. -// Usually that means it is the hash-based color of the sender (see ui/widget/color.go) -func (msg *UIMessage) SenderColor() tcell.Color { - stateColor := msg.getStateSpecificColor() - switch { - case stateColor != tcell.ColorDefault: - return stateColor - case msg.Type == "m.room.member": - return widget.GetHashColor(msg.SenderName) - case msg.IsService: - return tcell.ColorGray - default: - return msg.DefaultSenderColor - } -} - -// TextColor returns the color the actual content of the message should be shown in. -func (msg *UIMessage) TextColor() tcell.Color { - stateColor := msg.getStateSpecificColor() - switch { - case stateColor != tcell.ColorDefault: - return stateColor - case msg.IsService, msg.Type == "m.notice": - return tcell.ColorGray - case msg.IsHighlight: - return tcell.ColorYellow - case msg.Type == "m.room.member": - return tcell.ColorGreen - default: - return tcell.ColorDefault - } -} - -// TimestampColor returns the color the timestamp should be shown in. -// -// As with SenderColor(), messages being sent and messages that failed to be sent are -// gray and red respectively. -// -// However, other messages are the default color instead of a color stored in the struct. -func (msg *UIMessage) TimestampColor() tcell.Color { - if msg.IsService { - return tcell.ColorGray - } - return msg.getStateSpecificColor() -} - -func (msg *UIMessage) ReplyHeight() int { - if msg.ReplyTo != nil { - return 1 + msg.ReplyTo.Height() - } - return 0 -} - -func (msg *UIMessage) ReactionHeight() int { - if len(msg.Reactions) > 0 { - return 1 - } - return 0 -} - -// Height returns the number of rows in the computed buffer (see Buffer()). -func (msg *UIMessage) Height() int { - return msg.ReplyHeight() + msg.Renderer.Height() + msg.ReactionHeight() -} - -func (msg *UIMessage) Time() time.Time { - return msg.Timestamp -} - -// FormatTime returns the formatted time when the message was sent. -func (msg *UIMessage) FormatTime() string { - return msg.Timestamp.Format(TimeFormat) -} - -// FormatDate returns the formatted date when the message was sent. -func (msg *UIMessage) FormatDate() string { - return msg.Timestamp.Format(DateFormat) -} - -func (msg *UIMessage) SameDate(message *UIMessage) bool { - year1, month1, day1 := msg.Timestamp.Date() - year2, month2, day2 := message.Timestamp.Date() - return day1 == day2 && month1 == month2 && year1 == year2 -} - -func (msg *UIMessage) ID() id.EventID { - if len(msg.EventID) == 0 { - return id.EventID(msg.TxnID) - } - return msg.EventID -} - -func (msg *UIMessage) SetID(id id.EventID) { - msg.EventID = id -} - -func (msg *UIMessage) SetIsHighlight(isHighlight bool) { - msg.IsHighlight = isHighlight -} - -func (msg *UIMessage) DrawReactions(screen mauview.Screen) { - if len(msg.Reactions) == 0 { - return - } - width, height := screen.Size() - screen = mauview.NewProxyScreen(screen, 0, height-1, width, 1) - - x := 0 - for _, reaction := range msg.Reactions { - _, drawn := mauview.PrintWithStyle(screen, reaction.String(), x, 0, width-x, mauview.AlignLeft, tcell.StyleDefault.Foreground(mauview.Styles.PrimaryTextColor).Background(tcell.ColorDarkGreen)) - x += drawn + 1 - if x >= width { - break - } - } -} - -func (msg *UIMessage) Draw(screen mauview.Screen) { - proxyScreen := msg.DrawReply(screen) - msg.Renderer.Draw(proxyScreen, msg) - msg.DrawReactions(proxyScreen) - if msg.IsSelected { - w, h := screen.Size() - for x := 0; x < w; x++ { - for y := 0; y < h; y++ { - mainc, combc, style, _ := screen.GetContent(x, y) - _, bg, _ := style.Decompose() - if bg == tcell.ColorDefault { - screen.SetContent(x, y, mainc, combc, style.Background(tcell.ColorDarkGreen)) - } - } - } - } -} - -func (msg *UIMessage) Clone() *UIMessage { - clone := *msg - clone.ReplyTo = nil - clone.Reactions = nil - clone.Renderer = clone.Renderer.Clone() - return &clone -} - -func (msg *UIMessage) CalculateReplyBuffer(preferences config.UserPreferences, width int) { - if msg.ReplyTo == nil { - return - } - msg.ReplyTo.CalculateBuffer(preferences, width-1) -} - -func (msg *UIMessage) CalculateBuffer(preferences config.UserPreferences, width int) { - msg.Renderer.CalculateBuffer(preferences, width, msg) - msg.CalculateReplyBuffer(preferences, width) -} - -func (msg *UIMessage) DrawReply(screen mauview.Screen) mauview.Screen { - if msg.ReplyTo == nil { - return screen - } - width, height := screen.Size() - replyHeight := msg.ReplyTo.Height() - widget.WriteLineSimpleColor(screen, "In reply to", 1, 0, tcell.ColorGreen) - widget.WriteLineSimpleColor(screen, msg.ReplyTo.SenderName, 13, 0, msg.ReplyTo.SenderColor()) - for y := 0; y < 1+replyHeight; y++ { - screen.SetCell(0, y, tcell.StyleDefault, '▊') - } - replyScreen := mauview.NewProxyScreen(screen, 1, 1, width-1, replyHeight) - msg.ReplyTo.Draw(replyScreen) - return mauview.NewProxyScreen(screen, 0, replyHeight+1, width, height-replyHeight-1) -} - -func (msg *UIMessage) String() string { - return fmt.Sprintf(`&messages.UIMessage{ - ID="%s", TxnID="%s", - Type="%s", Timestamp=%s, - Sender={ID="%s", Name="%s", Color=#%X}, - IsService=%t, IsHighlight=%t, - Renderer=%s, -}`, - msg.EventID, msg.TxnID, - msg.Type, msg.Timestamp.String(), - msg.SenderID, msg.SenderName, msg.DefaultSenderColor.Hex(), - msg.IsService, msg.IsHighlight, msg.Renderer.String()) -} - -func (msg *UIMessage) PlainText() string { - return msg.Renderer.PlainText() -} diff --git a/ui/messages/doc.go b/ui/messages/doc.go deleted file mode 100644 index 289c308..0000000 --- a/ui/messages/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package messages contains different message types and code to generate and render them. -package messages diff --git a/ui/messages/expandedtextmessage.go b/ui/messages/expandedtextmessage.go deleted file mode 100644 index ea001ab..0000000 --- a/ui/messages/expandedtextmessage.go +++ /dev/null @@ -1,102 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package messages - -import ( - "fmt" - "time" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/gomuks/matrix/muksevt" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/ui/messages/tstring" -) - -type ExpandedTextMessage struct { - Text tstring.TString - buffer []tstring.TString -} - -// NewExpandedTextMessage creates a new ExpandedTextMessage object with the provided values and the default state. -func NewExpandedTextMessage(evt *muksevt.Event, displayname string, text tstring.TString) *UIMessage { - return newUIMessage(evt, displayname, &ExpandedTextMessage{ - Text: text, - }) -} - -func NewServiceMessage(text string) *UIMessage { - return &UIMessage{ - SenderID: "*", - SenderName: "*", - Timestamp: time.Now(), - IsService: true, - Renderer: &ExpandedTextMessage{ - Text: tstring.NewTString(text), - }, - } -} - -func NewDateChangeMessage(text string) *UIMessage { - midnight := time.Now() - midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(), - 0, 0, 0, 0, - midnight.Location()) - return &UIMessage{ - SenderID: "*", - SenderName: "*", - Timestamp: midnight, - IsService: true, - Renderer: &ExpandedTextMessage{ - Text: tstring.NewColorTString(text, tcell.ColorGreen), - }, - } -} - -func (msg *ExpandedTextMessage) Clone() MessageRenderer { - return &ExpandedTextMessage{ - Text: msg.Text.Clone(), - } -} - -func (msg *ExpandedTextMessage) NotificationContent() string { - return msg.Text.String() -} - -func (msg *ExpandedTextMessage) PlainText() string { - return msg.Text.String() -} - -func (msg *ExpandedTextMessage) String() string { - return fmt.Sprintf(`&messages.ExpandedTextMessage{Text="%s"}`, msg.Text.String()) -} - -func (msg *ExpandedTextMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) { - msg.buffer = calculateBufferWithText(prefs, msg.Text, width, uiMsg) -} - -func (msg *ExpandedTextMessage) Height() int { - return len(msg.buffer) -} - -func (msg *ExpandedTextMessage) Draw(screen mauview.Screen, _ *UIMessage) { - for y, line := range msg.buffer { - line.Draw(screen, 0, y) - } -} diff --git a/ui/messages/filemessage.go b/ui/messages/filemessage.go deleted file mode 100644 index abc0b52..0000000 --- a/ui/messages/filemessage.go +++ /dev/null @@ -1,190 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package messages - -import ( - "bytes" - "fmt" - "image" - "image/color" - - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/lib/ansimage" - "maunium.net/go/gomuks/matrix/muksevt" - "maunium.net/go/gomuks/ui/messages/tstring" -) - -type FileMessage struct { - Type event.MessageType - Body string - - URL id.ContentURI - File *attachment.EncryptedFile - Thumbnail id.ContentURI - ThumbnailFile *attachment.EncryptedFile - - eventID id.EventID - - imageData []byte - buffer []tstring.TString - - matrix ifc.MatrixContainer -} - -// NewFileMessage creates a new FileMessage object with the provided values and the default state. -func NewFileMessage(matrix ifc.MatrixContainer, evt *muksevt.Event, displayname string) *UIMessage { - content := evt.Content.AsMessage() - var file, thumbnailFile *attachment.EncryptedFile - if content.File != nil { - file = &content.File.EncryptedFile - content.URL = content.File.URL - } - if content.GetInfo().ThumbnailFile != nil { - thumbnailFile = &content.Info.ThumbnailFile.EncryptedFile - content.Info.ThumbnailURL = content.Info.ThumbnailFile.URL - } - return newUIMessage(evt, displayname, &FileMessage{ - Type: content.MsgType, - Body: content.Body, - URL: content.URL.ParseOrIgnore(), - File: file, - Thumbnail: content.GetInfo().ThumbnailURL.ParseOrIgnore(), - ThumbnailFile: thumbnailFile, - eventID: evt.ID, - matrix: matrix, - }) -} - -func (msg *FileMessage) Clone() MessageRenderer { - data := make([]byte, len(msg.imageData)) - copy(data, msg.imageData) - return &FileMessage{ - Body: msg.Body, - URL: msg.URL, - Thumbnail: msg.Thumbnail, - imageData: data, - matrix: msg.matrix, - } -} - -func (msg *FileMessage) NotificationContent() string { - switch msg.Type { - case event.MsgImage: - return "Sent an image" - case event.MsgAudio: - return "Sent an audio file" - case event.MsgVideo: - return "Sent a video" - case event.MsgFile: - fallthrough - default: - return "Sent a file" - } -} - -func (msg *FileMessage) PlainText() string { - return fmt.Sprintf("%s: %s", msg.Body, msg.matrix.GetDownloadURL(msg.URL, msg.File)) -} - -func (msg *FileMessage) String() string { - return fmt.Sprintf(`&messages.FileMessage{Body="%s", URL="%s", Thumbnail="%s"}`, msg.Body, msg.URL, msg.Thumbnail) -} - -func (msg *FileMessage) DownloadPreview() { - var url id.ContentURI - var file *attachment.EncryptedFile - if !msg.Thumbnail.IsEmpty() { - url = msg.Thumbnail - file = msg.ThumbnailFile - } else if msg.Type == event.MsgImage && !msg.URL.IsEmpty() { - msg.Thumbnail = msg.URL - url = msg.URL - file = msg.File - } else { - return - } - debug.Print("Loading file:", url) - data, err := msg.matrix.Download(url, file) - if err != nil { - debug.Printf("Failed to download file %s: %v", url, err) - return - } - debug.Print("File", url, "loaded.") - msg.imageData = data -} - -func (msg *FileMessage) ThumbnailPath() string { - return msg.matrix.GetCachePath(msg.Thumbnail) -} - -func (msg *FileMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) { - if width < 2 { - return - } - - if prefs.BareMessageView || prefs.DisableImages || len(msg.imageData) == 0 { - url := msg.matrix.GetDownloadURL(msg.URL, msg.File) - var urlTString tstring.TString - if prefs.EnableInlineURLs() { - urlTString = tstring.NewStyleTString(url, tcell.StyleDefault.Url(url).UrlId(msg.eventID.String())) - } else { - urlTString = tstring.NewTString(url) - } - text := tstring.NewTString(msg.Body). - Append(": "). - AppendTString(urlTString) - msg.buffer = calculateBufferWithText(prefs, text, width, uiMsg) - return - } - - img, _, err := image.DecodeConfig(bytes.NewReader(msg.imageData)) - if err != nil { - debug.Print("File could not be decoded:", err) - } - imgWidth := img.Width - if img.Width > width { - imgWidth = width / 3 - } - - ansFile, err := ansimage.NewScaledFromReader(bytes.NewReader(msg.imageData), 0, imgWidth, color.Black) - if err != nil { - msg.buffer = []tstring.TString{tstring.NewColorTString("Failed to display image", tcell.ColorRed)} - debug.Print("Failed to display image:", err) - return - } - - msg.buffer = ansFile.Render() -} - -func (msg *FileMessage) Height() int { - return len(msg.buffer) -} - -func (msg *FileMessage) Draw(screen mauview.Screen, _ *UIMessage) { - for y, line := range msg.buffer { - line.Draw(screen, 0, y) - } -} diff --git a/ui/messages/html/base.go b/ui/messages/html/base.go deleted file mode 100644 index 16eb9fb..0000000 --- a/ui/messages/html/base.go +++ /dev/null @@ -1,101 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -package html - -import ( - "fmt" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" -) - -type BaseEntity struct { - // The HTML tag of this entity. - Tag string - // Style for this entity. - Style tcell.Style - // Whether or not this is a block-type entity. - Block bool - // Height to use for entity if both text and children are empty. - DefaultHeight int - - prevWidth int - startX int - height int -} - -// AdjustStyle changes the style of this text entity. -func (be *BaseEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { - be.Style = fn(be.Style) - return be -} - -func (be *BaseEntity) IsEmpty() bool { - return false -} - -// IsBlock returns whether or not this is a block-type entity. -func (be *BaseEntity) IsBlock() bool { - return be.Block -} - -// GetTag returns the HTML tag of this entity. -func (be *BaseEntity) GetTag() string { - return be.Tag -} - -// Height returns the render height of this entity. -func (be *BaseEntity) Height() int { - return be.height -} - -func (be *BaseEntity) getStartX() int { - return be.startX -} - -// Clone creates a copy of this base entity. -func (be *BaseEntity) Clone() Entity { - return &BaseEntity{ - Tag: be.Tag, - Style: be.Style, - Block: be.Block, - DefaultHeight: be.DefaultHeight, - } -} - -func (be *BaseEntity) PlainText() string { - return "" -} - -// String returns a textual representation of this BaseEntity struct. -func (be *BaseEntity) String() string { - return fmt.Sprintf(`&html.BaseEntity{Tag="%s", Style=%#v, Block=%t, startX=%d, height=%d}`, - be.Tag, be.Style, be.Block, be.startX, be.height) -} - -// CalculateBuffer prepares this entity for rendering with the given parameters. -func (be *BaseEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { - be.height = be.DefaultHeight - be.startX = startX - if be.Block { - be.startX = 0 - } - return be.startX -} - -func (be *BaseEntity) Draw(screen mauview.Screen, ctx DrawContext) { - panic("Called Draw() of BaseEntity") -} diff --git a/ui/messages/html/blockquote.go b/ui/messages/html/blockquote.go deleted file mode 100644 index 25123da..0000000 --- a/ui/messages/html/blockquote.go +++ /dev/null @@ -1,88 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package html - -import ( - "fmt" - "strings" - - "go.mau.fi/mauview" -) - -type BlockquoteEntity struct { - *ContainerEntity -} - -const BlockQuoteChar = '>' - -func NewBlockquoteEntity(children []Entity) *BlockquoteEntity { - return &BlockquoteEntity{&ContainerEntity{ - BaseEntity: &BaseEntity{ - Tag: "blockquote", - Block: true, - }, - Children: children, - Indent: 2, - }} -} - -func (be *BlockquoteEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { - be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) - return be -} - -func (be *BlockquoteEntity) Clone() Entity { - return &BlockquoteEntity{ContainerEntity: be.ContainerEntity.Clone().(*ContainerEntity)} -} - -func (be *BlockquoteEntity) Draw(screen mauview.Screen, ctx DrawContext) { - be.ContainerEntity.Draw(screen, ctx) - for y := 0; y < be.height; y++ { - screen.SetContent(0, y, BlockQuoteChar, nil, be.Style) - } -} - -func (be *BlockquoteEntity) PlainText() string { - if len(be.Children) == 0 { - return "" - } - var buf strings.Builder - newlined := false - for i, child := range be.Children { - if i != 0 && child.IsBlock() && !newlined { - buf.WriteRune('\n') - } - newlined = false - for i, row := range strings.Split(child.PlainText(), "\n") { - if i != 0 { - buf.WriteRune('\n') - } - buf.WriteRune('>') - buf.WriteRune(' ') - buf.WriteString(row) - } - if child.IsBlock() { - buf.WriteRune('\n') - newlined = true - } - } - return strings.TrimSpace(buf.String()) -} - -func (be *BlockquoteEntity) String() string { - return fmt.Sprintf("&html.BlockquoteEntity{%s},\n", be.BaseEntity) -} diff --git a/ui/messages/html/break.go b/ui/messages/html/break.go deleted file mode 100644 index f702a76..0000000 --- a/ui/messages/html/break.go +++ /dev/null @@ -1,54 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package html - -import ( - "go.mau.fi/mauview" -) - -type BreakEntity struct { - *BaseEntity -} - -func NewBreakEntity() *BreakEntity { - return &BreakEntity{&BaseEntity{ - Tag: "br", - Block: true, - }} -} - -// AdjustStyle changes the style of this text entity. -func (be *BreakEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { - be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) - return be -} - -func (be *BreakEntity) Clone() Entity { - return NewBreakEntity() -} - -func (be *BreakEntity) PlainText() string { - return "\n" -} - -func (be *BreakEntity) String() string { - return "&html.BreakEntity{},\n" -} - -func (be *BreakEntity) Draw(screen mauview.Screen, ctx DrawContext) { - // No-op, the logic happens in containers -} diff --git a/ui/messages/html/codeblock.go b/ui/messages/html/codeblock.go deleted file mode 100644 index 42d4433..0000000 --- a/ui/messages/html/codeblock.go +++ /dev/null @@ -1,59 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package html - -import ( - "go.mau.fi/mauview" - "go.mau.fi/tcell" -) - -type CodeBlockEntity struct { - *ContainerEntity - Background tcell.Style -} - -func NewCodeBlockEntity(children []Entity, background tcell.Style) *CodeBlockEntity { - return &CodeBlockEntity{ - ContainerEntity: &ContainerEntity{ - BaseEntity: &BaseEntity{ - Tag: "pre", - Block: true, - }, - Children: children, - }, - Background: background, - } -} - -func (ce *CodeBlockEntity) Clone() Entity { - return &CodeBlockEntity{ - ContainerEntity: ce.ContainerEntity.Clone().(*ContainerEntity), - Background: ce.Background, - } -} - -func (ce *CodeBlockEntity) Draw(screen mauview.Screen, ctx DrawContext) { - screen.Fill(' ', ce.Background) - ce.ContainerEntity.Draw(screen, ctx) -} - -func (ce *CodeBlockEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { - if reason != AdjustStyleReasonNormal { - ce.ContainerEntity.AdjustStyle(fn, reason) - } - return ce -} diff --git a/ui/messages/html/colormap.go b/ui/messages/html/colormap.go deleted file mode 100644 index 305309c..0000000 --- a/ui/messages/html/colormap.go +++ /dev/null @@ -1,156 +0,0 @@ -// From https://github.com/golang/image/blob/master/colornames/colornames.go -package html - -import ( - "image/color" -) - -var colorMap = map[string]color.RGBA{ - "aliceblue": {0xf0, 0xf8, 0xff, 0xff}, // rgb(240, 248, 255) - "antiquewhite": {0xfa, 0xeb, 0xd7, 0xff}, // rgb(250, 235, 215) - "aqua": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255) - "aquamarine": {0x7f, 0xff, 0xd4, 0xff}, // rgb(127, 255, 212) - "azure": {0xf0, 0xff, 0xff, 0xff}, // rgb(240, 255, 255) - "beige": {0xf5, 0xf5, 0xdc, 0xff}, // rgb(245, 245, 220) - "bisque": {0xff, 0xe4, 0xc4, 0xff}, // rgb(255, 228, 196) - "black": {0x00, 0x00, 0x00, 0xff}, // rgb(0, 0, 0) - "blanchedalmond": {0xff, 0xeb, 0xcd, 0xff}, // rgb(255, 235, 205) - "blue": {0x00, 0x00, 0xff, 0xff}, // rgb(0, 0, 255) - "blueviolet": {0x8a, 0x2b, 0xe2, 0xff}, // rgb(138, 43, 226) - "brown": {0xa5, 0x2a, 0x2a, 0xff}, // rgb(165, 42, 42) - "burlywood": {0xde, 0xb8, 0x87, 0xff}, // rgb(222, 184, 135) - "cadetblue": {0x5f, 0x9e, 0xa0, 0xff}, // rgb(95, 158, 160) - "chartreuse": {0x7f, 0xff, 0x00, 0xff}, // rgb(127, 255, 0) - "chocolate": {0xd2, 0x69, 0x1e, 0xff}, // rgb(210, 105, 30) - "coral": {0xff, 0x7f, 0x50, 0xff}, // rgb(255, 127, 80) - "cornflowerblue": {0x64, 0x95, 0xed, 0xff}, // rgb(100, 149, 237) - "cornsilk": {0xff, 0xf8, 0xdc, 0xff}, // rgb(255, 248, 220) - "crimson": {0xdc, 0x14, 0x3c, 0xff}, // rgb(220, 20, 60) - "cyan": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255) - "darkblue": {0x00, 0x00, 0x8b, 0xff}, // rgb(0, 0, 139) - "darkcyan": {0x00, 0x8b, 0x8b, 0xff}, // rgb(0, 139, 139) - "darkgoldenrod": {0xb8, 0x86, 0x0b, 0xff}, // rgb(184, 134, 11) - "darkgray": {0xa9, 0xa9, 0xa9, 0xff}, // rgb(169, 169, 169) - "darkgreen": {0x00, 0x64, 0x00, 0xff}, // rgb(0, 100, 0) - "darkgrey": {0xa9, 0xa9, 0xa9, 0xff}, // rgb(169, 169, 169) - "darkkhaki": {0xbd, 0xb7, 0x6b, 0xff}, // rgb(189, 183, 107) - "darkmagenta": {0x8b, 0x00, 0x8b, 0xff}, // rgb(139, 0, 139) - "darkolivegreen": {0x55, 0x6b, 0x2f, 0xff}, // rgb(85, 107, 47) - "darkorange": {0xff, 0x8c, 0x00, 0xff}, // rgb(255, 140, 0) - "darkorchid": {0x99, 0x32, 0xcc, 0xff}, // rgb(153, 50, 204) - "darkred": {0x8b, 0x00, 0x00, 0xff}, // rgb(139, 0, 0) - "darksalmon": {0xe9, 0x96, 0x7a, 0xff}, // rgb(233, 150, 122) - "darkseagreen": {0x8f, 0xbc, 0x8f, 0xff}, // rgb(143, 188, 143) - "darkslateblue": {0x48, 0x3d, 0x8b, 0xff}, // rgb(72, 61, 139) - "darkslategray": {0x2f, 0x4f, 0x4f, 0xff}, // rgb(47, 79, 79) - "darkslategrey": {0x2f, 0x4f, 0x4f, 0xff}, // rgb(47, 79, 79) - "darkturquoise": {0x00, 0xce, 0xd1, 0xff}, // rgb(0, 206, 209) - "darkviolet": {0x94, 0x00, 0xd3, 0xff}, // rgb(148, 0, 211) - "deeppink": {0xff, 0x14, 0x93, 0xff}, // rgb(255, 20, 147) - "deepskyblue": {0x00, 0xbf, 0xff, 0xff}, // rgb(0, 191, 255) - "dimgray": {0x69, 0x69, 0x69, 0xff}, // rgb(105, 105, 105) - "dimgrey": {0x69, 0x69, 0x69, 0xff}, // rgb(105, 105, 105) - "dodgerblue": {0x1e, 0x90, 0xff, 0xff}, // rgb(30, 144, 255) - "firebrick": {0xb2, 0x22, 0x22, 0xff}, // rgb(178, 34, 34) - "floralwhite": {0xff, 0xfa, 0xf0, 0xff}, // rgb(255, 250, 240) - "forestgreen": {0x22, 0x8b, 0x22, 0xff}, // rgb(34, 139, 34) - "fuchsia": {0xff, 0x00, 0xff, 0xff}, // rgb(255, 0, 255) - "gainsboro": {0xdc, 0xdc, 0xdc, 0xff}, // rgb(220, 220, 220) - "ghostwhite": {0xf8, 0xf8, 0xff, 0xff}, // rgb(248, 248, 255) - "gold": {0xff, 0xd7, 0x00, 0xff}, // rgb(255, 215, 0) - "goldenrod": {0xda, 0xa5, 0x20, 0xff}, // rgb(218, 165, 32) - "gray": {0x80, 0x80, 0x80, 0xff}, // rgb(128, 128, 128) - "green": {0x00, 0x80, 0x00, 0xff}, // rgb(0, 128, 0) - "greenyellow": {0xad, 0xff, 0x2f, 0xff}, // rgb(173, 255, 47) - "grey": {0x80, 0x80, 0x80, 0xff}, // rgb(128, 128, 128) - "honeydew": {0xf0, 0xff, 0xf0, 0xff}, // rgb(240, 255, 240) - "hotpink": {0xff, 0x69, 0xb4, 0xff}, // rgb(255, 105, 180) - "indianred": {0xcd, 0x5c, 0x5c, 0xff}, // rgb(205, 92, 92) - "indigo": {0x4b, 0x00, 0x82, 0xff}, // rgb(75, 0, 130) - "ivory": {0xff, 0xff, 0xf0, 0xff}, // rgb(255, 255, 240) - "khaki": {0xf0, 0xe6, 0x8c, 0xff}, // rgb(240, 230, 140) - "lavender": {0xe6, 0xe6, 0xfa, 0xff}, // rgb(230, 230, 250) - "lavenderblush": {0xff, 0xf0, 0xf5, 0xff}, // rgb(255, 240, 245) - "lawngreen": {0x7c, 0xfc, 0x00, 0xff}, // rgb(124, 252, 0) - "lemonchiffon": {0xff, 0xfa, 0xcd, 0xff}, // rgb(255, 250, 205) - "lightblue": {0xad, 0xd8, 0xe6, 0xff}, // rgb(173, 216, 230) - "lightcoral": {0xf0, 0x80, 0x80, 0xff}, // rgb(240, 128, 128) - "lightcyan": {0xe0, 0xff, 0xff, 0xff}, // rgb(224, 255, 255) - "lightgoldenrodyellow": {0xfa, 0xfa, 0xd2, 0xff}, // rgb(250, 250, 210) - "lightgray": {0xd3, 0xd3, 0xd3, 0xff}, // rgb(211, 211, 211) - "lightgreen": {0x90, 0xee, 0x90, 0xff}, // rgb(144, 238, 144) - "lightgrey": {0xd3, 0xd3, 0xd3, 0xff}, // rgb(211, 211, 211) - "lightpink": {0xff, 0xb6, 0xc1, 0xff}, // rgb(255, 182, 193) - "lightsalmon": {0xff, 0xa0, 0x7a, 0xff}, // rgb(255, 160, 122) - "lightseagreen": {0x20, 0xb2, 0xaa, 0xff}, // rgb(32, 178, 170) - "lightskyblue": {0x87, 0xce, 0xfa, 0xff}, // rgb(135, 206, 250) - "lightslategray": {0x77, 0x88, 0x99, 0xff}, // rgb(119, 136, 153) - "lightslategrey": {0x77, 0x88, 0x99, 0xff}, // rgb(119, 136, 153) - "lightsteelblue": {0xb0, 0xc4, 0xde, 0xff}, // rgb(176, 196, 222) - "lightyellow": {0xff, 0xff, 0xe0, 0xff}, // rgb(255, 255, 224) - "lime": {0x00, 0xff, 0x00, 0xff}, // rgb(0, 255, 0) - "limegreen": {0x32, 0xcd, 0x32, 0xff}, // rgb(50, 205, 50) - "linen": {0xfa, 0xf0, 0xe6, 0xff}, // rgb(250, 240, 230) - "magenta": {0xff, 0x00, 0xff, 0xff}, // rgb(255, 0, 255) - "maroon": {0x80, 0x00, 0x00, 0xff}, // rgb(128, 0, 0) - "mediumaquamarine": {0x66, 0xcd, 0xaa, 0xff}, // rgb(102, 205, 170) - "mediumblue": {0x00, 0x00, 0xcd, 0xff}, // rgb(0, 0, 205) - "mediumorchid": {0xba, 0x55, 0xd3, 0xff}, // rgb(186, 85, 211) - "mediumpurple": {0x93, 0x70, 0xdb, 0xff}, // rgb(147, 112, 219) - "mediumseagreen": {0x3c, 0xb3, 0x71, 0xff}, // rgb(60, 179, 113) - "mediumslateblue": {0x7b, 0x68, 0xee, 0xff}, // rgb(123, 104, 238) - "mediumspringgreen": {0x00, 0xfa, 0x9a, 0xff}, // rgb(0, 250, 154) - "mediumturquoise": {0x48, 0xd1, 0xcc, 0xff}, // rgb(72, 209, 204) - "mediumvioletred": {0xc7, 0x15, 0x85, 0xff}, // rgb(199, 21, 133) - "midnightblue": {0x19, 0x19, 0x70, 0xff}, // rgb(25, 25, 112) - "mintcream": {0xf5, 0xff, 0xfa, 0xff}, // rgb(245, 255, 250) - "mistyrose": {0xff, 0xe4, 0xe1, 0xff}, // rgb(255, 228, 225) - "moccasin": {0xff, 0xe4, 0xb5, 0xff}, // rgb(255, 228, 181) - "navajowhite": {0xff, 0xde, 0xad, 0xff}, // rgb(255, 222, 173) - "navy": {0x00, 0x00, 0x80, 0xff}, // rgb(0, 0, 128) - "oldlace": {0xfd, 0xf5, 0xe6, 0xff}, // rgb(253, 245, 230) - "olive": {0x80, 0x80, 0x00, 0xff}, // rgb(128, 128, 0) - "olivedrab": {0x6b, 0x8e, 0x23, 0xff}, // rgb(107, 142, 35) - "orange": {0xff, 0xa5, 0x00, 0xff}, // rgb(255, 165, 0) - "orangered": {0xff, 0x45, 0x00, 0xff}, // rgb(255, 69, 0) - "orchid": {0xda, 0x70, 0xd6, 0xff}, // rgb(218, 112, 214) - "palegoldenrod": {0xee, 0xe8, 0xaa, 0xff}, // rgb(238, 232, 170) - "palegreen": {0x98, 0xfb, 0x98, 0xff}, // rgb(152, 251, 152) - "paleturquoise": {0xaf, 0xee, 0xee, 0xff}, // rgb(175, 238, 238) - "palevioletred": {0xdb, 0x70, 0x93, 0xff}, // rgb(219, 112, 147) - "papayawhip": {0xff, 0xef, 0xd5, 0xff}, // rgb(255, 239, 213) - "peachpuff": {0xff, 0xda, 0xb9, 0xff}, // rgb(255, 218, 185) - "peru": {0xcd, 0x85, 0x3f, 0xff}, // rgb(205, 133, 63) - "pink": {0xff, 0xc0, 0xcb, 0xff}, // rgb(255, 192, 203) - "plum": {0xdd, 0xa0, 0xdd, 0xff}, // rgb(221, 160, 221) - "powderblue": {0xb0, 0xe0, 0xe6, 0xff}, // rgb(176, 224, 230) - "purple": {0x80, 0x00, 0x80, 0xff}, // rgb(128, 0, 128) - "red": {0xff, 0x00, 0x00, 0xff}, // rgb(255, 0, 0) - "rosybrown": {0xbc, 0x8f, 0x8f, 0xff}, // rgb(188, 143, 143) - "royalblue": {0x41, 0x69, 0xe1, 0xff}, // rgb(65, 105, 225) - "saddlebrown": {0x8b, 0x45, 0x13, 0xff}, // rgb(139, 69, 19) - "salmon": {0xfa, 0x80, 0x72, 0xff}, // rgb(250, 128, 114) - "sandybrown": {0xf4, 0xa4, 0x60, 0xff}, // rgb(244, 164, 96) - "seagreen": {0x2e, 0x8b, 0x57, 0xff}, // rgb(46, 139, 87) - "seashell": {0xff, 0xf5, 0xee, 0xff}, // rgb(255, 245, 238) - "sienna": {0xa0, 0x52, 0x2d, 0xff}, // rgb(160, 82, 45) - "silver": {0xc0, 0xc0, 0xc0, 0xff}, // rgb(192, 192, 192) - "skyblue": {0x87, 0xce, 0xeb, 0xff}, // rgb(135, 206, 235) - "slateblue": {0x6a, 0x5a, 0xcd, 0xff}, // rgb(106, 90, 205) - "slategray": {0x70, 0x80, 0x90, 0xff}, // rgb(112, 128, 144) - "slategrey": {0x70, 0x80, 0x90, 0xff}, // rgb(112, 128, 144) - "snow": {0xff, 0xfa, 0xfa, 0xff}, // rgb(255, 250, 250) - "springgreen": {0x00, 0xff, 0x7f, 0xff}, // rgb(0, 255, 127) - "steelblue": {0x46, 0x82, 0xb4, 0xff}, // rgb(70, 130, 180) - "tan": {0xd2, 0xb4, 0x8c, 0xff}, // rgb(210, 180, 140) - "teal": {0x00, 0x80, 0x80, 0xff}, // rgb(0, 128, 128) - "thistle": {0xd8, 0xbf, 0xd8, 0xff}, // rgb(216, 191, 216) - "tomato": {0xff, 0x63, 0x47, 0xff}, // rgb(255, 99, 71) - "turquoise": {0x40, 0xe0, 0xd0, 0xff}, // rgb(64, 224, 208) - "violet": {0xee, 0x82, 0xee, 0xff}, // rgb(238, 130, 238) - "wheat": {0xf5, 0xde, 0xb3, 0xff}, // rgb(245, 222, 179) - "white": {0xff, 0xff, 0xff, 0xff}, // rgb(255, 255, 255) - "whitesmoke": {0xf5, 0xf5, 0xf5, 0xff}, // rgb(245, 245, 245) - "yellow": {0xff, 0xff, 0x00, 0xff}, // rgb(255, 255, 0) - "yellowgreen": {0x9a, 0xcd, 0x32, 0xff}, // rgb(154, 205, 50) -} diff --git a/ui/messages/html/container.go b/ui/messages/html/container.go deleted file mode 100644 index 17e7aa9..0000000 --- a/ui/messages/html/container.go +++ /dev/null @@ -1,148 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package html - -import ( - "fmt" - "strings" - - "go.mau.fi/mauview" -) - -type ContainerEntity struct { - *BaseEntity - - // The children of this container entity. - Children []Entity - // Number of cells to indent children. - Indent int -} - -func (ce *ContainerEntity) IsEmpty() bool { - return len(ce.Children) == 0 -} - -// PlainText returns the plaintext content in this entity and all its children. -func (ce *ContainerEntity) PlainText() string { - if len(ce.Children) == 0 { - return "" - } - var buf strings.Builder - newlined := false - for _, child := range ce.Children { - text := child.PlainText() - if !strings.HasPrefix(text, "\n") && child.IsBlock() && !newlined { - buf.WriteRune('\n') - } - newlined = false - buf.WriteString(text) - if child.IsBlock() { - if !strings.HasSuffix(text, "\n") { - buf.WriteRune('\n') - } - newlined = true - } - } - return strings.TrimSpace(buf.String()) -} - -// AdjustStyle recursively changes the style of this entity and all its children. -func (ce *ContainerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { - for _, child := range ce.Children { - child.AdjustStyle(fn, reason) - } - ce.Style = fn(ce.Style) - return ce -} - -// Clone creates a deep copy of this base entity. -func (ce *ContainerEntity) Clone() Entity { - children := make([]Entity, len(ce.Children)) - for i, child := range ce.Children { - children[i] = child.Clone() - } - return &ContainerEntity{ - BaseEntity: ce.BaseEntity.Clone().(*BaseEntity), - Children: children, - Indent: ce.Indent, - } -} - -// String returns a textual representation of this BaseEntity struct. -func (ce *ContainerEntity) String() string { - if len(ce.Children) == 0 { - return fmt.Sprintf(`&html.ContainerEntity{Base=%s, Indent=%d, Children=[]}`, ce.BaseEntity, ce.Indent) - } - var buf strings.Builder - _, _ = fmt.Fprintf(&buf, `&html.ContainerEntity{Base=%s, Indent=%d, Children=[`, ce.BaseEntity, ce.Indent) - for _, child := range ce.Children { - buf.WriteString("\n ") - buf.WriteString(strings.Join(strings.Split(strings.TrimRight(child.String(), "\n"), "\n"), "\n ")) - } - buf.WriteString("\n]},") - return buf.String() -} - -// Draw draws this entity onto the given mauview Screen. -func (ce *ContainerEntity) Draw(screen mauview.Screen, ctx DrawContext) { - if len(ce.Children) == 0 { - return - } - width, _ := screen.Size() - prevBreak := false - proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: ce.Indent, Width: width - ce.Indent, Style: ce.Style} - for i, entity := range ce.Children { - if i != 0 && entity.getStartX() == 0 { - proxyScreen.OffsetY++ - } - proxyScreen.Height = entity.Height() - entity.Draw(proxyScreen, ctx) - proxyScreen.SetStyle(ce.Style) - proxyScreen.OffsetY += entity.Height() - 1 - _, isBreak := entity.(*BreakEntity) - if prevBreak && isBreak { - proxyScreen.OffsetY++ - } - prevBreak = isBreak - } -} - -// CalculateBuffer prepares this entity and all its children for rendering with the given parameters -func (ce *ContainerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { - ce.BaseEntity.CalculateBuffer(width, startX, ctx) - if len(ce.Children) > 0 { - ce.height = 0 - childStartX := ce.startX - prevBreak := false - for _, entity := range ce.Children { - if entity.IsBlock() || childStartX == 0 || ce.height == 0 { - ce.height++ - } - childStartX = entity.CalculateBuffer(width-ce.Indent, childStartX, ctx) - ce.height += entity.Height() - 1 - _, isBreak := entity.(*BreakEntity) - if prevBreak && isBreak { - ce.height++ - } - prevBreak = isBreak - } - if !ce.Block { - return childStartX - } - } - return ce.startX -} diff --git a/ui/messages/html/entity.go b/ui/messages/html/entity.go deleted file mode 100644 index 094fa10..0000000 --- a/ui/messages/html/entity.go +++ /dev/null @@ -1,63 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package html - -import ( - "go.mau.fi/mauview" - "go.mau.fi/tcell" -) - -// AdjustStyleFunc is a lambda function type to edit an existing tcell Style. -type AdjustStyleFunc func(tcell.Style) tcell.Style - -type AdjustStyleReason int - -const ( - AdjustStyleReasonNormal AdjustStyleReason = iota - AdjustStyleReasonHideSpoiler -) - -type DrawContext struct { - IsSelected bool - BareMessages bool -} - -type Entity interface { - // AdjustStyle recursively changes the style of the entity and all its children. - AdjustStyle(AdjustStyleFunc, AdjustStyleReason) Entity - // Draw draws the entity onto the given mauview Screen. - Draw(screen mauview.Screen, ctx DrawContext) - // IsBlock returns whether or not it's a block-type entity. - IsBlock() bool - // GetTag returns the HTML tag of the entity. - GetTag() string - // PlainText returns the plaintext content in the entity and all its children. - PlainText() string - // String returns a string representation of the entity struct. - String() string - // Clone creates a deep copy of the entity. - Clone() Entity - - // Height returns the render height of the entity. - Height() int - // CalculateBuffer prepares the entity and all its children for rendering with the given parameters - CalculateBuffer(width, startX int, ctx DrawContext) int - - getStartX() int - - IsEmpty() bool -} diff --git a/ui/messages/html/horizontalline.go b/ui/messages/html/horizontalline.go deleted file mode 100644 index ff12709..0000000 --- a/ui/messages/html/horizontalline.go +++ /dev/null @@ -1,61 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package html - -import ( - "strings" - - "go.mau.fi/mauview" -) - -type HorizontalLineEntity struct { - *BaseEntity -} - -const HorizontalLineChar = '━' - -func NewHorizontalLineEntity() *HorizontalLineEntity { - return &HorizontalLineEntity{&BaseEntity{ - Tag: "hr", - Block: true, - DefaultHeight: 1, - }} -} - -func (he *HorizontalLineEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { - he.BaseEntity = he.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) - return he -} - -func (he *HorizontalLineEntity) Clone() Entity { - return NewHorizontalLineEntity() -} - -func (he *HorizontalLineEntity) Draw(screen mauview.Screen, ctx DrawContext) { - width, _ := screen.Size() - for x := 0; x < width; x++ { - screen.SetContent(x, 0, HorizontalLineChar, nil, he.Style) - } -} - -func (he *HorizontalLineEntity) PlainText() string { - return strings.Repeat(string(HorizontalLineChar), 5) -} - -func (he *HorizontalLineEntity) String() string { - return "&html.HorizontalLineEntity{},\n" -} diff --git a/ui/messages/html/list.go b/ui/messages/html/list.go deleted file mode 100644 index b01c2d6..0000000 --- a/ui/messages/html/list.go +++ /dev/null @@ -1,123 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package html - -import ( - "fmt" - "strings" - - "go.mau.fi/mauview" - - "maunium.net/go/gomuks/ui/widget" - "maunium.net/go/mautrix/format" -) - -type ListEntity struct { - *ContainerEntity - Ordered bool - Start int -} - -func NewListEntity(ordered bool, start int, children []Entity) *ListEntity { - entity := &ListEntity{ - ContainerEntity: &ContainerEntity{ - BaseEntity: &BaseEntity{ - Tag: "ul", - Block: true, - }, - Indent: 2, - Children: children, - }, - Ordered: ordered, - Start: start, - } - if ordered { - entity.Tag = "ol" - entity.Indent += format.Digits(start + len(children) - 1) - } - return entity -} - -func (le *ListEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { - le.BaseEntity = le.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) - le.ContainerEntity.AdjustStyle(fn, reason) - return le -} - -func (le *ListEntity) Clone() Entity { - return &ListEntity{ - ContainerEntity: le.ContainerEntity.Clone().(*ContainerEntity), - Ordered: le.Ordered, - Start: le.Start, - } -} - -func (le *ListEntity) paddingFor(number int) string { - padding := le.Indent - 2 - format.Digits(number) - if padding <= 0 { - return "" - } - return strings.Repeat(" ", padding) -} - -func (le *ListEntity) Draw(screen mauview.Screen, ctx DrawContext) { - width, _ := screen.Size() - - proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: le.Indent, Width: width - le.Indent, Style: le.Style} - for i, entity := range le.Children { - proxyScreen.Height = entity.Height() - if le.Ordered { - number := le.Start + i - line := fmt.Sprintf("%d. %s", number, le.paddingFor(number)) - widget.WriteLine(screen, mauview.AlignLeft, line, 0, proxyScreen.OffsetY, le.Indent, le.Style) - } else { - screen.SetContent(0, proxyScreen.OffsetY, '●', nil, le.Style) - } - entity.Draw(proxyScreen, ctx) - proxyScreen.SetStyle(le.Style) - proxyScreen.OffsetY += entity.Height() - } -} - -func (le *ListEntity) PlainText() string { - if len(le.Children) == 0 { - return "" - } - var buf strings.Builder - for i, child := range le.Children { - indent := strings.Repeat(" ", le.Indent) - if le.Ordered { - number := le.Start + i - _, _ = fmt.Fprintf(&buf, "%d. %s", number, le.paddingFor(number)) - } else { - buf.WriteString("● ") - } - for j, row := range strings.Split(child.PlainText(), "\n") { - if j != 0 { - buf.WriteRune('\n') - buf.WriteString(indent) - } - buf.WriteString(row) - } - buf.WriteRune('\n') - } - return strings.TrimSpace(buf.String()) -} - -func (le *ListEntity) String() string { - return fmt.Sprintf("&html.ListEntity{Ordered=%t, Start=%d, Base=%s},\n", le.Ordered, le.Start, le.BaseEntity) -} diff --git a/ui/messages/html/parser.go b/ui/messages/html/parser.go deleted file mode 100644 index fd10c92..0000000 --- a/ui/messages/html/parser.go +++ /dev/null @@ -1,552 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package html - -import ( - "fmt" - "regexp" - "strconv" - "strings" - - "github.com/alecthomas/chroma" - "github.com/alecthomas/chroma/lexers" - "github.com/alecthomas/chroma/styles" - "github.com/lucasb-eyer/go-colorful" - "golang.org/x/net/html" - "mvdan.cc/xurls/v2" - - "go.mau.fi/tcell" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/matrix/muksevt" - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/widget" -) - -type htmlParser struct { - prefs *config.UserPreferences - room *rooms.Room - evt *muksevt.Event - - preserveWhitespace bool - linkIDCounter int -} - -func AdjustStyleBold(style tcell.Style) tcell.Style { - return style.Bold(true) -} - -func AdjustStyleItalic(style tcell.Style) tcell.Style { - return style.Italic(true) -} - -func AdjustStyleUnderline(style tcell.Style) tcell.Style { - return style.Underline(true) -} - -func AdjustStyleStrikethrough(style tcell.Style) tcell.Style { - return style.StrikeThrough(true) -} - -func AdjustStyleTextColor(color tcell.Color) AdjustStyleFunc { - return func(style tcell.Style) tcell.Style { - return style.Foreground(color) - } -} - -func AdjustStyleBackgroundColor(color tcell.Color) AdjustStyleFunc { - return func(style tcell.Style) tcell.Style { - return style.Background(color) - } -} - -func AdjustStyleLink(url, id string) AdjustStyleFunc { - return func(style tcell.Style) tcell.Style { - return style.Url(url).UrlId(id) - } -} - -func (parser *htmlParser) maybeGetAttribute(node *html.Node, attribute string) (string, bool) { - for _, attr := range node.Attr { - if attr.Key == attribute { - return attr.Val, true - } - } - return "", false -} - -func (parser *htmlParser) getAttribute(node *html.Node, attribute string) string { - val, _ := parser.maybeGetAttribute(node, attribute) - return val -} - -func (parser *htmlParser) hasAttribute(node *html.Node, attribute string) bool { - _, ok := parser.maybeGetAttribute(node, attribute) - return ok -} - -func (parser *htmlParser) listToEntity(node *html.Node) Entity { - children := parser.nodeToEntities(node.FirstChild) - ordered := node.Data == "ol" - start := 1 - if ordered { - if startRaw := parser.getAttribute(node, "start"); len(startRaw) > 0 { - var err error - start, err = strconv.Atoi(startRaw) - if err != nil { - start = 1 - } - } - } - listItems := children[:0] - for _, child := range children { - if child.GetTag() == "li" { - listItems = append(listItems, child) - } - } - return NewListEntity(ordered, start, listItems) -} - -func (parser *htmlParser) basicFormatToEntity(node *html.Node) Entity { - entity := &ContainerEntity{ - BaseEntity: &BaseEntity{ - Tag: node.Data, - }, - Children: parser.nodeToEntities(node.FirstChild), - } - switch node.Data { - case "b", "strong": - entity.AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal) - case "i", "em": - entity.AdjustStyle(AdjustStyleItalic, AdjustStyleReasonNormal) - case "s", "del", "strike": - entity.AdjustStyle(AdjustStyleStrikethrough, AdjustStyleReasonNormal) - case "u", "ins": - entity.AdjustStyle(AdjustStyleUnderline, AdjustStyleReasonNormal) - case "code": - bgColor := tcell.ColorDarkSlateGray - fgColor := tcell.ColorWhite - entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal) - entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal) - case "font", "span": - fgColor, ok := parser.parseColor(node, "data-mx-color", "color") - if ok { - entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal) - } - bgColor, ok := parser.parseColor(node, "data-mx-bg-color", "background-color") - if ok { - entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal) - } - spoilerReason, isSpoiler := parser.maybeGetAttribute(node, "data-mx-spoiler") - if isSpoiler { - return NewSpoilerEntity(entity, spoilerReason) - } - } - return entity -} - -func (parser *htmlParser) parseColor(node *html.Node, mainName, altName string) (color tcell.Color, ok bool) { - hex := parser.getAttribute(node, mainName) - if len(hex) == 0 { - hex = parser.getAttribute(node, altName) - if len(hex) == 0 { - return - } - } - - cful, err := colorful.Hex(hex) - if err != nil { - color2, found := colorMap[strings.ToLower(hex)] - if !found { - return - } - cful, _ = colorful.MakeColor(color2) - } - - r, g, b := cful.RGB255() - return tcell.NewRGBColor(int32(r), int32(g), int32(b)), true -} - -func (parser *htmlParser) headerToEntity(node *html.Node) Entity { - return (&ContainerEntity{ - BaseEntity: &BaseEntity{ - Tag: node.Data, - }, - Children: append( - []Entity{NewTextEntity(strings.Repeat("#", int(node.Data[1]-'0')) + " ")}, - parser.nodeToEntities(node.FirstChild)..., - ), - }).AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal) -} - -func (parser *htmlParser) blockquoteToEntity(node *html.Node) Entity { - return NewBlockquoteEntity(parser.nodeToEntities(node.FirstChild)) -} - -func (parser *htmlParser) linkToEntity(node *html.Node) Entity { - sameURL := false - href := parser.getAttribute(node, "href") - - entity := &ContainerEntity{ - BaseEntity: &BaseEntity{ - Tag: "a", - }, - Children: parser.nodeToEntities(node.FirstChild), - } - - if len(href) == 0 { - return entity - } - - if len(entity.Children) == 1 { - entity, ok := entity.Children[0].(*TextEntity) - if ok && entity.Text == href { - sameURL = true - } - } - - matrixURI, _ := id.ParseMatrixURIOrMatrixToURL(href) - if matrixURI != nil && (matrixURI.Sigil1 == '@' || matrixURI.Sigil1 == '#') && matrixURI.Sigil2 == 0 { - text := NewTextEntity(matrixURI.PrimaryIdentifier()) - if matrixURI.Sigil1 == '@' { - if member := parser.room.GetMember(matrixURI.UserID()); member != nil { - text.Text = member.Displayname - text.Style = text.Style.Foreground(widget.GetHashColor(matrixURI.UserID())) - } - entity.Children = []Entity{text} - } else if matrixURI.Sigil1 == '#' { - entity.Children = []Entity{text} - } - } else if parser.prefs.EnableInlineURLs() { - linkID := fmt.Sprintf("%s-%d", parser.evt.ID, parser.linkIDCounter) - parser.linkIDCounter++ - entity.AdjustStyle(AdjustStyleLink(href, linkID), AdjustStyleReasonNormal) - } else if !sameURL && !parser.prefs.DisableShowURLs && !parser.hasAttribute(node, "data-mautrix-exclude-plaintext") { - entity.Children = append(entity.Children, NewTextEntity(fmt.Sprintf(" (%s)", href))) - } - return entity -} - -func (parser *htmlParser) imageToEntity(node *html.Node) Entity { - alt := parser.getAttribute(node, "alt") - if len(alt) == 0 { - alt = parser.getAttribute(node, "title") - if len(alt) == 0 { - alt = "[inline image]" - } - } - entity := &TextEntity{ - BaseEntity: &BaseEntity{ - Tag: "img", - }, - Text: alt, - } - // TODO add click action and underline on hover for inline images - return entity -} - -func colourToColor(colour chroma.Colour) tcell.Color { - if !colour.IsSet() { - return tcell.ColorDefault - } - return tcell.NewRGBColor(int32(colour.Red()), int32(colour.Green()), int32(colour.Blue())) -} - -func styleEntryToStyle(se chroma.StyleEntry) tcell.Style { - return tcell.StyleDefault. - Bold(se.Bold == chroma.Yes). - Italic(se.Italic == chroma.Yes). - Underline(se.Underline == chroma.Yes). - Foreground(colourToColor(se.Colour)). - Background(colourToColor(se.Background)) -} - -func tokenToTextEntity(style *chroma.Style, token *chroma.Token) *TextEntity { - return &TextEntity{ - BaseEntity: &BaseEntity{ - Tag: token.Type.String(), - Style: styleEntryToStyle(style.Get(token.Type)), - DefaultHeight: 1, - }, - Text: token.Value, - } -} - -func (parser *htmlParser) syntaxHighlight(text, language string) Entity { - lexer := lexers.Get(strings.ToLower(language)) - if lexer == nil { - lexer = lexers.Get("plaintext") - } - iter, err := lexer.Tokenise(nil, text) - if err != nil { - return nil - } - // TODO allow changing theme - style := styles.SolarizedDark - - tokens := iter.Tokens() - - var children []Entity - for _, token := range tokens { - lines := strings.SplitAfter(token.Value, "\n") - for _, line := range lines { - line_len := len(line) - if line_len == 0 { - continue - } - t := token.Clone() - - if line[line_len-1:] == "\n" { - t.Value = line[:line_len-1] - children = append(children, tokenToTextEntity(style, &t), NewBreakEntity()) - } else { - t.Value = line - children = append(children, tokenToTextEntity(style, &t)) - } - } - } - - return NewCodeBlockEntity(children, styleEntryToStyle(style.Get(chroma.Background))) -} - -func (parser *htmlParser) codeblockToEntity(node *html.Node) Entity { - lang := "plaintext" - // TODO allow disabling syntax highlighting - if node.FirstChild != nil && node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" { - node = node.FirstChild - attr := parser.getAttribute(node, "class") - for _, class := range strings.Split(attr, " ") { - if strings.HasPrefix(class, "language-") { - lang = class[len("language-"):] - break - } - } - } - parser.preserveWhitespace = true - text := (&ContainerEntity{ - Children: parser.nodeToEntities(node.FirstChild), - }).PlainText() - parser.preserveWhitespace = false - return parser.syntaxHighlight(text, lang) -} - -func (parser *htmlParser) tagNodeToEntity(node *html.Node) Entity { - switch node.Data { - case "blockquote": - return parser.blockquoteToEntity(node) - case "ol", "ul": - return parser.listToEntity(node) - case "h1", "h2", "h3", "h4", "h5", "h6": - return parser.headerToEntity(node) - case "br": - return NewBreakEntity() - case "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "font", "span", "code": - return parser.basicFormatToEntity(node) - case "a": - return parser.linkToEntity(node) - case "img": - return parser.imageToEntity(node) - case "pre": - return parser.codeblockToEntity(node) - case "hr": - return NewHorizontalLineEntity() - case "mx-reply": - return nil - default: - return &ContainerEntity{ - BaseEntity: &BaseEntity{ - Tag: node.Data, - Block: parser.isBlockTag(node.Data), - }, - Children: parser.nodeToEntities(node.FirstChild), - } - } -} - -var spaces = regexp.MustCompile("\\s+") - -// textToHTMLEntity converts a plain text string into an HTML Entity while preserving newlines. -func textToHTMLEntity(text string) Entity { - if strings.Index(text, "\n") == -1 { - return NewTextEntity(text) - } - return &ContainerEntity{ - BaseEntity: &BaseEntity{Tag: "span"}, - Children: textToHTMLEntities(text), - } -} - -func textToHTMLEntities(text string) []Entity { - lines := strings.SplitAfter(text, "\n") - entities := make([]Entity, 0, len(lines)) - for _, line := range lines { - line_len := len(line) - if line_len == 0 { - continue - } - if line == "\n" { - entities = append(entities, NewBreakEntity()) - } else if line[line_len-1:] == "\n" { - entities = append(entities, NewTextEntity(line[:line_len-1]), NewBreakEntity()) - } else { - entities = append(entities, NewTextEntity(line)) - } - } - return entities -} - -func TextToEntity(text string, eventID id.EventID, linkify bool) Entity { - if len(text) == 0 { - return nil - } - if !linkify { - return textToHTMLEntity(text) - } - indices := xurls.Strict().FindAllStringIndex(text, -1) - if len(indices) == 0 { - return textToHTMLEntity(text) - } - ent := &ContainerEntity{ - BaseEntity: &BaseEntity{Tag: "span"}, - } - var lastEnd int - for i, item := range indices { - start, end := item[0], item[1] - if start > lastEnd { - ent.Children = append(ent.Children, textToHTMLEntities(text[lastEnd:start])...) - } - link := text[start:end] - linkID := fmt.Sprintf("%s-%d", eventID, i) - ent.Children = append(ent.Children, NewTextEntity(link).AdjustStyle(AdjustStyleLink(link, linkID), AdjustStyleReasonNormal)) - lastEnd = end - } - if lastEnd < len(text) { - ent.Children = append(ent.Children, textToHTMLEntities(text[lastEnd:])...) - } - return ent -} - -func (parser *htmlParser) singleNodeToEntity(node *html.Node) Entity { - switch node.Type { - case html.TextNode: - if !parser.preserveWhitespace { - node.Data = strings.ReplaceAll(node.Data, "\n", "") - node.Data = spaces.ReplaceAllLiteralString(node.Data, " ") - } - return TextToEntity(node.Data, parser.evt.ID, parser.prefs.EnableInlineURLs()) - case html.ElementNode: - parsed := parser.tagNodeToEntity(node) - if parsed != nil && !parsed.IsBlock() && parsed.IsEmpty() { - return nil - } - return parsed - case html.DocumentNode: - if node.FirstChild.Data == "html" && node.FirstChild.NextSibling == nil { - return parser.singleNodeToEntity(node.FirstChild) - } - return &ContainerEntity{ - BaseEntity: &BaseEntity{ - Tag: "html", - Block: true, - }, - Children: parser.nodeToEntities(node.FirstChild), - } - default: - return nil - } -} - -func (parser *htmlParser) nodeToEntities(node *html.Node) (entities []Entity) { - for ; node != nil; node = node.NextSibling { - if entity := parser.singleNodeToEntity(node); entity != nil { - entities = append(entities, entity) - } - } - return -} - -var BlockTags = []string{"p", "h1", "h2", "h3", "h4", "h5", "h6", "ol", "ul", "li", "pre", "blockquote", "div", "hr", "table"} - -func (parser *htmlParser) isBlockTag(tag string) bool { - for _, blockTag := range BlockTags { - if tag == blockTag { - return true - } - } - return false -} - -func (parser *htmlParser) Parse(htmlData string) Entity { - node, _ := html.Parse(strings.NewReader(htmlData)) - bodyNode := node.FirstChild.FirstChild - for bodyNode != nil && (bodyNode.Type != html.ElementNode || bodyNode.Data != "body") { - bodyNode = bodyNode.NextSibling - } - if bodyNode != nil { - return parser.singleNodeToEntity(bodyNode) - } - - return parser.singleNodeToEntity(node) -} - -const TabLength = 4 - -// Parse parses a HTML-formatted Matrix event into a UIMessage. -func Parse(prefs *config.UserPreferences, room *rooms.Room, content *event.MessageEventContent, evt *muksevt.Event, senderDisplayname string) Entity { - htmlData := content.FormattedBody - - if content.Format != event.FormatHTML { - htmlData = strings.Replace(html.EscapeString(content.Body), "\n", "
", -1) - } - htmlData = strings.Replace(htmlData, "\t", strings.Repeat(" ", TabLength), -1) - - parser := htmlParser{room: room, prefs: prefs, evt: evt} - root := parser.Parse(htmlData) - if root == nil { - return nil - } - beRoot, ok := root.(*ContainerEntity) - if ok { - beRoot.Block = false - if len(beRoot.Children) > 0 { - beChild, ok := beRoot.Children[0].(*ContainerEntity) - if ok && beChild.Tag == "p" { - // Hacky fix for m.emote - beChild.Block = false - } - } - } - - if content.MsgType == event.MsgEmote { - root = &ContainerEntity{ - BaseEntity: &BaseEntity{ - Tag: "emote", - }, - Children: []Entity{ - NewTextEntity("* "), - NewTextEntity(senderDisplayname).AdjustStyle(AdjustStyleTextColor(widget.GetHashColor(evt.Sender)), AdjustStyleReasonNormal), - NewTextEntity(" "), - root, - }, - } - } - - return root -} diff --git a/ui/messages/html/spoiler.go b/ui/messages/html/spoiler.go deleted file mode 100644 index f28e8b8..0000000 --- a/ui/messages/html/spoiler.go +++ /dev/null @@ -1,120 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package html - -import ( - "fmt" - "strings" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" -) - -type SpoilerEntity struct { - reason string - hidden *ContainerEntity - visible *ContainerEntity -} - -const SpoilerColor = tcell.ColorYellow - -func NewSpoilerEntity(visible *ContainerEntity, reason string) *SpoilerEntity { - hidden := visible.Clone().(*ContainerEntity) - hidden.AdjustStyle(func(style tcell.Style) tcell.Style { - return style.Foreground(SpoilerColor).Background(SpoilerColor) - }, AdjustStyleReasonHideSpoiler) - if len(reason) > 0 { - reasonEnt := NewTextEntity(fmt.Sprintf("(%s)", reason)) - hidden.Children = append([]Entity{reasonEnt}, hidden.Children...) - visible.Children = append([]Entity{reasonEnt}, visible.Children...) - } - return &SpoilerEntity{ - reason: reason, - hidden: hidden, - visible: visible, - } -} - -func (se *SpoilerEntity) Clone() Entity { - return &SpoilerEntity{ - reason: se.reason, - hidden: se.hidden.Clone().(*ContainerEntity), - visible: se.visible.Clone().(*ContainerEntity), - } -} - -func (se *SpoilerEntity) IsBlock() bool { - return false -} - -func (se *SpoilerEntity) GetTag() string { - return "span" -} - -func (se *SpoilerEntity) Draw(screen mauview.Screen, ctx DrawContext) { - if ctx.IsSelected { - se.visible.Draw(screen, ctx) - } else { - se.hidden.Draw(screen, ctx) - } -} - -func (se *SpoilerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { - if reason != AdjustStyleReasonHideSpoiler { - se.hidden.AdjustStyle(func(style tcell.Style) tcell.Style { - return fn(style).Foreground(SpoilerColor).Background(SpoilerColor) - }, reason) - se.visible.AdjustStyle(fn, reason) - } - return se -} - -func (se *SpoilerEntity) PlainText() string { - if len(se.reason) > 0 { - return fmt.Sprintf("spoiler: %s", se.reason) - } else { - return "spoiler" - } -} - -func (se *SpoilerEntity) String() string { - var buf strings.Builder - _, _ = fmt.Fprintf(&buf, `&html.SpoilerEntity{reason=%s`, se.reason) - buf.WriteString("\n visible=") - buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.visible.String(), "\n"), "\n"), "\n ")) - buf.WriteString("\n hidden=") - buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.hidden.String(), "\n"), "\n"), "\n ")) - buf.WriteString("\n]},") - return buf.String() -} - -func (se *SpoilerEntity) Height() int { - return se.visible.Height() -} - -func (se *SpoilerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { - se.hidden.CalculateBuffer(width, startX, ctx) - return se.visible.CalculateBuffer(width, startX, ctx) -} - -func (se *SpoilerEntity) getStartX() int { - return se.visible.getStartX() -} - -func (se *SpoilerEntity) IsEmpty() bool { - return se.visible.IsEmpty() -} diff --git a/ui/messages/html/text.go b/ui/messages/html/text.go deleted file mode 100644 index 1b8860c..0000000 --- a/ui/messages/html/text.go +++ /dev/null @@ -1,156 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package html - -import ( - "fmt" - "regexp" - - "github.com/mattn/go-runewidth" - - "go.mau.fi/mauview" - - "maunium.net/go/gomuks/ui/widget" -) - -type TextEntity struct { - *BaseEntity - // Text in this entity. - Text string - - buffer []string -} - -// NewTextEntity creates a new text-only Entity. -func NewTextEntity(text string) *TextEntity { - return &TextEntity{ - BaseEntity: &BaseEntity{ - Tag: "text", - }, - Text: text, - } -} - -func (te *TextEntity) IsEmpty() bool { - return len(te.Text) == 0 -} - -func (te *TextEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { - te.BaseEntity = te.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) - return te -} - -func (te *TextEntity) Clone() Entity { - return &TextEntity{ - BaseEntity: te.BaseEntity.Clone().(*BaseEntity), - Text: te.Text, - } -} - -func (te *TextEntity) PlainText() string { - return te.Text -} - -func (te *TextEntity) String() string { - return fmt.Sprintf("&html.TextEntity{Text=%s, Base=%s},\n", te.Text, te.BaseEntity) -} - -func (te *TextEntity) Draw(screen mauview.Screen, ctx DrawContext) { - width, _ := screen.Size() - x := te.startX - for y, line := range te.buffer { - widget.WriteLine(screen, mauview.AlignLeft, line, x, y, width, te.Style) - x = 0 - } -} - -func (te *TextEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { - te.BaseEntity.CalculateBuffer(width, startX, ctx) - if len(te.Text) == 0 { - return te.startX - } - te.height = 0 - te.prevWidth = width - if te.buffer == nil { - te.buffer = []string{} - } - bufPtr := 0 - text := te.Text - textStartX := te.startX - for { - // TODO add option no wrap and character wrap options - extract := runewidth.Truncate(text, width-textStartX, "") - extract, wordWrapped := trim(extract, text, ctx.BareMessages) - if !wordWrapped && textStartX > 0 { - if bufPtr < len(te.buffer) { - te.buffer[bufPtr] = "" - } else { - te.buffer = append(te.buffer, "") - } - bufPtr++ - textStartX = 0 - continue - } - if bufPtr < len(te.buffer) { - te.buffer[bufPtr] = extract - } else { - te.buffer = append(te.buffer, extract) - } - bufPtr++ - text = text[len(extract):] - if len(text) == 0 { - te.buffer = te.buffer[:bufPtr] - te.height += len(te.buffer) - // This entity is over, return the startX for the next entity - if te.Block { - // ...except if it's a block entity - return 0 - } - return textStartX + runewidth.StringWidth(extract) - } - textStartX = 0 - } -} - -var ( - boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`) - bareBoundaryPattern = regexp.MustCompile(`(\s+)`) - spacePattern = regexp.MustCompile(`\s+`) -) - -func trim(extract, full string, bare bool) (string, bool) { - if len(extract) == len(full) { - return extract, true - } - if spaces := spacePattern.FindStringIndex(full[len(extract):]); spaces != nil && spaces[0] == 0 { - extract = full[:len(extract)+spaces[1]] - } - regex := boundaryPattern - if bare { - regex = bareBoundaryPattern - } - matches := regex.FindAllStringIndex(extract, -1) - if len(matches) > 0 { - if match := matches[len(matches)-1]; len(match) >= 2 { - if until := match[1]; until < len(extract) { - extract = extract[:until] - return extract, true - } - } - } - return extract, len(extract) > 0 && extract[len(extract)-1] == ' ' -} diff --git a/ui/messages/htmlmessage.go b/ui/messages/htmlmessage.go deleted file mode 100644 index d3593a6..0000000 --- a/ui/messages/htmlmessage.go +++ /dev/null @@ -1,99 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package messages - -import ( - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/gomuks/matrix/muksevt" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/ui/messages/html" -) - -type HTMLMessage struct { - Root html.Entity - TextColor tcell.Color -} - -func NewHTMLMessage(evt *muksevt.Event, displayname string, root html.Entity) *UIMessage { - return newUIMessage(evt, displayname, &HTMLMessage{ - Root: root, - }) -} - -func (hw *HTMLMessage) Clone() MessageRenderer { - return &HTMLMessage{ - Root: hw.Root.Clone(), - } -} - -func (hw *HTMLMessage) Draw(screen mauview.Screen, msg *UIMessage) { - if hw.TextColor != tcell.ColorDefault { - hw.Root.AdjustStyle(func(style tcell.Style) tcell.Style { - fg, _, _ := style.Decompose() - if fg == tcell.ColorDefault { - return style.Foreground(hw.TextColor) - } - return style - }, html.AdjustStyleReasonNormal) - } - screen.Clear() - hw.Root.Draw(screen, html.DrawContext{IsSelected: msg.IsSelected}) -} - -func (hw *HTMLMessage) OnKeyEvent(event mauview.KeyEvent) bool { - return false -} - -func (hw *HTMLMessage) OnMouseEvent(event mauview.MouseEvent) bool { - return false -} - -func (hw *HTMLMessage) OnPasteEvent(event mauview.PasteEvent) bool { - return false -} - -func (hw *HTMLMessage) CalculateBuffer(preferences config.UserPreferences, width int, msg *UIMessage) { - if width < 2 { - return - } - // TODO account for bare messages in initial startX - startX := 0 - hw.TextColor = msg.TextColor() - hw.Root.CalculateBuffer(width, startX, html.DrawContext{ - IsSelected: msg.IsSelected, - BareMessages: preferences.BareMessageView, - }) -} - -func (hw *HTMLMessage) Height() int { - return hw.Root.Height() -} - -func (hw *HTMLMessage) PlainText() string { - return hw.Root.PlainText() -} - -func (hw *HTMLMessage) NotificationContent() string { - return hw.Root.PlainText() -} - -func (hw *HTMLMessage) String() string { - return hw.Root.String() -} diff --git a/ui/messages/parser.go b/ui/messages/parser.go deleted file mode 100644 index dc9733e..0000000 --- a/ui/messages/parser.go +++ /dev/null @@ -1,324 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package messages - -import ( - "fmt" - "strings" - - "go.mau.fi/tcell" - - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/matrix/muksevt" - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/messages/html" - "maunium.net/go/gomuks/ui/messages/tstring" - "maunium.net/go/gomuks/ui/widget" -) - -func getCachedEvent(mainView ifc.MainView, roomID id.RoomID, eventID id.EventID) *UIMessage { - if roomView := mainView.GetRoom(roomID); roomView != nil { - if replyToIfcMsg := roomView.GetEvent(eventID); replyToIfcMsg != nil { - if replyToMsg, ok := replyToIfcMsg.(*UIMessage); ok && replyToMsg != nil { - return replyToMsg - } - } - } - return nil -} - -func ParseEvent(matrix ifc.MatrixContainer, mainView ifc.MainView, room *rooms.Room, evt *muksevt.Event) *UIMessage { - msg := directParseEvent(matrix, room, evt) - if msg == nil { - return nil - } - if content, ok := evt.Content.Parsed.(*event.MessageEventContent); ok && len(content.GetReplyTo()) > 0 { - if replyToMsg := getCachedEvent(mainView, room.ID, content.GetReplyTo()); replyToMsg != nil { - msg.ReplyTo = replyToMsg.Clone() - } else if replyToEvt, _ := matrix.GetEvent(room, content.GetReplyTo()); replyToEvt != nil { - if replyToMsg = directParseEvent(matrix, room, replyToEvt); replyToMsg != nil { - msg.ReplyTo = replyToMsg - msg.ReplyTo.Reactions = nil - } else { - // TODO add unrenderable reply header - } - } else { - // TODO add unknown reply header - } - } - return msg -} - -func directParseEvent(matrix ifc.MatrixContainer, room *rooms.Room, evt *muksevt.Event) *UIMessage { - displayname := string(evt.Sender) - member := room.GetMember(evt.Sender) - if member != nil { - displayname = member.Displayname - } - if evt.Unsigned.RedactedBecause != nil || evt.Type == event.EventRedaction { - return NewRedactedMessage(evt, displayname) - } - switch content := evt.Content.Parsed.(type) { - case *event.MessageEventContent: - if evt.Type == event.EventSticker { - content.MsgType = event.MsgImage - } - return ParseMessage(matrix, room, evt, displayname) - case *muksevt.BadEncryptedContent: - return NewExpandedTextMessage(evt, displayname, tstring.NewStyleTString(content.Reason, tcell.StyleDefault.Italic(true))) - case *muksevt.EncryptionUnsupportedContent: - return NewExpandedTextMessage(evt, displayname, tstring.NewStyleTString("gomuks not built with encryption support", tcell.StyleDefault.Italic(true))) - case *event.TopicEventContent, *event.RoomNameEventContent, *event.CanonicalAliasEventContent: - return ParseStateEvent(evt, displayname) - case *event.MemberEventContent: - return ParseMembershipEvent(room, evt) - default: - debug.Printf("Unknown event content type %T in directParseEvent", content) - return nil - } -} - -func findAltAliasDifference(newList, oldList []id.RoomAlias) (addedStr, removedStr tstring.TString) { - var addedList, removedList []tstring.TString -OldLoop: - for _, oldAlias := range oldList { - for _, newAlias := range newList { - if oldAlias == newAlias { - continue OldLoop - } - } - removedList = append(removedList, tstring.NewStyleTString(string(oldAlias), tcell.StyleDefault.Foreground(widget.GetHashColor(oldAlias)).Underline(true))) - } -NewLoop: - for _, newAlias := range newList { - for _, oldAlias := range oldList { - if newAlias == oldAlias { - continue NewLoop - } - } - addedList = append(addedList, tstring.NewStyleTString(string(newAlias), tcell.StyleDefault.Foreground(widget.GetHashColor(newAlias)).Underline(true))) - } - if len(addedList) == 1 { - addedStr = tstring.NewColorTString("added alternative address ", tcell.ColorGreen).AppendTString(addedList[0]) - } else if len(addedList) != 0 { - addedStr = tstring. - Join(addedList[:len(addedList)-1], ", "). - PrependColor("added alternative addresses ", tcell.ColorGreen). - AppendColor(" and ", tcell.ColorGreen). - AppendTString(addedList[len(addedList)-1]) - } - if len(removedList) == 1 { - removedStr = tstring.NewColorTString("removed alternative address ", tcell.ColorGreen).AppendTString(removedList[0]) - } else if len(removedList) != 0 { - removedStr = tstring. - Join(removedList[:len(removedList)-1], ", "). - PrependColor("removed alternative addresses ", tcell.ColorGreen). - AppendColor(" and ", tcell.ColorGreen). - AppendTString(removedList[len(removedList)-1]) - } - return -} - -func ParseStateEvent(evt *muksevt.Event, displayname string) *UIMessage { - text := tstring.NewColorTString(displayname, widget.GetHashColor(evt.Sender)).Append(" ") - switch content := evt.Content.Parsed.(type) { - case *event.TopicEventContent: - if len(content.Topic) == 0 { - text = text.AppendColor("removed the topic.", tcell.ColorGreen) - } else { - text = text.AppendColor("changed the topic to ", tcell.ColorGreen). - AppendStyle(content.Topic, tcell.StyleDefault.Underline(true)). - AppendColor(".", tcell.ColorGreen) - } - case *event.RoomNameEventContent: - if len(content.Name) == 0 { - text = text.AppendColor("removed the room name.", tcell.ColorGreen) - } else { - text = text.AppendColor("changed the room name to ", tcell.ColorGreen). - AppendStyle(content.Name, tcell.StyleDefault.Underline(true)). - AppendColor(".", tcell.ColorGreen) - } - case *event.CanonicalAliasEventContent: - prevContent := &event.CanonicalAliasEventContent{} - if evt.Unsigned.PrevContent != nil { - _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) - prevContent = evt.Unsigned.PrevContent.AsCanonicalAlias() - } - debug.Printf("%+v -> %+v", prevContent, content) - if len(content.Alias) == 0 && len(prevContent.Alias) != 0 { - text = text.AppendColor("removed the main address of the room", tcell.ColorGreen) - } else if content.Alias != prevContent.Alias { - text = text. - AppendColor("changed the main address of the room to ", tcell.ColorGreen). - AppendStyle(string(content.Alias), tcell.StyleDefault.Underline(true)) - } else { - added, removed := findAltAliasDifference(content.AltAliases, prevContent.AltAliases) - if len(added) > 0 { - if len(removed) > 0 { - text = text. - AppendTString(added). - AppendColor(" and ", tcell.ColorGreen). - AppendTString(removed) - } else { - text = text.AppendTString(added) - } - } else if len(removed) > 0 { - text = text.AppendTString(removed) - } else { - text = text.AppendColor("changed nothing", tcell.ColorGreen) - } - text = text.AppendColor(" for this room", tcell.ColorGreen) - } - } - return NewExpandedTextMessage(evt, displayname, text) -} - -func ParseMessage(matrix ifc.MatrixContainer, room *rooms.Room, evt *muksevt.Event, displayname string) *UIMessage { - content := evt.Content.AsMessage() - if len(content.GetReplyTo()) > 0 { - content.RemoveReplyFallback() - } - if len(evt.Gomuks.Edits) > 0 { - newContent := evt.Gomuks.Edits[len(evt.Gomuks.Edits)-1].Content.AsMessage().NewContent - if newContent != nil { - content = newContent - } - } - switch content.MsgType { - case event.MsgText, event.MsgNotice, event.MsgEmote: - var htmlEntity html.Entity - if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 { - htmlEntity = html.Parse(matrix.Preferences(), room, content, evt, displayname) - if htmlEntity == nil { - htmlEntity = html.NewTextEntity("Malformed message") - htmlEntity.AdjustStyle(html.AdjustStyleTextColor(tcell.ColorRed), html.AdjustStyleReasonNormal) - } - } else if len(content.Body) > 0 { - content.Body = strings.Replace(content.Body, "\t", " ", -1) - htmlEntity = html.TextToEntity(content.Body, evt.ID, matrix.Preferences().EnableInlineURLs()) - } else { - htmlEntity = html.NewTextEntity("Blank message") - htmlEntity.AdjustStyle(html.AdjustStyleTextColor(tcell.ColorRed), html.AdjustStyleReasonNormal) - } - return NewHTMLMessage(evt, displayname, htmlEntity) - case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: - msg := NewFileMessage(matrix, evt, displayname) - if !matrix.Preferences().DisableDownloads { - renderer := msg.Renderer.(*FileMessage) - renderer.DownloadPreview() - } - return msg - } - return nil -} - -func getMembershipChangeMessage(evt *muksevt.Event, content *event.MemberEventContent, prevMembership event.Membership, senderDisplayname, displayname, prevDisplayname string) (sender string, text tstring.TString) { - switch content.Membership { - case "invite": - sender = "---" - text = tstring.NewColorTString(fmt.Sprintf("%s invited %s.", senderDisplayname, displayname), tcell.ColorGreen) - text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender)) - text.Colorize(len(senderDisplayname)+len(" invited "), len(displayname), widget.GetHashColor(evt.StateKey)) - case "join": - sender = "-->" - if prevMembership == event.MembershipInvite { - text = tstring.NewColorTString(fmt.Sprintf("%s accepted the invite.", displayname), tcell.ColorGreen) - } else { - text = tstring.NewColorTString(fmt.Sprintf("%s joined the room.", displayname), tcell.ColorGreen) - } - text.Colorize(0, len(displayname), widget.GetHashColor(evt.StateKey)) - case "leave": - sender = "<--" - if evt.Sender != id.UserID(*evt.StateKey) { - if prevMembership == event.MembershipBan { - text = tstring.NewColorTString(fmt.Sprintf("%s unbanned %s", senderDisplayname, displayname), tcell.ColorGreen) - text.Colorize(len(senderDisplayname)+len(" unbanned "), len(displayname), widget.GetHashColor(evt.StateKey)) - } else { - text = tstring.NewColorTString(fmt.Sprintf("%s kicked %s: %s", senderDisplayname, displayname, content.Reason), tcell.ColorRed) - text.Colorize(len(senderDisplayname)+len(" kicked "), len(displayname), widget.GetHashColor(evt.StateKey)) - } - text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender)) - } else { - if displayname == *evt.StateKey { - displayname = prevDisplayname - } - if prevMembership == event.MembershipInvite { - text = tstring.NewColorTString(fmt.Sprintf("%s rejected the invite.", displayname), tcell.ColorRed) - } else { - text = tstring.NewColorTString(fmt.Sprintf("%s left the room.", displayname), tcell.ColorRed) - } - text.Colorize(0, len(displayname), widget.GetHashColor(evt.StateKey)) - } - case "ban": - text = tstring.NewColorTString(fmt.Sprintf("%s banned %s: %s", senderDisplayname, displayname, content.Reason), tcell.ColorRed) - text.Colorize(len(senderDisplayname)+len(" banned "), len(displayname), widget.GetHashColor(evt.StateKey)) - text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender)) - } - return -} - -func getMembershipEventContent(room *rooms.Room, evt *muksevt.Event) (sender string, text tstring.TString) { - member := room.GetMember(evt.Sender) - senderDisplayname := string(evt.Sender) - if member != nil { - senderDisplayname = member.Displayname - } - - content := evt.Content.AsMember() - displayname := content.Displayname - if len(displayname) == 0 { - displayname = *evt.StateKey - } - - prevMembership := event.MembershipLeave - prevDisplayname := *evt.StateKey - if evt.Unsigned.PrevContent != nil { - _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) - prevContent := evt.Unsigned.PrevContent.AsMember() - prevMembership = prevContent.Membership - prevDisplayname = prevContent.Displayname - if len(prevDisplayname) == 0 { - prevDisplayname = *evt.StateKey - } - } - - if content.Membership != prevMembership { - sender, text = getMembershipChangeMessage(evt, content, prevMembership, senderDisplayname, displayname, prevDisplayname) - } else if displayname != prevDisplayname { - sender = "---" - color := widget.GetHashColor(evt.StateKey) - text = tstring.NewBlankTString(). - AppendColor(prevDisplayname, color). - AppendColor(" changed their display name to ", tcell.ColorGreen). - AppendColor(displayname, color). - AppendColor(".", tcell.ColorGreen) - } - return -} - -func ParseMembershipEvent(room *rooms.Room, evt *muksevt.Event) *UIMessage { - displayname, text := getMembershipEventContent(room, evt) - if len(text) == 0 { - return nil - } - - return NewExpandedTextMessage(evt, displayname, text) -} diff --git a/ui/messages/redactedmessage.go b/ui/messages/redactedmessage.go deleted file mode 100644 index e3e1718..0000000 --- a/ui/messages/redactedmessage.go +++ /dev/null @@ -1,67 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package messages - -import ( - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/gomuks/matrix/muksevt" - - "maunium.net/go/gomuks/config" -) - -type RedactedMessage struct{} - -func NewRedactedMessage(evt *muksevt.Event, displayname string) *UIMessage { - return newUIMessage(evt, displayname, &RedactedMessage{}) -} - -func (msg *RedactedMessage) Clone() MessageRenderer { - return &RedactedMessage{} -} - -func (msg *RedactedMessage) NotificationContent() string { - return "" -} - -func (msg *RedactedMessage) PlainText() string { - return "[redacted]" -} - -func (msg *RedactedMessage) String() string { - return "&messages.RedactedMessage{}" -} - -func (msg *RedactedMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) { -} - -func (msg *RedactedMessage) Height() int { - return 1 -} - -const RedactionChar = '█' -const RedactionMaxWidth = 40 - -var RedactionStyle = tcell.StyleDefault.Foreground(tcell.NewRGBColor(50, 0, 0)) - -func (msg *RedactedMessage) Draw(screen mauview.Screen, _ *UIMessage) { - w, _ := screen.Size() - for x := 0; x < w && x < RedactionMaxWidth; x++ { - screen.SetContent(x, 0, RedactionChar, nil, RedactionStyle) - } -} diff --git a/ui/messages/textbase.go b/ui/messages/textbase.go deleted file mode 100644 index 1e8b376..0000000 --- a/ui/messages/textbase.go +++ /dev/null @@ -1,96 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package messages - -import ( - "fmt" - "regexp" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/ui/messages/tstring" -) - -// Regular expressions used to split lines when calculating the buffer. -// -// From tview/textview.go -var ( - boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`) - bareBoundaryPattern = regexp.MustCompile(`(\s+)`) - spacePattern = regexp.MustCompile(`\s+`) -) - -func matchBoundaryPattern(bare bool, extract tstring.TString) tstring.TString { - regex := boundaryPattern - if bare { - regex = bareBoundaryPattern - } - matches := regex.FindAllStringIndex(extract.String(), -1) - if len(matches) > 0 { - if match := matches[len(matches)-1]; len(match) >= 2 { - if until := match[1]; until < len(extract) { - extract = extract[:until] - } - } - } - return extract -} - -// CalculateBuffer generates the internal buffer for this message that consists -// of the text of this message split into lines at most as wide as the width -// parameter. -func calculateBufferWithText(prefs config.UserPreferences, text tstring.TString, width int, msg *UIMessage) []tstring.TString { - if width < 2 { - return nil - } - - var buffer []tstring.TString - - if prefs.BareMessageView { - newText := tstring.NewTString(msg.FormatTime()) - if len(msg.Sender()) > 0 { - newText = newText.AppendTString(tstring.NewColorTString(fmt.Sprintf(" <%s> ", msg.Sender()), msg.SenderColor())) - } else { - newText = newText.Append(" ") - } - newText = newText.AppendTString(text) - text = newText - } - - forcedLinebreaks := text.Split('\n') - newlines := 0 - for _, str := range forcedLinebreaks { - if len(str) == 0 && newlines < 1 { - buffer = append(buffer, tstring.TString{}) - newlines++ - } else { - newlines = 0 - } - // Adapted from tview/textview.go#reindexBuffer() - for len(str) > 0 { - extract := str.Truncate(width) - if len(extract) < len(str) { - if spaces := spacePattern.FindStringIndex(str[len(extract):].String()); spaces != nil && spaces[0] == 0 { - extract = str[:len(extract)+spaces[1]] - } - extract = matchBoundaryPattern(prefs.BareMessageView, extract) - } - buffer = append(buffer, extract) - str = str[len(extract):] - } - } - return buffer -} diff --git a/ui/messages/tstring/cell.go b/ui/messages/tstring/cell.go deleted file mode 100644 index 4ed3735..0000000 --- a/ui/messages/tstring/cell.go +++ /dev/null @@ -1,53 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package tstring - -import ( - "github.com/mattn/go-runewidth" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" -) - -type Cell struct { - Char rune - Style tcell.Style -} - -func NewStyleCell(char rune, style tcell.Style) Cell { - return Cell{char, style} -} - -func NewColorCell(char rune, color tcell.Color) Cell { - return Cell{char, tcell.StyleDefault.Foreground(color)} -} - -func NewCell(char rune) Cell { - return Cell{char, tcell.StyleDefault} -} - -func (cell Cell) RuneWidth() int { - return runewidth.RuneWidth(cell.Char) -} - -func (cell Cell) Draw(screen mauview.Screen, x, y int) (chWidth int) { - chWidth = cell.RuneWidth() - for runeWidthOffset := 0; runeWidthOffset < chWidth; runeWidthOffset++ { - screen.SetContent(x+runeWidthOffset, y, cell.Char, nil, cell.Style) - } - return -} diff --git a/ui/messages/tstring/doc.go b/ui/messages/tstring/doc.go deleted file mode 100644 index d03a1da..0000000 --- a/ui/messages/tstring/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package tstring contains a string type that stores style data for each -// character, allowing it to be rendered to a tcell screen essentially -// unmodified. -package tstring diff --git a/ui/messages/tstring/string.go b/ui/messages/tstring/string.go deleted file mode 100644 index df51d5d..0000000 --- a/ui/messages/tstring/string.go +++ /dev/null @@ -1,270 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package tstring - -import ( - "strings" - "unicode" - - "github.com/mattn/go-runewidth" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" -) - -type TString []Cell - -func NewBlankTString() TString { - return make(TString, 0) -} - -func NewTString(str string) TString { - newStr := make(TString, len(str)) - for i, char := range str { - newStr[i] = NewCell(char) - } - return newStr -} - -func NewColorTString(str string, color tcell.Color) TString { - newStr := make(TString, len(str)) - for i, char := range str { - newStr[i] = NewColorCell(char, color) - } - return newStr -} - -func NewStyleTString(str string, style tcell.Style) TString { - newStr := make(TString, len(str)) - for i, char := range str { - newStr[i] = NewStyleCell(char, style) - } - return newStr -} - -func Join(strings []TString, separator string) TString { - if len(strings) == 0 { - return NewBlankTString() - } - - out := strings[0] - strings = strings[1:] - - if len(separator) == 0 { - return out.AppendTString(strings...) - } - - for _, str := range strings { - out = append(out, str.Prepend(separator)...) - } - return out -} - -func (str TString) Clone() TString { - newStr := make(TString, len(str)) - copy(newStr, str) - return newStr -} - -func (str TString) AppendTString(dataList ...TString) TString { - newStr := str - for _, data := range dataList { - newStr = append(newStr, data...) - } - return newStr -} - -func (str TString) PrependTString(data TString) TString { - return append(data, str...) -} - -func (str TString) Append(data string) TString { - return str.AppendCustom(data, func(r rune) Cell { - return NewCell(r) - }) -} - -func (str TString) TrimSpace() TString { - return str.Trim(unicode.IsSpace) -} - -func (str TString) Trim(fn func(rune) bool) TString { - return str.TrimLeft(fn).TrimRight(fn) -} - -func (str TString) TrimLeft(fn func(rune) bool) TString { - for index, cell := range str { - if !fn(cell.Char) { - return append(NewBlankTString(), str[index:]...) - } - } - return NewBlankTString() -} - -func (str TString) TrimRight(fn func(rune) bool) TString { - for i := len(str) - 1; i >= 0; i-- { - if !fn(str[i].Char) { - return append(NewBlankTString(), str[:i+1]...) - } - } - return NewBlankTString() -} - -func (str TString) AppendColor(data string, color tcell.Color) TString { - return str.AppendCustom(data, func(r rune) Cell { - return NewColorCell(r, color) - }) -} - -func (str TString) AppendStyle(data string, style tcell.Style) TString { - return str.AppendCustom(data, func(r rune) Cell { - return NewStyleCell(r, style) - }) -} - -func (str TString) AppendCustom(data string, cellCreator func(rune) Cell) TString { - newStr := make(TString, len(str)+len(data)) - copy(newStr, str) - for i, char := range data { - newStr[i+len(str)] = cellCreator(char) - } - return newStr -} - -func (str TString) Prepend(data string) TString { - return str.PrependCustom(data, func(r rune) Cell { - return NewCell(r) - }) -} - -func (str TString) PrependColor(data string, color tcell.Color) TString { - return str.PrependCustom(data, func(r rune) Cell { - return NewColorCell(r, color) - }) -} - -func (str TString) PrependStyle(data string, style tcell.Style) TString { - return str.PrependCustom(data, func(r rune) Cell { - return NewStyleCell(r, style) - }) -} - -func (str TString) PrependCustom(data string, cellCreator func(rune) Cell) TString { - newStr := make(TString, len(str)+len(data)) - copy(newStr[len(data):], str) - for i, char := range data { - newStr[i] = cellCreator(char) - } - return newStr -} - -func (str TString) Colorize(from, length int, color tcell.Color) { - str.AdjustStyle(from, length, func(style tcell.Style) tcell.Style { - return style.Foreground(color) - }) -} - -func (str TString) AdjustStyle(from, length int, fn func(tcell.Style) tcell.Style) { - for i := from; i < from+length; i++ { - str[i].Style = fn(str[i].Style) - } -} - -func (str TString) AdjustStyleFull(fn func(tcell.Style) tcell.Style) { - str.AdjustStyle(0, len(str), fn) -} - -func (str TString) Draw(screen mauview.Screen, x, y int) { - for _, cell := range str { - x += cell.Draw(screen, x, y) - } -} - -func (str TString) RuneWidth() (width int) { - for _, cell := range str { - width += runewidth.RuneWidth(cell.Char) - } - return width -} - -func (str TString) String() string { - var buf strings.Builder - for _, cell := range str { - buf.WriteRune(cell.Char) - } - return buf.String() -} - -// Truncate return string truncated with w cells -func (str TString) Truncate(w int) TString { - if str.RuneWidth() <= w { - return str[:] - } - width := 0 - i := 0 - for ; i < len(str); i++ { - cw := runewidth.RuneWidth(str[i].Char) - if width+cw > w { - break - } - width += cw - } - return str[0:i] -} - -func (str TString) IndexFrom(r rune, from int) int { - for i := from; i < len(str); i++ { - if str[i].Char == r { - return i - } - } - return -1 -} - -func (str TString) Index(r rune) int { - return str.IndexFrom(r, 0) -} - -func (str TString) Count(r rune) (counter int) { - index := 0 - for { - index = str.IndexFrom(r, index) - if index < 0 { - break - } - index++ - counter++ - } - return -} - -func (str TString) Split(sep rune) []TString { - a := make([]TString, str.Count(sep)+1) - i := 0 - orig := str - for { - m := orig.Index(sep) - if m < 0 { - break - } - a[i] = orig[:m] - orig = orig[m+1:] - i++ - } - a[i] = orig - return a[:i+1] -} diff --git a/ui/no-crypto-commands.go b/ui/no-crypto-commands.go deleted file mode 100644 index b98d277..0000000 --- a/ui/no-crypto-commands.go +++ /dev/null @@ -1,46 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build !cgo - -package ui - -func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) { - return []string{}, "" -} - -func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) { - return []string{}, "" -} - -func cmdNoCrypto(cmd *Command) { - cmd.Reply("This gomuks was built without encryption support") -} - -var ( - cmdDevices = cmdNoCrypto - cmdDevice = cmdNoCrypto - cmdVerifyDevice = cmdNoCrypto - cmdVerify = cmdNoCrypto - cmdUnverify = cmdNoCrypto - cmdBlacklist = cmdNoCrypto - cmdResetSession = cmdNoCrypto - cmdImportKeys = cmdNoCrypto - cmdExportKeys = cmdNoCrypto - cmdExportRoomKeys = cmdNoCrypto - cmdSSSS = cmdNoCrypto - cmdCrossSigning = cmdNoCrypto -) diff --git a/ui/password-modal.go b/ui/password-modal.go deleted file mode 100644 index ceb2f7c..0000000 --- a/ui/password-modal.go +++ /dev/null @@ -1,143 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "fmt" - "strings" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" -) - -type PasswordModal struct { - mauview.Component - - outputChan chan string - cancelChan chan struct{} - - form *mauview.Form - - text *mauview.TextField - confirmText *mauview.TextField - - input *mauview.InputField - confirmInput *mauview.InputField - - cancel *mauview.Button - submit *mauview.Button - - parent *MainView -} - -func (view *MainView) AskPassword(title, thing, placeholder string, isNew bool) (string, bool) { - pwm := NewPasswordModal(view, title, thing, placeholder, isNew) - view.ShowModal(pwm) - view.parent.Render() - return pwm.Wait() -} - -func NewPasswordModal(parent *MainView, title, thing, placeholder string, isNew bool) *PasswordModal { - if placeholder == "" { - placeholder = "correct horse battery staple" - } - if thing == "" { - thing = strings.ToLower(title) - } - pwm := &PasswordModal{ - parent: parent, - form: mauview.NewForm(), - outputChan: make(chan string, 1), - cancelChan: make(chan struct{}, 1), - } - - pwm.form. - SetColumns([]int{1, 20, 1, 20, 1}). - SetRows([]int{1, 1, 1, 0, 0, 0, 1, 1, 1}) - - width := 45 - height := 8 - - pwm.text = mauview.NewTextField() - if isNew { - pwm.text.SetText(fmt.Sprintf("Create a %s", thing)) - } else { - pwm.text.SetText(fmt.Sprintf("Enter the %s", thing)) - } - pwm.input = mauview.NewInputField(). - SetMaskCharacter('*'). - SetPlaceholder(placeholder) - pwm.form.AddComponent(pwm.text, 1, 1, 3, 1) - pwm.form.AddFormItem(pwm.input, 1, 2, 3, 1) - - if isNew { - height += 3 - pwm.confirmInput = mauview.NewInputField(). - SetMaskCharacter('*'). - SetPlaceholder(placeholder). - SetChangedFunc(pwm.HandleChange) - pwm.input.SetChangedFunc(pwm.HandleChange) - pwm.confirmText = mauview.NewTextField().SetText(fmt.Sprintf("Confirm %s", thing)) - - pwm.form.SetRow(3, 1).SetRow(4, 1).SetRow(5, 1) - pwm.form.AddComponent(pwm.confirmText, 1, 4, 3, 1) - pwm.form.AddFormItem(pwm.confirmInput, 1, 5, 3, 1) - } - - pwm.cancel = mauview.NewButton("Cancel").SetOnClick(pwm.ClickCancel) - pwm.submit = mauview.NewButton("Submit").SetOnClick(pwm.ClickSubmit) - - pwm.form.AddFormItem(pwm.submit, 3, 7, 1, 1) - pwm.form.AddFormItem(pwm.cancel, 1, 7, 1, 1) - - box := mauview.NewBox(pwm.form).SetTitle(title) - center := mauview.Center(box, width, height).SetAlwaysFocusChild(true) - center.Focus() - pwm.form.FocusNextItem() - pwm.Component = center - - return pwm -} - -func (pwm *PasswordModal) HandleChange(_ string) { - if pwm.input.GetText() == pwm.confirmInput.GetText() { - pwm.submit.SetBackgroundColor(mauview.Styles.ContrastBackgroundColor) - } else { - pwm.submit.SetBackgroundColor(tcell.ColorDefault) - } -} - -func (pwm *PasswordModal) ClickCancel() { - pwm.parent.HideModal() - pwm.cancelChan <- struct{}{} -} - -func (pwm *PasswordModal) ClickSubmit() { - if pwm.confirmInput == nil || pwm.input.GetText() == pwm.confirmInput.GetText() { - pwm.parent.HideModal() - pwm.outputChan <- pwm.input.GetText() - } -} - -func (pwm *PasswordModal) Wait() (string, bool) { - select { - case result := <-pwm.outputChan: - return result, true - case <-pwm.cancelChan: - return "", false - } -} diff --git a/ui/rainbow.go b/ui/rainbow.go deleted file mode 100644 index 83f2800..0000000 --- a/ui/rainbow.go +++ /dev/null @@ -1,135 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "fmt" - "math/rand" - "unicode" - - "github.com/rivo/uniseg" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/renderer" - "github.com/yuin/goldmark/renderer/html" - "github.com/yuin/goldmark/util" -) - -func Rand(n int) (str string) { - b := make([]byte, n) - rand.Read(b) - str = fmt.Sprintf("%x", b) - return -} - -type extRainbow struct{} -type rainbowRenderer struct { - HardWraps bool - ColorID string -} - -var ExtensionRainbow = &extRainbow{} -var defaultRB = &rainbowRenderer{HardWraps: true, ColorID: Rand(16)} - -func (er *extRainbow) Extend(m goldmark.Markdown) { - m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(defaultRB, 0))) -} - -func (rb *rainbowRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { - reg.Register(ast.KindText, rb.renderText) - reg.Register(ast.KindString, rb.renderString) -} - -type rainbowBufWriter struct { - util.BufWriter - ColorID string -} - -func (rbw rainbowBufWriter) WriteString(s string) (int, error) { - i := 0 - graphemes := uniseg.NewGraphemes(s) - for graphemes.Next() { - runes := graphemes.Runes() - if len(runes) == 1 && unicode.IsSpace(runes[0]) { - i2, err := rbw.BufWriter.WriteRune(runes[0]) - i += i2 - if err != nil { - return i, err - } - continue - } - i2, err := fmt.Fprintf(rbw.BufWriter, "%s", rbw.ColorID, graphemes.Str()) - i += i2 - if err != nil { - return i, err - } - } - return i, nil -} - -func (rbw rainbowBufWriter) Write(data []byte) (int, error) { - return rbw.WriteString(string(data)) -} - -func (rbw rainbowBufWriter) WriteByte(c byte) error { - _, err := rbw.WriteRune(rune(c)) - return err -} - -func (rbw rainbowBufWriter) WriteRune(r rune) (int, error) { - if unicode.IsSpace(r) { - return rbw.BufWriter.WriteRune(r) - } else { - return fmt.Fprintf(rbw.BufWriter, "%c", rbw.ColorID, r) - } -} - -func (rb *rainbowRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering { - return ast.WalkContinue, nil - } - n := node.(*ast.Text) - segment := n.Segment - if n.IsRaw() { - html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, segment.Value(source)) - } else { - html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, segment.Value(source)) - if n.HardLineBreak() || (n.SoftLineBreak() && rb.HardWraps) { - _, _ = w.WriteString("
\n") - } else if n.SoftLineBreak() { - _ = w.WriteByte('\n') - } - } - return ast.WalkContinue, nil -} - -func (rb *rainbowRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - if !entering { - return ast.WalkContinue, nil - } - n := node.(*ast.String) - if n.IsCode() { - _, _ = w.Write(n.Value) - } else { - if n.IsRaw() { - html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, n.Value) - } else { - html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, n.Value) - } - } - return ast.WalkContinue, nil -} diff --git a/ui/room-list.go b/ui/room-list.go deleted file mode 100644 index 6425225..0000000 --- a/ui/room-list.go +++ /dev/null @@ -1,592 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "math" - "regexp" - "sort" - "strings" - - sync "github.com/sasha-s/go-deadlock" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/debug" - "maunium.net/go/gomuks/matrix/rooms" -) - -var tagOrder = map[string]int{ - "net.maunium.gomuks.fake.invite": 4, - "m.favourite": 3, - "net.maunium.gomuks.fake.direct": 2, - "": 1, - "m.lowpriority": -1, - "m.server_notice": -2, - "net.maunium.gomuks.fake.leave": -3, -} - -// TagNameList is a list of Matrix tag names where default names are sorted in a hardcoded way. -type TagNameList []string - -func (tnl TagNameList) Len() int { - return len(tnl) -} - -func (tnl TagNameList) Less(i, j int) bool { - orderI, _ := tagOrder[tnl[i]] - orderJ, _ := tagOrder[tnl[j]] - if orderI != orderJ { - return orderI > orderJ - } - return strings.Compare(tnl[i], tnl[j]) > 0 -} - -func (tnl TagNameList) Swap(i, j int) { - tnl[i], tnl[j] = tnl[j], tnl[i] -} - -type RoomList struct { - sync.RWMutex - - parent *MainView - - // The list of tags in display order. - tags TagNameList - // The list of rooms, in reverse order. - items map[string]*TagRoomList - // The selected room. - selected *rooms.Room - selectedTag string - - scrollOffset int - height int - width int - - // The item main text color. - mainTextColor tcell.Color - // The text color for selected items. - selectedTextColor tcell.Color - // The background color for selected items. - selectedBackgroundColor tcell.Color -} - -func NewRoomList(parent *MainView) *RoomList { - list := &RoomList{ - parent: parent, - - items: make(map[string]*TagRoomList), - tags: []string{}, - - scrollOffset: 0, - - mainTextColor: tcell.ColorDefault, - selectedTextColor: tcell.ColorWhite, - selectedBackgroundColor: tcell.ColorDarkGreen, - } - for _, tag := range list.tags { - list.items[tag] = NewTagRoomList(list, tag) - } - return list -} - -func (list *RoomList) Contains(roomID id.RoomID) bool { - list.RLock() - defer list.RUnlock() - for _, trl := range list.items { - for _, room := range trl.All() { - if room.ID == roomID { - return true - } - } - } - return false -} - -func (list *RoomList) Add(room *rooms.Room) { - if room.IsReplaced() { - debug.Print(room.ID, "is replaced by", room.ReplacedBy(), "-> not adding to room list") - return - } - debug.Print("Adding room to list", room.ID, room.GetTitle(), room.IsDirect, room.ReplacedBy(), room.Tags()) - for _, tag := range room.Tags() { - list.AddToTag(tag, room) - } -} - -func (list *RoomList) checkTag(tag string) { - index := list.indexTag(tag) - - trl, ok := list.items[tag] - - if ok && trl.IsEmpty() { - delete(list.items, tag) - ok = false - } - - if ok && index == -1 { - list.tags = append(list.tags, tag) - sort.Sort(list.tags) - } else if !ok && index != -1 { - list.tags = append(list.tags[0:index], list.tags[index+1:]...) - } -} - -func (list *RoomList) AddToTag(tag rooms.RoomTag, room *rooms.Room) { - list.Lock() - defer list.Unlock() - trl, ok := list.items[tag.Tag] - if !ok { - list.items[tag.Tag] = NewTagRoomList(list, tag.Tag, NewOrderedRoom(tag.Order, room)) - } else { - trl.Insert(tag.Order, room) - } - list.checkTag(tag.Tag) -} - -func (list *RoomList) Remove(room *rooms.Room) { - for _, tag := range list.tags { - list.RemoveFromTag(tag, room) - } -} - -func (list *RoomList) RemoveFromTag(tag string, room *rooms.Room) { - list.Lock() - defer list.Unlock() - trl, ok := list.items[tag] - if !ok { - return - } - - index := trl.Index(room) - if index == -1 { - return - } - - trl.RemoveIndex(index) - - if trl.IsEmpty() { - // delete(list.items, tag) - } - - if room == list.selected { - if index > 0 { - list.selected = trl.All()[index-1].Room - } else if trl.Length() > 0 { - list.selected = trl.Visible()[0].Room - } else if len(list.items) > 0 { - for _, tag := range list.tags { - moreItems := list.items[tag] - if moreItems.Length() > 0 { - list.selected = moreItems.Visible()[0].Room - list.selectedTag = tag - } - } - } else { - list.selected = nil - list.selectedTag = "" - } - } - list.checkTag(tag) -} - -func (list *RoomList) Bump(room *rooms.Room) { - list.RLock() - defer list.RUnlock() - for _, tag := range room.Tags() { - trl, ok := list.items[tag.Tag] - if !ok { - return - } - trl.Bump(room) - } -} - -func (list *RoomList) Clear() { - list.Lock() - defer list.Unlock() - list.items = make(map[string]*TagRoomList) - list.tags = []string{} - for _, tag := range list.tags { - list.items[tag] = NewTagRoomList(list, tag) - } - list.selected = nil - list.selectedTag = "" -} - -func (list *RoomList) SetSelected(tag string, room *rooms.Room) { - list.selected = room - list.selectedTag = tag - pos := list.index(tag, room) - if pos <= list.scrollOffset { - list.scrollOffset = pos - 1 - } else if pos >= list.scrollOffset+list.height { - list.scrollOffset = pos - list.height + 1 - } - if list.scrollOffset < 0 { - list.scrollOffset = 0 - } - debug.Print("Selecting", room.GetTitle(), "in", list.GetTagDisplayName(tag)) -} - -func (list *RoomList) HasSelected() bool { - return list.selected != nil -} - -func (list *RoomList) Selected() (string, *rooms.Room) { - return list.selectedTag, list.selected -} - -func (list *RoomList) SelectedRoom() *rooms.Room { - return list.selected -} - -func (list *RoomList) AddScrollOffset(offset int) { - list.scrollOffset += offset - contentHeight := list.ContentHeight() - if list.scrollOffset > contentHeight-list.height { - list.scrollOffset = contentHeight - list.height - } - if list.scrollOffset < 0 { - list.scrollOffset = 0 - } -} - -func (list *RoomList) First() (string, *rooms.Room) { - list.RLock() - defer list.RUnlock() - return list.first() -} - -func (list *RoomList) first() (string, *rooms.Room) { - for _, tag := range list.tags { - trl := list.items[tag] - if trl.HasVisibleRooms() { - return tag, trl.FirstVisible() - } - } - return "", nil -} - -func (list *RoomList) Last() (string, *rooms.Room) { - list.RLock() - defer list.RUnlock() - return list.last() -} - -func (list *RoomList) last() (string, *rooms.Room) { - for tagIndex := len(list.tags) - 1; tagIndex >= 0; tagIndex-- { - tag := list.tags[tagIndex] - trl := list.items[tag] - if trl.HasVisibleRooms() { - return tag, trl.LastVisible() - } - } - return "", nil -} - -func (list *RoomList) indexTag(tag string) int { - for index, entry := range list.tags { - if tag == entry { - return index - } - } - return -1 -} - -func (list *RoomList) Previous() (string, *rooms.Room) { - list.RLock() - defer list.RUnlock() - if len(list.items) == 0 { - return "", nil - } else if list.selected == nil { - return list.first() - } - - trl := list.items[list.selectedTag] - index := trl.IndexVisible(list.selected) - indexInvisible := trl.Index(list.selected) - if index == -1 && indexInvisible >= 0 { - num := trl.TotalLength() - indexInvisible - trl.maxShown = int(math.Ceil(float64(num)/10.0) * 10.0) - index = trl.IndexVisible(list.selected) - } - - if index == trl.Length()-1 { - tagIndex := list.indexTag(list.selectedTag) - tagIndex-- - for ; tagIndex >= 0; tagIndex-- { - prevTag := list.tags[tagIndex] - prevTRL := list.items[prevTag] - if prevTRL.HasVisibleRooms() { - return prevTag, prevTRL.LastVisible() - } - } - return list.last() - } else if index >= 0 { - return list.selectedTag, trl.Visible()[index+1].Room - } - return list.first() -} - -func (list *RoomList) Next() (string, *rooms.Room) { - list.RLock() - defer list.RUnlock() - if len(list.items) == 0 { - return "", nil - } else if list.selected == nil { - return list.first() - } - - trl := list.items[list.selectedTag] - index := trl.IndexVisible(list.selected) - indexInvisible := trl.Index(list.selected) - if index == -1 && indexInvisible >= 0 { - num := trl.TotalLength() - indexInvisible + 1 - trl.maxShown = int(math.Ceil(float64(num)/10.0) * 10.0) - index = trl.IndexVisible(list.selected) - } - - if index == 0 { - tagIndex := list.indexTag(list.selectedTag) - tagIndex++ - for ; tagIndex < len(list.tags); tagIndex++ { - nextTag := list.tags[tagIndex] - nextTRL := list.items[nextTag] - if nextTRL.HasVisibleRooms() { - return nextTag, nextTRL.FirstVisible() - } - } - return list.first() - } else if index > 0 { - return list.selectedTag, trl.Visible()[index-1].Room - } - return list.last() -} - -// NextWithActivity Returns next room with activity. -// -// Sorted by (in priority): -// -// - Highlights -// - Messages -// - Other traffic (joins, parts, etc) -// -// TODO: Sorting. Now just finds first room with new messages. -func (list *RoomList) NextWithActivity() (string, *rooms.Room) { - list.RLock() - defer list.RUnlock() - for tag, trl := range list.items { - for _, room := range trl.All() { - if room.HasNewMessages() { - return tag, room.Room - } - } - } - // No room with activity found - return "", nil -} - -func (list *RoomList) index(tag string, room *rooms.Room) int { - tagIndex := list.indexTag(tag) - if tagIndex == -1 { - return -1 - } - - trl, ok := list.items[tag] - localIndex := -1 - if ok { - localIndex = trl.IndexVisible(room) - } - if localIndex == -1 { - return -1 - } - localIndex = trl.Length() - 1 - localIndex - - // Tag header - localIndex++ - - if tagIndex > 0 { - for i := 0; i < tagIndex; i++ { - prevTag := list.tags[i] - - prevTRL := list.items[prevTag] - localIndex += prevTRL.RenderHeight() - } - } - - return localIndex -} - -func (list *RoomList) ContentHeight() (height int) { - list.RLock() - for _, tag := range list.tags { - height += list.items[tag].RenderHeight() - } - list.RUnlock() - return -} - -func (list *RoomList) OnKeyEvent(_ mauview.KeyEvent) bool { - return false -} - -func (list *RoomList) OnPasteEvent(_ mauview.PasteEvent) bool { - return false -} - -func (list *RoomList) OnMouseEvent(event mauview.MouseEvent) bool { - if event.HasMotion() { - return false - } - switch event.Buttons() { - case tcell.WheelUp: - list.AddScrollOffset(-WheelScrollOffsetDiff) - return true - case tcell.WheelDown: - list.AddScrollOffset(WheelScrollOffsetDiff) - return true - case tcell.Button1: - x, y := event.Position() - return list.clickRoom(y, x, event.Modifiers() == tcell.ModCtrl) - } - return false -} - -func (list *RoomList) Focus() { - -} - -func (list *RoomList) Blur() { - -} - -func (list *RoomList) clickRoom(line, column int, mod bool) bool { - line += list.scrollOffset - if line < 0 { - return false - } - list.RLock() - for _, tag := range list.tags { - trl := list.items[tag] - if line--; line == -1 { - trl.ToggleCollapse() - list.RUnlock() - return true - } - - if trl.IsCollapsed() { - continue - } - - if line < 0 { - break - } else if line < trl.Length() { - switchToRoom := trl.Visible()[trl.Length()-1-line].Room - list.RUnlock() - list.parent.SwitchRoom(tag, switchToRoom) - return true - } - - // Tag items - line -= trl.Length() - - hasMore := trl.HasInvisibleRooms() - hasLess := trl.maxShown > 10 - if hasMore || hasLess { - if line--; line == -1 { - diff := 10 - if mod { - diff = 100 - } - if column <= 6 && hasLess { - trl.maxShown -= diff - } else if column >= list.width-6 && hasMore { - trl.maxShown += diff - } - if trl.maxShown < 10 { - trl.maxShown = 10 - } - list.RUnlock() - return true - } - } - // Tag footer - line-- - } - list.RUnlock() - return false -} - -var nsRegex = regexp.MustCompile("^[a-z]+\\.[a-z]+(?:\\.[a-z]+)*$") - -func (list *RoomList) GetTagDisplayName(tag string) string { - switch { - case len(tag) == 0: - return "Rooms" - case tag == "m.favourite": - return "Favorites" - case tag == "m.lowpriority": - return "Low Priority" - case tag == "m.server_notice": - return "System Alerts" - case tag == "net.maunium.gomuks.fake.direct": - return "People" - case tag == "net.maunium.gomuks.fake.invite": - return "Invites" - case tag == "net.maunium.gomuks.fake.leave": - return "Historical" - case strings.HasPrefix(tag, "u."): - return tag[len("u."):] - case !nsRegex.MatchString(tag): - return tag - default: - return "" - } -} - -// Draw draws this primitive onto the screen. -func (list *RoomList) Draw(screen mauview.Screen) { - list.width, list.height = screen.Size() - y := 0 - yLimit := y + list.height - y -= list.scrollOffset - - // Draw the list items. - list.RLock() - for _, tag := range list.tags { - trl := list.items[tag] - tagDisplayName := list.GetTagDisplayName(tag) - if trl == nil || len(tagDisplayName) == 0 { - continue - } - - renderHeight := trl.RenderHeight() - if y+renderHeight >= yLimit { - renderHeight = yLimit - y - } - trl.Draw(mauview.NewProxyScreen(screen, 0, y, list.width, renderHeight)) - y += renderHeight - if y >= yLimit { - break - } - } - list.RUnlock() -} diff --git a/ui/room-view.go b/ui/room-view.go deleted file mode 100644 index 73fe967..0000000 --- a/ui/room-view.go +++ /dev/null @@ -1,937 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "fmt" - "sort" - "strings" - "time" - "unicode" - - "github.com/kyokomi/emoji/v2" - "github.com/mattn/go-runewidth" - "github.com/zyedidia/clipboard" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/crypto/attachment" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/variationselector" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/lib/open" - "maunium.net/go/gomuks/lib/util" - "maunium.net/go/gomuks/matrix/muksevt" - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/messages" - "maunium.net/go/gomuks/ui/widget" -) - -type RoomView struct { - topic *mauview.TextView - content *MessageView - status *mauview.TextField - userList *MemberList - ulBorder *widget.Border - input *mauview.InputArea - Room *rooms.Room - - topicScreen *mauview.ProxyScreen - contentScreen *mauview.ProxyScreen - statusScreen *mauview.ProxyScreen - inputScreen *mauview.ProxyScreen - ulBorderScreen *mauview.ProxyScreen - ulScreen *mauview.ProxyScreen - - userListLoaded bool - - prevScreen mauview.Screen - - parent *MainView - config *config.Config - - typing []string - - selecting bool - selectReason SelectReason - selectContent string - - replying *muksevt.Event - - editing *muksevt.Event - editMoveText string - - completions struct { - list []string - textCache string - time time.Time - } -} - -func NewRoomView(parent *MainView, room *rooms.Room) *RoomView { - view := &RoomView{ - topic: mauview.NewTextView(), - status: mauview.NewTextField(), - userList: NewMemberList(), - ulBorder: widget.NewBorder(), - input: mauview.NewInputArea(), - Room: room, - - topicScreen: &mauview.ProxyScreen{OffsetX: 0, OffsetY: 0, Height: TopicBarHeight}, - contentScreen: &mauview.ProxyScreen{OffsetX: 0, OffsetY: StatusBarHeight}, - statusScreen: &mauview.ProxyScreen{OffsetX: 0, Height: StatusBarHeight}, - inputScreen: &mauview.ProxyScreen{OffsetX: 0}, - ulBorderScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListBorderWidth}, - ulScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListWidth}, - - parent: parent, - config: parent.config, - } - view.content = NewMessageView(view) - view.Room.SetPreUnload(func() bool { - if view.parent.currentRoom == view { - return false - } - view.content.Unload() - return true - }) - view.Room.SetPostLoad(view.loadTyping) - - view.input. - SetTextColor(tcell.ColorDefault). - SetBackgroundColor(tcell.ColorDefault). - SetPlaceholder("Send a message..."). - SetPlaceholderTextColor(tcell.ColorGray). - SetTabCompleteFunc(view.InputTabComplete). - SetPressKeyUpAtStartFunc(view.EditPrevious). - SetPressKeyDownAtEndFunc(view.EditNext) - - if room.Encrypted { - view.input.SetPlaceholder("Send an encrypted message...") - } - - view.topic. - SetTextColor(tcell.ColorWhite). - SetBackgroundColor(tcell.ColorDarkGreen) - - view.status.SetBackgroundColor(tcell.ColorDimGray) - - return view -} - -func (view *RoomView) SetInputChangedFunc(fn func(room *RoomView, text string)) *RoomView { - view.input.SetChangedFunc(func(text string) { - fn(view, text) - }) - return view -} - -func (view *RoomView) SetInputText(newText string) *RoomView { - view.input.SetTextAndMoveCursor(newText) - return view -} - -func (view *RoomView) GetInputText() string { - return view.input.GetText() -} - -func (view *RoomView) Focus() { - view.input.Focus() -} - -func (view *RoomView) Blur() { - view.StopSelecting() - view.input.Blur() -} - -func (view *RoomView) StartSelecting(reason SelectReason, content string) { - view.selecting = true - view.selectReason = reason - view.selectContent = content - msgView := view.MessageView() - if msgView.selected != nil { - view.OnSelect(msgView.selected) - } else { - view.input.Blur() - view.SelectPrevious() - } -} - -func (view *RoomView) StopSelecting() { - view.selecting = false - view.selectContent = "" - view.MessageView().SetSelected(nil) -} - -func (view *RoomView) OnSelect(message *messages.UIMessage) { - if !view.selecting || message == nil { - return - } - switch view.selectReason { - case SelectReply: - view.replying = message.Event - if len(view.selectContent) > 0 { - go view.SendMessage(event.MsgText, view.selectContent) - } - case SelectEdit: - view.SetEditing(message.Event) - case SelectReact: - go view.SendReaction(message.EventID, view.selectContent) - case SelectRedact: - go view.Redact(message.EventID, view.selectContent) - case SelectDownload, SelectOpen: - msg, ok := message.Renderer.(*messages.FileMessage) - if ok { - path := "" - if len(view.selectContent) > 0 { - path = view.selectContent - } else if view.selectReason == SelectDownload { - path = msg.Body - } - go view.Download(msg.URL, msg.File, path, view.selectReason == SelectOpen) - } - case SelectCopy: - go view.CopyToClipboard(message.Renderer.PlainText(), view.selectContent) - } - view.selecting = false - view.selectContent = "" - view.MessageView().SetSelected(nil) - view.input.Focus() -} - -func (view *RoomView) GetStatus() string { - var buf strings.Builder - - if view.editing != nil { - buf.WriteString("Editing message - ") - } else if view.replying != nil { - buf.WriteString("Replying to ") - buf.WriteString(string(view.replying.Sender)) - buf.WriteString(" - ") - } else if view.selecting { - buf.WriteString("Selecting message to ") - buf.WriteString(string(view.selectReason)) - buf.WriteString(" - ") - } - - if len(view.completions.list) > 0 { - if view.completions.textCache != view.input.GetText() || view.completions.time.Add(10*time.Second).Before(time.Now()) { - view.completions.list = []string{} - } else { - buf.WriteString(strings.Join(view.completions.list, ", ")) - buf.WriteString(" - ") - } - } - - if len(view.typing) == 1 { - buf.WriteString("Typing: " + string(view.typing[0])) - buf.WriteString(" - ") - } else if len(view.typing) > 1 { - buf.WriteString("Typing: ") - for i, userID := range view.typing { - if i == len(view.typing)-1 { - buf.WriteString(" and ") - } else if i > 0 { - buf.WriteString(", ") - } - buf.WriteString(string(userID)) - } - buf.WriteString(" - ") - } - - return strings.TrimSuffix(buf.String(), " - ") -} - -// Constants defining the size of the room view grid. -const ( - UserListBorderWidth = 1 - UserListWidth = 20 - StaticHorizontalSpace = UserListBorderWidth + UserListWidth - - TopicBarHeight = 1 - StatusBarHeight = 1 - - MaxInputHeight = 5 -) - -func (view *RoomView) Draw(screen mauview.Screen) { - width, height := screen.Size() - if width <= 0 || height <= 0 { - return - } - - if view.prevScreen != screen { - view.topicScreen.Parent = screen - view.contentScreen.Parent = screen - view.statusScreen.Parent = screen - view.inputScreen.Parent = screen - view.ulBorderScreen.Parent = screen - view.ulScreen.Parent = screen - view.prevScreen = screen - } - - view.input.PrepareDraw(width) - inputHeight := view.input.GetTextHeight() - if inputHeight > MaxInputHeight { - inputHeight = MaxInputHeight - } else if inputHeight < 1 { - inputHeight = 1 - } - contentHeight := height - inputHeight - TopicBarHeight - StatusBarHeight - contentWidth := width - StaticHorizontalSpace - if view.config.Preferences.HideUserList { - contentWidth = width - } - - view.topicScreen.Width = width - view.contentScreen.Width = contentWidth - view.contentScreen.Height = contentHeight - view.statusScreen.OffsetY = view.contentScreen.YEnd() - view.statusScreen.Width = width - view.inputScreen.Width = width - view.inputScreen.OffsetY = view.statusScreen.YEnd() - view.inputScreen.Height = inputHeight - view.ulBorderScreen.OffsetX = view.contentScreen.XEnd() - view.ulBorderScreen.Height = contentHeight - view.ulScreen.OffsetX = view.ulBorderScreen.XEnd() - view.ulScreen.Height = contentHeight - - // Draw everything - view.topic.Draw(view.topicScreen) - view.content.Draw(view.contentScreen) - view.status.SetText(view.GetStatus()) - view.status.Draw(view.statusScreen) - view.input.Draw(view.inputScreen) - if !view.config.Preferences.HideUserList { - view.ulBorder.Draw(view.ulBorderScreen) - view.userList.Draw(view.ulScreen) - } -} - -func (view *RoomView) ClearAllContext() { - view.SetEditing(nil) - view.StopSelecting() - view.replying = nil - view.input.Focus() -} - -func (view *RoomView) OnKeyEvent(event mauview.KeyEvent) bool { - msgView := view.MessageView() - kb := config.Keybind{ - Key: event.Key(), - Ch: event.Rune(), - Mod: event.Modifiers(), - } - - if view.selecting { - switch view.config.Keybindings.Visual[kb] { - case "clear": - view.ClearAllContext() - case "select_prev": - view.SelectPrevious() - case "select_next": - view.SelectNext() - case "confirm": - view.OnSelect(msgView.selected) - default: - return false - } - return true - } - - switch view.config.Keybindings.Room[kb] { - case "clear": - view.ClearAllContext() - return true - case "scroll_up": - if msgView.IsAtTop() { - go view.parent.LoadHistory(view.Room.ID) - } - msgView.AddScrollOffset(+msgView.Height() / 2) - return true - case "scroll_down": - msgView.AddScrollOffset(-msgView.Height() / 2) - return true - case "send": - view.InputSubmit(view.input.GetText()) - return true - } - return view.input.OnKeyEvent(event) -} - -func (view *RoomView) OnPasteEvent(event mauview.PasteEvent) bool { - return view.input.OnPasteEvent(event) -} - -func (view *RoomView) OnMouseEvent(event mauview.MouseEvent) bool { - switch { - case view.contentScreen.IsInArea(event.Position()): - return view.content.OnMouseEvent(view.contentScreen.OffsetMouseEvent(event)) - case view.topicScreen.IsInArea(event.Position()): - return view.topic.OnMouseEvent(view.topicScreen.OffsetMouseEvent(event)) - case view.inputScreen.IsInArea(event.Position()): - return view.input.OnMouseEvent(view.inputScreen.OffsetMouseEvent(event)) - } - return false -} - -func (view *RoomView) SetCompletions(completions []string) { - view.completions.list = completions - view.completions.textCache = view.input.GetText() - view.completions.time = time.Now() -} - -func (view *RoomView) loadTyping() { - for index, user := range view.typing { - member := view.Room.GetMember(id.UserID(user)) - if member != nil { - view.typing[index] = member.Displayname - } - } -} - -func (view *RoomView) SetTyping(users []id.UserID) { - view.typing = make([]string, len(users)) - for i, user := range users { - view.typing[i] = string(user) - } - if view.Room.Loaded() { - view.loadTyping() - } -} - -var editHTMLParser = &format.HTMLParser{ - PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string { - if len(eventID) > 0 { - return fmt.Sprintf(`[%s](https://matrix.to/#/%s/%s)`, displayname, mxid, eventID) - } else { - return fmt.Sprintf(`[%s](https://matrix.to/#/%s)`, displayname, mxid) - } - }, - Newline: "\n", - HorizontalLine: "\n---\n", -} - -func (view *RoomView) SetEditing(evt *muksevt.Event) { - if evt == nil { - view.editing = nil - view.SetInputText(view.editMoveText) - view.editMoveText = "" - } else { - if view.editing == nil { - view.editMoveText = view.GetInputText() - } - view.editing = evt - // replying should never be non-nil when SetEditing, but do this just to be safe - view.replying = nil - msgContent := view.editing.Content.AsMessage() - if len(view.editing.Gomuks.Edits) > 0 { - // This feels kind of dangerous, but I think it works - msgContent = view.editing.Gomuks.Edits[len(view.editing.Gomuks.Edits)-1].Content.AsMessage().NewContent - } - text := msgContent.Body - if len(msgContent.FormattedBody) > 0 && (!view.config.Preferences.DisableMarkdown || !view.config.Preferences.DisableHTML) { - if view.config.Preferences.DisableMarkdown { - text = msgContent.FormattedBody - } else { - text = editHTMLParser.Parse(msgContent.FormattedBody, make(format.Context)) - } - } - if msgContent.MsgType == event.MsgEmote { - text = "/me " + text - } - view.input.SetText(text) - } - view.status.SetText(view.GetStatus()) - view.input.SetCursorOffset(-1) -} - -type findFilter func(evt *muksevt.Event) bool - -func (view *RoomView) filterOwnOnly(evt *muksevt.Event) bool { - return evt.Sender == view.parent.matrix.Client().UserID && evt.Type == event.EventMessage -} - -func (view *RoomView) filterMediaOnly(evt *muksevt.Event) bool { - content, ok := evt.Content.Parsed.(*event.MessageEventContent) - return ok && (content.MsgType == event.MsgFile || - content.MsgType == event.MsgImage || - content.MsgType == event.MsgAudio || - content.MsgType == event.MsgVideo) -} - -func (view *RoomView) findMessage(current *muksevt.Event, forward bool, allow findFilter) *messages.UIMessage { - currentFound := current == nil - msgs := view.MessageView().messages - for i := 0; i < len(msgs); i++ { - index := i - if !forward { - index = len(msgs) - i - 1 - } - evt := msgs[index] - if evt.EventID == "" || string(evt.EventID) == evt.TxnID || evt.IsService { - continue - } else if currentFound { - if allow == nil || allow(evt.Event) { - return evt - } - } else if evt.EventID == current.ID { - currentFound = true - } - } - return nil -} - -func (view *RoomView) EditNext() { - if view.editing == nil { - return - } - foundMsg := view.findMessage(view.editing, true, view.filterOwnOnly) - view.SetEditing(foundMsg.GetEvent()) -} - -func (view *RoomView) EditPrevious() { - if view.replying != nil { - return - } - foundMsg := view.findMessage(view.editing, false, view.filterOwnOnly) - if foundMsg != nil { - view.SetEditing(foundMsg.GetEvent()) - } -} - -func (view *RoomView) SelectNext() { - msgView := view.MessageView() - if msgView.selected == nil { - return - } - var filter findFilter - if view.selectReason == SelectDownload || view.selectReason == SelectOpen { - filter = view.filterMediaOnly - } - foundMsg := view.findMessage(msgView.selected.GetEvent(), true, filter) - if foundMsg != nil { - msgView.SetSelected(foundMsg) - // TODO scroll selected message into view - } -} - -func (view *RoomView) SelectPrevious() { - msgView := view.MessageView() - var filter findFilter - if view.selectReason == SelectDownload || view.selectReason == SelectOpen { - filter = view.filterMediaOnly - } - foundMsg := view.findMessage(msgView.selected.GetEvent(), false, filter) - if foundMsg != nil { - msgView.SetSelected(foundMsg) - // TODO scroll selected message into view - } -} - -type completion struct { - displayName string - id string -} - -func (view *RoomView) AutocompleteUser(existingText string) (completions []completion) { - textWithoutPrefix := strings.TrimPrefix(existingText, "@") - for userID, user := range view.Room.GetMembers() { - if user.Displayname == textWithoutPrefix || string(userID) == existingText { - // Exact match, return that. - return []completion{{user.Displayname, string(userID)}} - } - - if strings.HasPrefix(user.Displayname, textWithoutPrefix) || strings.HasPrefix(string(userID), existingText) { - completions = append(completions, completion{user.Displayname, string(userID)}) - } - } - return -} - -func (view *RoomView) AutocompleteRoom(existingText string) (completions []completion) { - for _, room := range view.parent.rooms { - alias := string(room.Room.GetCanonicalAlias()) - if alias == existingText { - // Exact match, return that. - return []completion{{alias, string(room.Room.ID)}} - } - if strings.HasPrefix(alias, existingText) { - completions = append(completions, completion{alias, string(room.Room.ID)}) - continue - } - } - return -} - -func (view *RoomView) AutocompleteEmoji(word string) (completions []string) { - if word[0] != ':' { - return - } - var valueCompletion1 string - var manyValues bool - for name, value := range emoji.CodeMap() { - if name == word { - return []string{value} - } else if strings.HasPrefix(name, word) { - completions = append(completions, name) - if valueCompletion1 == "" { - valueCompletion1 = value - } else if valueCompletion1 != value { - manyValues = true - } - } - } - if !manyValues && len(completions) > 0 { - return []string{emoji.CodeMap()[completions[0]]} - } - return -} - -func findWordToTabComplete(text string) string { - output := "" - runes := []rune(text) - for i := len(runes) - 1; i >= 0; i-- { - if unicode.IsSpace(runes[i]) { - break - } - output = string(runes[i]) + output - } - return output -} - -var ( - mentionMarkdown = "[%[1]s](https://matrix.to/#/%[2]s)" - mentionHTML = `%[1]s` - mentionPlaintext = "%[1]s" -) - -func (view *RoomView) defaultAutocomplete(word string, startIndex int) (strCompletions []string, strCompletion string) { - if len(word) == 0 { - return []string{}, "" - } - - completions := view.AutocompleteUser(word) - completions = append(completions, view.AutocompleteRoom(word)...) - - if len(completions) == 1 { - completion := completions[0] - template := mentionMarkdown - if view.config.Preferences.DisableMarkdown { - if view.config.Preferences.DisableHTML { - template = mentionPlaintext - } else { - template = mentionHTML - } - } - strCompletion = fmt.Sprintf(template, completion.displayName, completion.id) - if startIndex == 0 && completion.id[0] == '@' { - strCompletion = strCompletion + ":" - } - } else if len(completions) > 1 { - for _, completion := range completions { - strCompletions = append(strCompletions, completion.displayName) - } - } - - strCompletions = append(strCompletions, view.parent.cmdProcessor.AutocompleteCommand(word)...) - strCompletions = append(strCompletions, view.AutocompleteEmoji(word)...) - - return -} - -func (view *RoomView) InputTabComplete(text string, cursorOffset int) { - if len(text) == 0 { - return - } - - str := runewidth.Truncate(text, cursorOffset, "") - word := findWordToTabComplete(str) - startIndex := len(str) - len(word) - - var strCompletion string - - strCompletions, newText, ok := view.parent.cmdProcessor.Autocomplete(view, text, cursorOffset) - if !ok { - strCompletions, strCompletion = view.defaultAutocomplete(word, startIndex) - } - - if len(strCompletions) > 0 { - strCompletion = util.LongestCommonPrefix(strCompletions) - sort.Sort(sort.StringSlice(strCompletions)) - } - if len(strCompletion) > 0 && len(strCompletions) < 2 { - strCompletion += " " - strCompletions = []string{} - } - - if len(strCompletion) > 0 && newText == text { - newText = str[0:startIndex] + strCompletion + text[len(str):] - } - - view.input.SetTextAndMoveCursor(newText) - view.SetCompletions(strCompletions) -} - -func (view *RoomView) InputSubmit(text string) { - if len(text) == 0 { - return - } else if cmd := view.parent.cmdProcessor.ParseCommand(view, text); cmd != nil { - go view.parent.cmdProcessor.HandleCommand(cmd) - } else { - go view.SendMessage(event.MsgText, text) - } - view.editMoveText = "" - view.SetInputText("") -} - -func (view *RoomView) CopyToClipboard(text string, register string) { - if register == "clipboard" || register == "primary" { - err := clipboard.WriteAll(text, register) - if err != nil { - view.AddServiceMessage(fmt.Sprintf("Clipboard unsupported: %v", err)) - view.parent.parent.Render() - } - } else { - view.AddServiceMessage(fmt.Sprintf("Clipboard register %v unsupported", register)) - view.parent.parent.Render() - } -} - -func (view *RoomView) Download(url id.ContentURI, file *attachment.EncryptedFile, filename string, openFile bool) { - path, err := view.parent.matrix.DownloadToDisk(url, file, filename) - if err != nil { - view.AddServiceMessage(fmt.Sprintf("Failed to download media: %v", err)) - view.parent.parent.Render() - return - } - view.AddServiceMessage(fmt.Sprintf("File downloaded to %s", path)) - view.parent.parent.Render() - if openFile { - debug.Print("Opening file", path) - open.Open(path) - } -} - -func (view *RoomView) Redact(eventID id.EventID, reason string) { - defer debug.Recover() - err := view.parent.matrix.Redact(view.Room.ID, eventID, reason) - if err != nil { - if httpErr, ok := err.(mautrix.HTTPError); ok { - err = httpErr - if respErr := httpErr.RespError; respErr != nil { - err = respErr - } - } - view.AddServiceMessage(fmt.Sprintf("Failed to redact message: %v", err)) - view.parent.parent.Render() - } -} - -func (view *RoomView) SendReaction(eventID id.EventID, reaction string) { - defer debug.Recover() - if !view.config.Preferences.DisableEmojis { - reaction = emoji.Sprint(reaction) - } - reaction = variationselector.Add(strings.TrimSpace(reaction)) - debug.Print("Reacting to", eventID, "in", view.Room.ID, "with", reaction) - eventID, err := view.parent.matrix.SendEvent(&muksevt.Event{ - Event: &event.Event{ - Type: event.EventReaction, - RoomID: view.Room.ID, - Content: event.Content{Parsed: &event.ReactionEventContent{RelatesTo: event.RelatesTo{ - Type: event.RelAnnotation, - EventID: eventID, - Key: reaction, - }}}, - }, - }) - if err != nil { - if httpErr, ok := err.(mautrix.HTTPError); ok { - err = httpErr - if respErr := httpErr.RespError; respErr != nil { - err = respErr - } - } - view.AddServiceMessage(fmt.Sprintf("Failed to send reaction: %v", err)) - view.parent.parent.Render() - } -} - -func (view *RoomView) SendMessage(msgtype event.MessageType, text string) { - view.SendMessageHTML(msgtype, text, "") -} - -func (view *RoomView) getRelationForNewEvent() *ifc.Relation { - if view.editing != nil { - return &ifc.Relation{ - Type: event.RelReplace, - Event: view.editing, - } - } else if view.replying != nil { - return &ifc.Relation{ - Type: event.RelReply, - Event: view.replying, - } - } - return nil -} - -func (view *RoomView) SendMessageHTML(msgtype event.MessageType, text, html string) { - defer debug.Recover() - debug.Print("Sending message", msgtype, text, "to", view.Room.ID) - if !view.config.Preferences.DisableEmojis { - text = emoji.Sprint(text) - } - rel := view.getRelationForNewEvent() - evt := view.parent.matrix.PrepareMarkdownMessage(view.Room.ID, msgtype, text, html, rel) - view.addLocalEcho(evt) -} - -func (view *RoomView) SendMessageMedia(path string) { - defer debug.Recover() - debug.Print("Sending media at", path, "to", view.Room.ID) - rel := view.getRelationForNewEvent() - evt, err := view.parent.matrix.PrepareMediaMessage(view.Room, path, rel) - if err != nil { - view.AddServiceMessage(fmt.Sprintf("Failed to upload media: %v", err)) - view.parent.parent.Render() - return - } - view.addLocalEcho(evt) -} - -func (view *RoomView) addLocalEcho(evt *muksevt.Event) { - msg := view.parseEvent(evt.SomewhatDangerousCopy()) - view.content.AddMessage(msg, AppendMessage) - view.ClearAllContext() - view.status.SetText(view.GetStatus()) - eventID, err := view.parent.matrix.SendEvent(evt) - if err != nil { - msg.State = muksevt.StateSendFail - // Show shorter version if available - if httpErr, ok := err.(mautrix.HTTPError); ok { - err = httpErr - if respErr := httpErr.RespError; respErr != nil { - err = respErr - } - } - view.AddServiceMessage(fmt.Sprintf("Failed to send message: %v", err)) - view.parent.parent.Render() - } else { - debug.Print("Event ID received:", eventID) - msg.EventID = eventID - msg.State = muksevt.StateDefault - view.MessageView().setMessageID(msg) - view.parent.parent.Render() - } -} - -func (view *RoomView) MessageView() *MessageView { - return view.content -} - -func (view *RoomView) MxRoom() *rooms.Room { - return view.Room -} - -func (view *RoomView) Update() { - topicStr := strings.TrimSpace(strings.ReplaceAll(view.Room.GetTopic(), "\n", " ")) - if view.config.Preferences.HideRoomList { - if len(topicStr) > 0 { - topicStr = fmt.Sprintf("%s - %s", view.Room.GetTitle(), topicStr) - } else { - topicStr = view.Room.GetTitle() - } - topicStr = strings.TrimSpace(topicStr) - } - view.topic.SetText(topicStr) - if !view.userListLoaded { - view.UpdateUserList() - } -} - -func (view *RoomView) UpdateUserList() { - pls := &event.PowerLevelsEventContent{} - if plEvent := view.Room.GetStateEvent(event.StatePowerLevels, ""); plEvent != nil { - pls = plEvent.Content.AsPowerLevels() - } - view.userList.Update(view.Room.GetMembers(), pls) - view.userListLoaded = true -} - -func (view *RoomView) AddServiceMessage(text string) { - view.content.AddMessage(messages.NewServiceMessage(text), AppendMessage) -} - -func (view *RoomView) parseEvent(evt *muksevt.Event) *messages.UIMessage { - return messages.ParseEvent(view.parent.matrix, view.parent, view.Room, evt) -} - -func (view *RoomView) AddHistoryEvent(evt *muksevt.Event) { - if msg := view.parseEvent(evt); msg != nil { - view.content.AddMessage(msg, PrependMessage) - } -} - -func (view *RoomView) AddEvent(evt *muksevt.Event) ifc.Message { - if msg := view.parseEvent(evt); msg != nil { - view.content.AddMessage(msg, AppendMessage) - return msg - } - return nil -} - -func (view *RoomView) AddRedaction(redactedEvt *muksevt.Event) { - view.AddEvent(redactedEvt) -} - -func (view *RoomView) AddEdit(evt *muksevt.Event) { - if msg := view.parseEvent(evt); msg != nil { - view.content.AddMessage(msg, IgnoreMessage) - } -} - -func (view *RoomView) AddReaction(evt *muksevt.Event, key string) { - msgView := view.MessageView() - msg := msgView.getMessageByID(evt.ID) - if msg == nil { - // Message not in view, nothing to do - return - } - heightChanged := len(msg.Reactions) == 0 - msg.AddReaction(key) - if heightChanged { - // Replace buffer to update height of message - msgView.replaceBuffer(msg, msg) - } -} - -func (view *RoomView) GetEvent(eventID id.EventID) ifc.Message { - message, ok := view.content.messageIDs[eventID] - if !ok { - return nil - } - return message -} diff --git a/ui/syncing-modal.go b/ui/syncing-modal.go deleted file mode 100644 index 646d479..0000000 --- a/ui/syncing-modal.go +++ /dev/null @@ -1,71 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "time" - - "go.mau.fi/mauview" -) - -type SyncingModal struct { - parent *MainView - text *mauview.TextView - progress *mauview.ProgressBar -} - -func NewSyncingModal(parent *MainView) (mauview.Component, *SyncingModal) { - sm := &SyncingModal{ - parent: parent, - progress: mauview.NewProgressBar(), - text: mauview.NewTextView(), - } - return mauview.Center( - mauview.NewBox( - mauview.NewFlex(). - SetDirection(mauview.FlexRow). - AddFixedComponent(sm.progress, 1). - AddFixedComponent(mauview.Center(sm.text, 40, 1), 1)). - SetTitle("Synchronizing"), - 42, 4). - SetAlwaysFocusChild(true), sm -} - -func (sm *SyncingModal) SetMessage(text string) { - sm.text.SetText(text) -} - -func (sm *SyncingModal) SetIndeterminate() { - sm.progress.SetIndeterminate(true) - sm.parent.parent.app.SetRedrawTicker(100 * time.Millisecond) - sm.parent.parent.app.Redraw() -} - -func (sm *SyncingModal) SetSteps(max int) { - sm.progress.SetMax(max) - sm.progress.SetIndeterminate(false) - sm.parent.parent.app.SetRedrawTicker(1 * time.Minute) - sm.parent.parent.Render() -} - -func (sm *SyncingModal) Step() { - sm.progress.Increment(1) -} - -func (sm *SyncingModal) Close() { - sm.parent.HideModal() -} diff --git a/ui/tag-room-list.go b/ui/tag-room-list.go deleted file mode 100644 index 1a01d5d..0000000 --- a/ui/tag-room-list.go +++ /dev/null @@ -1,331 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "encoding/json" - "fmt" - "math" - "strconv" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/gomuks/debug" - - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/widget" -) - -type OrderedRoom struct { - *rooms.Room - order float64 -} - -func NewOrderedRoom(order json.Number, room *rooms.Room) *OrderedRoom { - numOrder, err := order.Float64() - if err != nil { - numOrder = 0.5 - } - return &OrderedRoom{ - Room: room, - order: numOrder, - } -} - -func NewDefaultOrderedRoom(room *rooms.Room) *OrderedRoom { - return NewOrderedRoom("0.5", room) -} - -func (or *OrderedRoom) Draw(roomList *RoomList, screen mauview.Screen, x, y, lineWidth int, isSelected bool) { - style := tcell.StyleDefault. - Foreground(roomList.mainTextColor). - Bold(or.HasNewMessages()) - if isSelected { - style = style. - Foreground(roomList.selectedTextColor). - Background(roomList.selectedBackgroundColor) - } - - unreadCount := or.UnreadCount() - - widget.WriteLinePadded(screen, mauview.AlignLeft, or.GetTitle(), x, y, lineWidth, style) - - if unreadCount > 0 { - unreadMessageCount := "99+" - if unreadCount < 100 { - unreadMessageCount = strconv.Itoa(unreadCount) - } - if or.Highlighted() { - unreadMessageCount += "!" - } - unreadMessageCount = fmt.Sprintf("(%s)", unreadMessageCount) - widget.WriteLine(screen, mauview.AlignRight, unreadMessageCount, x+lineWidth-7, y, 7, style) - lineWidth -= len(unreadMessageCount) - } -} - -type TagRoomList struct { - mauview.NoopEventHandler - // The list of rooms in the list, in reverse order - rooms []*OrderedRoom - // Maximum number of rooms to show - maxShown int - // The internal name of this tag - name string - // The displayname of this tag - displayname string - // The parent RoomList instance - parent *RoomList -} - -func NewTagRoomList(parent *RoomList, name string, rooms ...*OrderedRoom) *TagRoomList { - return &TagRoomList{ - maxShown: 10, - rooms: rooms, - name: name, - displayname: parent.GetTagDisplayName(name), - parent: parent, - } -} - -func (trl *TagRoomList) Visible() []*OrderedRoom { - return trl.rooms[len(trl.rooms)-trl.Length():] -} - -func (trl *TagRoomList) FirstVisible() *rooms.Room { - visible := trl.Visible() - if len(visible) > 0 { - return visible[len(visible)-1].Room - } - return nil -} - -func (trl *TagRoomList) LastVisible() *rooms.Room { - visible := trl.Visible() - if len(visible) > 0 { - return visible[0].Room - } - return nil -} - -func (trl *TagRoomList) All() []*OrderedRoom { - return trl.rooms -} - -func (trl *TagRoomList) Length() int { - if len(trl.rooms) < trl.maxShown { - return len(trl.rooms) - } - return trl.maxShown -} - -func (trl *TagRoomList) TotalLength() int { - return len(trl.rooms) -} - -func (trl *TagRoomList) IsEmpty() bool { - return len(trl.rooms) == 0 -} - -func (trl *TagRoomList) IsCollapsed() bool { - return trl.maxShown == 0 -} - -func (trl *TagRoomList) ToggleCollapse() { - if trl.IsCollapsed() { - trl.maxShown = 10 - } else { - trl.maxShown = 0 - } -} - -func (trl *TagRoomList) HasInvisibleRooms() bool { - return trl.maxShown < trl.TotalLength() -} - -func (trl *TagRoomList) HasVisibleRooms() bool { - return !trl.IsEmpty() && trl.maxShown > 0 -} - -const equalityThreshold = 1e-6 - -func almostEqual(a, b float64) bool { - return math.Abs(a-b) <= equalityThreshold -} - -// ShouldBeAfter returns if the first room should be after the second room in the room list. -// The manual order and last received message timestamp are considered. -func (trl *TagRoomList) ShouldBeAfter(room1 *OrderedRoom, room2 *OrderedRoom) bool { - // Lower order value = higher in list - return room1.order > room2.order || - // Equal order value and more recent message = higher in the list - (almostEqual(room1.order, room2.order) && room2.LastReceivedMessage.After(room1.LastReceivedMessage)) -} - -func (trl *TagRoomList) Insert(order json.Number, mxRoom *rooms.Room) { - room := NewOrderedRoom(order, mxRoom) - // The default insert index is the newly added slot. - // That index will be used if all other rooms in the list have the same LastReceivedMessage timestamp. - insertAt := len(trl.rooms) - // Find the spot where the new room should be put according to the last received message timestamps. - for i := 0; i < len(trl.rooms); i++ { - if trl.rooms[i].Room == mxRoom { - debug.Printf("Warning: tried to re-insert room %s into tag %s", mxRoom.ID, trl.name) - return - } else if trl.ShouldBeAfter(room, trl.rooms[i]) { - insertAt = i - break - } - } - trl.rooms = append(trl.rooms, nil) - copy(trl.rooms[insertAt+1:], trl.rooms[insertAt:len(trl.rooms)-1]) - trl.rooms[insertAt] = room -} - -func (trl *TagRoomList) Bump(mxRoom *rooms.Room) { - var roomBeingBumped *OrderedRoom - for i := 0; i < len(trl.rooms); i++ { - currentIndexRoom := trl.rooms[i] - if roomBeingBumped != nil { - if trl.ShouldBeAfter(roomBeingBumped, currentIndexRoom) { - // This room should be after the room being bumped, so insert the - // room being bumped here and return - trl.rooms[i-1] = roomBeingBumped - return - } - // Move older rooms back in the array - trl.rooms[i-1] = currentIndexRoom - } else if currentIndexRoom.Room == mxRoom { - roomBeingBumped = currentIndexRoom - } - } - if roomBeingBumped == nil { - debug.Print("Warning: couldn't find room", mxRoom.ID, mxRoom.NameCache, "to bump in tag", trl.name) - return - } - // If the room being bumped should be first in the list, it won't be inserted during the loop. - trl.rooms[len(trl.rooms)-1] = roomBeingBumped -} - -func (trl *TagRoomList) Remove(room *rooms.Room) { - trl.RemoveIndex(trl.Index(room)) -} - -func (trl *TagRoomList) RemoveIndex(index int) { - if index < 0 || index > len(trl.rooms) { - return - } - last := len(trl.rooms) - 1 - if index < last { - copy(trl.rooms[index:], trl.rooms[index+1:]) - } - trl.rooms[last] = nil - trl.rooms = trl.rooms[:last] -} - -func (trl *TagRoomList) Index(room *rooms.Room) int { - return trl.indexInList(trl.All(), room) -} - -func (trl *TagRoomList) IndexVisible(room *rooms.Room) int { - return trl.indexInList(trl.Visible(), room) -} - -func (trl *TagRoomList) indexInList(list []*OrderedRoom, room *rooms.Room) int { - for index, entry := range list { - if entry.Room == room { - return index - } - } - return -1 -} - -var TagDisplayNameStyle = tcell.StyleDefault.Underline(true).Bold(true) -var TagRoomCountStyle = tcell.StyleDefault.Italic(true) - -func (trl *TagRoomList) RenderHeight() int { - if len(trl.displayname) == 0 { - return 0 - } - - if trl.IsCollapsed() { - return 1 - } - height := 2 + trl.Length() - if trl.HasInvisibleRooms() || trl.maxShown > 10 { - height++ - } - return height -} - -func (trl *TagRoomList) DrawHeader(screen mauview.Screen) { - width, _ := screen.Size() - roomCount := strconv.Itoa(trl.TotalLength()) - - // Draw tag name - displayNameWidth := width - 1 - len(roomCount) - widget.WriteLine(screen, mauview.AlignLeft, trl.displayname, 0, 0, displayNameWidth, TagDisplayNameStyle) - - // Draw tag room count - roomCountX := len(trl.displayname) + 1 - roomCountWidth := width - 2 - len(trl.displayname) - widget.WriteLine(screen, mauview.AlignLeft, roomCount, roomCountX, 0, roomCountWidth, TagRoomCountStyle) -} - -func (trl *TagRoomList) Draw(screen mauview.Screen) { - if len(trl.displayname) == 0 { - return - } - - trl.DrawHeader(screen) - - width, height := screen.Size() - - items := trl.Visible() - - if trl.IsCollapsed() { - screen.SetCell(width-1, 0, tcell.StyleDefault, '▶') - return - } - screen.SetCell(width-1, 0, tcell.StyleDefault, '▼') - - y := 1 - for i := len(items) - 1; i >= 0; i-- { - if y >= height { - return - } - - item := items[i] - - lineWidth := width - isSelected := trl.name == trl.parent.selectedTag && item.Room == trl.parent.selected - item.Draw(trl.parent, screen, 0, y, lineWidth, isSelected) - y++ - } - hasLess := trl.maxShown > 10 - hasMore := trl.HasInvisibleRooms() - if (hasLess || hasMore) && y < height { - if hasMore { - widget.WriteLine(screen, mauview.AlignRight, "More ↓", 0, y, width, tcell.StyleDefault) - } - if hasLess { - widget.WriteLine(screen, mauview.AlignLeft, "↑ Less", 0, y, width, tcell.StyleDefault) - } - y++ - } -} diff --git a/ui/ui.go b/ui/ui.go deleted file mode 100644 index d823e64..0000000 --- a/ui/ui.go +++ /dev/null @@ -1,128 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "os" - "os/exec" - - "github.com/zyedidia/clipboard" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - ifc "maunium.net/go/gomuks/interface" -) - -type View string - -// Allowed views in GomuksUI -const ( - ViewLogin View = "login" - ViewMain View = "main" -) - -type GomuksUI struct { - gmx ifc.Gomuks - app *mauview.Application - - mainView *MainView - loginView *LoginView - - views map[View]mauview.Component -} - -func init() { - mauview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault - mauview.Styles.PrimaryTextColor = tcell.ColorDefault - mauview.Styles.BorderColor = tcell.ColorDefault - mauview.Styles.ContrastBackgroundColor = tcell.ColorDarkGreen - if tcellDB := os.Getenv("TCELLDB"); len(tcellDB) == 0 { - if info, err := os.Stat("/usr/share/tcell/database"); err == nil && info.IsDir() { - os.Setenv("TCELLDB", "/usr/share/tcell/database") - } - } -} - -func NewGomuksUI(gmx ifc.Gomuks) ifc.GomuksUI { - ui := &GomuksUI{ - gmx: gmx, - app: mauview.NewApplication(), - } - return ui -} - -func (ui *GomuksUI) Init() { - mauview.Backspace2RemovesWord = ui.gmx.Config().Backspace2RemovesWord - mauview.Backspace1RemovesWord = ui.gmx.Config().Backspace1RemovesWord - ui.app.SetAlwaysClear(ui.gmx.Config().AlwaysClearScreen) - clipboard.Initialize() - ui.views = map[View]mauview.Component{ - ViewLogin: ui.NewLoginView(), - ViewMain: ui.NewMainView(), - } - ui.SetView(ViewLogin) -} - -func (ui *GomuksUI) Start() error { - return ui.app.Start() -} - -func (ui *GomuksUI) Stop() { - ui.app.Stop() -} - -func (ui *GomuksUI) Finish() { - ui.app.ForceStop() -} - -func (ui *GomuksUI) Render() { - ui.app.Redraw() -} - -func (ui *GomuksUI) OnLogin() { - ui.SetView(ViewMain) -} - -func (ui *GomuksUI) OnLogout() { - ui.SetView(ViewLogin) -} - -func (ui *GomuksUI) HandleNewPreferences() { - ui.Render() -} - -func (ui *GomuksUI) SetView(name View) { - ui.app.SetRoot(ui.views[name]) -} - -func (ui *GomuksUI) MainView() ifc.MainView { - return ui.mainView -} - -func (ui *GomuksUI) RunExternal(executablePath string, args ...string) error { - callback := make(chan error) - ui.app.Suspend(func() { - cmd := exec.Command(executablePath, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin - cmd.Env = os.Environ() - callback <- cmd.Run() - }) - return <-callback -} diff --git a/ui/verification-modal.go b/ui/verification-modal.go deleted file mode 100644 index 0959474..0000000 --- a/ui/verification-modal.go +++ /dev/null @@ -1,253 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//go:build cgo - -package ui - -import ( - "fmt" - "strconv" - "strings" - "time" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/mautrix/crypto" - "maunium.net/go/mautrix/event" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" -) - -type EmojiView struct { - mauview.SimpleEventHandler - Data crypto.SASData -} - -func (e *EmojiView) Draw(screen mauview.Screen) { - if e.Data == nil { - return - } - switch e.Data.Type() { - case event.SASEmoji: - width := 10 - for i, emoji := range e.Data.(crypto.EmojiSASData) { - x := i*width + i - y := 0 - if i >= 4 { - x = (i-4)*width + i - y = 2 - } - mauview.Print(screen, string(emoji.Emoji), x, y, width, mauview.AlignCenter, tcell.ColorDefault) - mauview.Print(screen, emoji.Description, x, y+1, width, mauview.AlignCenter, tcell.ColorDefault) - } - case event.SASDecimal: - maxWidth := 43 - for i, number := range e.Data.(crypto.DecimalSASData) { - mauview.Print(screen, strconv.FormatUint(uint64(number), 10), 0, i, maxWidth, mauview.AlignCenter, tcell.ColorDefault) - } - } -} - -type VerificationModal struct { - mauview.Component - - device *crypto.DeviceIdentity - - container *mauview.Box - - waitingBar *mauview.ProgressBar - infoText *mauview.TextView - emojiText *EmojiView - inputBar *mauview.InputField - - progress int - progressMax int - stopWaiting chan struct{} - confirmChan chan bool - done bool - - parent *MainView -} - -func NewVerificationModal(mainView *MainView, device *crypto.DeviceIdentity, timeout time.Duration) *VerificationModal { - vm := &VerificationModal{ - parent: mainView, - device: device, - stopWaiting: make(chan struct{}), - confirmChan: make(chan bool), - done: false, - } - - vm.progressMax = int(timeout.Seconds()) - vm.progress = vm.progressMax - vm.waitingBar = mauview.NewProgressBar(). - SetMax(vm.progressMax). - SetProgress(vm.progress). - SetIndeterminate(false) - - vm.infoText = mauview.NewTextView() - vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto accept", device.UserID)) - - vm.emojiText = &EmojiView{} - - vm.inputBar = mauview.NewInputField(). - SetBackgroundColor(tcell.ColorDefault). - SetPlaceholderTextColor(tcell.ColorDefault) - - flex := mauview.NewFlex(). - SetDirection(mauview.FlexRow). - AddFixedComponent(vm.waitingBar, 1). - AddFixedComponent(vm.infoText, 4). - AddFixedComponent(vm.emojiText, 4). - AddFixedComponent(vm.inputBar, 1) - - vm.container = mauview.NewBox(flex). - SetBorder(true). - SetTitle("Interactive verification") - - vm.Component = mauview.Center(vm.container, 45, 12).SetAlwaysFocusChild(true) - - go vm.decrementWaitingBar() - - return vm -} - -func (vm *VerificationModal) decrementWaitingBar() { - for { - select { - case <-time.Tick(time.Second): - if vm.progress <= 0 { - vm.waitingBar.SetIndeterminate(true) - vm.parent.parent.app.SetRedrawTicker(100 * time.Millisecond) - return - } - vm.progress-- - vm.waitingBar.SetProgress(vm.progress) - vm.parent.parent.Render() - case <-vm.stopWaiting: - return - } - } -} - -func (vm *VerificationModal) VerificationMethods() []crypto.VerificationMethod { - return []crypto.VerificationMethod{crypto.VerificationMethodEmoji{}, crypto.VerificationMethodDecimal{}} -} - -func (vm *VerificationModal) VerifySASMatch(device *crypto.DeviceIdentity, data crypto.SASData) bool { - vm.device = device - var typeName string - if data.Type() == event.SASDecimal { - typeName = "numbers" - } else if data.Type() == event.SASEmoji { - typeName = "emojis" - } else { - return false - } - vm.infoText.SetText(fmt.Sprintf( - "Check if the other device is showing the\n"+ - "same %s as below, then type \"yes\" to\n"+ - "accept, or \"no\" to reject", typeName)) - vm.inputBar. - SetTextColor(tcell.ColorDefault). - SetBackgroundColor(tcell.ColorDarkCyan). - SetPlaceholder("Type \"yes\" or \"no\""). - Focus() - vm.emojiText.Data = data - vm.parent.parent.Render() - vm.progress = vm.progressMax - confirm := <-vm.confirmChan - vm.progress = vm.progressMax - vm.emojiText.Data = nil - vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto confirm", vm.device.UserID)) - vm.parent.parent.Render() - return confirm -} - -func (vm *VerificationModal) OnCancel(cancelledByUs bool, reason string, _ event.VerificationCancelCode) { - vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100) - vm.parent.parent.app.SetRedrawTicker(1 * time.Minute) - if cancelledByUs { - vm.infoText.SetText(fmt.Sprintf("Verification failed: %s", reason)) - } else { - vm.infoText.SetText(fmt.Sprintf("Verification cancelled by %s: %s", vm.device.UserID, reason)) - } - vm.inputBar.SetPlaceholder("Press enter to close the dialog") - vm.stopWaiting <- struct{}{} - vm.done = true - vm.parent.parent.Render() -} - -func (vm *VerificationModal) OnSuccess() { - vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100) - vm.parent.parent.app.SetRedrawTicker(1 * time.Minute) - vm.infoText.SetText(fmt.Sprintf("Successfully verified %s (%s) of %s", vm.device.Name, vm.device.DeviceID, vm.device.UserID)) - vm.inputBar.SetPlaceholder("Press enter to close the dialog") - vm.stopWaiting <- struct{}{} - vm.done = true - vm.parent.parent.Render() - if vm.parent.config.SendToVerifiedOnly { - // Hacky way to make new group sessions after verified - vm.parent.matrix.Crypto().(*crypto.OlmMachine).OnDevicesChanged(vm.device.UserID) - } -} - -func (vm *VerificationModal) OnKeyEvent(event mauview.KeyEvent) bool { - kb := config.Keybind{ - Key: event.Key(), - Ch: event.Rune(), - Mod: event.Modifiers(), - } - if vm.done { - if vm.parent.config.Keybindings.Modal[kb] == "cancel" || vm.parent.config.Keybindings.Modal[kb] == "confirm" { - vm.parent.HideModal() - return true - } - return false - } else if vm.emojiText.Data == nil { - debug.Print("Ignoring pre-emoji key event") - return false - } - if vm.parent.config.Keybindings.Modal[kb] == "confirm" { - text := strings.ToLower(strings.TrimSpace(vm.inputBar.GetText())) - if text == "yes" { - debug.Print("Confirming verification") - vm.confirmChan <- true - } else if text == "no" { - debug.Print("Rejecting verification") - vm.confirmChan <- false - } - vm.inputBar. - SetPlaceholder(""). - SetTextAndMoveCursor(""). - SetBackgroundColor(tcell.ColorDefault). - SetTextColor(tcell.ColorDefault) - return true - } else { - return vm.inputBar.OnKeyEvent(event) - } -} - -func (vm *VerificationModal) Focus() { - vm.container.Focus() -} - -func (vm *VerificationModal) Blur() { - vm.container.Blur() -} diff --git a/ui/view-login.go b/ui/view-login.go deleted file mode 100644 index 100a56c..0000000 --- a/ui/view-login.go +++ /dev/null @@ -1,196 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "math" - - "github.com/mattn/go-runewidth" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/id" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" -) - -type LoginView struct { - *mauview.Form - - container *mauview.Centerer - - homeserverLabel *mauview.TextField - usernameLabel *mauview.TextField - passwordLabel *mauview.TextField - - homeserver *mauview.InputField - username *mauview.InputField - password *mauview.InputField - error *mauview.TextView - - loginButton *mauview.Button - quitButton *mauview.Button - - loading bool - - matrix ifc.MatrixContainer - config *config.Config - parent *GomuksUI -} - -func (ui *GomuksUI) NewLoginView() mauview.Component { - view := &LoginView{ - Form: mauview.NewForm(), - - usernameLabel: mauview.NewTextField().SetText("Username"), - passwordLabel: mauview.NewTextField().SetText("Password"), - homeserverLabel: mauview.NewTextField().SetText("Homeserver"), - - username: mauview.NewInputField(), - password: mauview.NewInputField(), - homeserver: mauview.NewInputField(), - - loginButton: mauview.NewButton("Login"), - quitButton: mauview.NewButton("Quit"), - - matrix: ui.gmx.Matrix(), - config: ui.gmx.Config(), - parent: ui, - } - - hs := ui.gmx.Config().HS - view.homeserver.SetPlaceholder("https://example.com").SetText(hs).SetTextColor(tcell.ColorWhite) - view.username.SetPlaceholder("@user:example.com").SetText(string(ui.gmx.Config().UserID)).SetTextColor(tcell.ColorWhite) - view.password.SetPlaceholder("correct horse battery staple").SetMaskCharacter('*').SetTextColor(tcell.ColorWhite) - - view.quitButton. - SetOnClick(func() { ui.gmx.Stop(true) }). - SetBackgroundColor(tcell.ColorDarkCyan). - SetForegroundColor(tcell.ColorWhite). - SetFocusedForegroundColor(tcell.ColorWhite) - view.loginButton. - SetOnClick(view.Login). - SetBackgroundColor(tcell.ColorDarkCyan). - SetForegroundColor(tcell.ColorWhite). - SetFocusedForegroundColor(tcell.ColorWhite) - - view. - SetColumns([]int{1, 10, 1, 30, 1}). - SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) - view. - AddFormItem(view.username, 3, 1, 1, 1). - AddFormItem(view.password, 3, 3, 1, 1). - AddFormItem(view.homeserver, 3, 5, 1, 1). - AddFormItem(view.loginButton, 1, 7, 3, 1). - AddFormItem(view.quitButton, 1, 9, 3, 1). - AddComponent(view.usernameLabel, 1, 1, 1, 1). - AddComponent(view.passwordLabel, 1, 3, 1, 1). - AddComponent(view.homeserverLabel, 1, 5, 1, 1) - view.SetOnFocusChanged(view.focusChanged) - view.FocusNextItem() - ui.loginView = view - - view.container = mauview.Center(mauview.NewBox(view).SetTitle("Log in to Matrix"), 45, 13) - view.container.SetAlwaysFocusChild(true) - return view.container -} - -func (view *LoginView) resolveWellKnown() { - _, homeserver, err := id.UserID(view.username.GetText()).Parse() - if err != nil { - return - } - view.homeserver.SetText("Resolving...") - resp, err := mautrix.DiscoverClientAPI(homeserver) - if err != nil { - view.homeserver.SetText("") - view.Error(err.Error()) - } else if resp != nil { - view.homeserver.SetText(resp.Homeserver.BaseURL) - view.parent.Render() - } -} - -func (view *LoginView) focusChanged(from, to mauview.Component) { - if from == view.username && view.homeserver.GetText() == "" { - go view.resolveWellKnown() - } -} - -func (view *LoginView) Error(err string) { - if len(err) == 0 && view.error != nil { - debug.Print("Hiding error") - view.RemoveComponent(view.error) - view.container.SetHeight(13) - view.SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1}) - view.error = nil - } else if len(err) > 0 { - debug.Print("Showing error", err) - if view.error == nil { - view.error = mauview.NewTextView().SetTextColor(tcell.ColorRed) - view.AddComponent(view.error, 1, 11, 3, 1) - } - view.error.SetText(err) - errorHeight := int(math.Ceil(float64(runewidth.StringWidth(err)) / 41)) - view.container.SetHeight(14 + errorHeight) - view.SetRow(11, errorHeight) - } - - view.parent.Render() -} - -func (view *LoginView) actuallyLogin(hs, mxid, password string) { - debug.Printf("Logging into %s as %s...", hs, mxid) - view.config.HS = hs - - if err := view.matrix.InitClient(false); err != nil { - debug.Print("Init error:", err) - view.Error(err.Error()) - } else if err = view.matrix.Login(mxid, password); err != nil { - if httpErr, ok := err.(mautrix.HTTPError); ok { - if httpErr.RespError != nil && len(httpErr.RespError.Err) > 0 { - view.Error(httpErr.RespError.Err) - } else if len(httpErr.Message) > 0 { - view.Error(httpErr.Message) - } else { - view.Error(err.Error()) - } - } else { - view.Error(err.Error()) - } - debug.Print("Login error:", err) - } - view.loading = false - view.loginButton.SetText("Login") -} - -func (view *LoginView) Login() { - if view.loading { - return - } - hs := view.homeserver.GetText() - mxid := view.username.GetText() - password := view.password.GetText() - - view.loading = true - view.loginButton.SetText("Logging in...") - go view.actuallyLogin(hs, mxid, password) -} diff --git a/ui/view-main.go b/ui/view-main.go deleted file mode 100644 index ed92e3a..0000000 --- a/ui/view-main.go +++ /dev/null @@ -1,463 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package ui - -import ( - "bufio" - "fmt" - "os" - "sync/atomic" - "time" - - sync "github.com/sasha-s/go-deadlock" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" - - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/pushrules" - - "maunium.net/go/gomuks/config" - "maunium.net/go/gomuks/debug" - ifc "maunium.net/go/gomuks/interface" - "maunium.net/go/gomuks/lib/notification" - "maunium.net/go/gomuks/matrix/rooms" - "maunium.net/go/gomuks/ui/messages" - "maunium.net/go/gomuks/ui/widget" -) - -type MainView struct { - flex *mauview.Flex - - roomList *RoomList - roomView *mauview.Box - currentRoom *RoomView - rooms map[id.RoomID]*RoomView - roomsLock sync.RWMutex - cmdProcessor *CommandProcessor - focused mauview.Focusable - - modal mauview.Component - - lastFocusTime time.Time - - matrix ifc.MatrixContainer - gmx ifc.Gomuks - config *config.Config - parent *GomuksUI -} - -func (ui *GomuksUI) NewMainView() mauview.Component { - mainView := &MainView{ - flex: mauview.NewFlex().SetDirection(mauview.FlexColumn), - roomView: mauview.NewBox(nil).SetBorder(false), - rooms: make(map[id.RoomID]*RoomView), - - matrix: ui.gmx.Matrix(), - gmx: ui.gmx, - config: ui.gmx.Config(), - parent: ui, - } - mainView.roomList = NewRoomList(mainView) - mainView.cmdProcessor = NewCommandProcessor(mainView) - - mainView.flex. - AddFixedComponent(mainView.roomList, 25). - AddFixedComponent(widget.NewBorder(), 1). - AddProportionalComponent(mainView.roomView, 1) - mainView.BumpFocus(nil) - - ui.mainView = mainView - - return mainView -} - -func (view *MainView) ShowModal(modal mauview.Component) { - view.modal = modal - var ok bool - view.focused, ok = modal.(mauview.Focusable) - if !ok { - view.focused = nil - } else { - view.focused.Focus() - } -} - -func (view *MainView) HideModal() { - view.modal = nil - view.focused = view.roomView -} - -func (view *MainView) Draw(screen mauview.Screen) { - if view.config.Preferences.HideRoomList { - view.roomView.Draw(screen) - } else { - view.flex.Draw(screen) - } - - if view.modal != nil { - view.modal.Draw(screen) - } -} - -func (view *MainView) BumpFocus(roomView *RoomView) { - if roomView != nil { - view.lastFocusTime = time.Now() - view.MarkRead(roomView) - } -} - -func (view *MainView) MarkRead(roomView *RoomView) { - if roomView != nil && roomView.Room.HasNewMessages() && roomView.MessageView().ScrollOffset == 0 { - msgList := roomView.MessageView().messages - if len(msgList) > 0 { - msg := msgList[len(msgList)-1] - if roomView.Room.MarkRead(msg.ID()) { - view.matrix.MarkRead(roomView.Room.ID, msg.ID()) - } - } - } -} - -func (view *MainView) InputChanged(roomView *RoomView, text string) { - if !roomView.config.Preferences.DisableTypingNotifs { - view.matrix.SendTyping(roomView.Room.ID, len(text) > 0 && text[0] != '/') - } -} - -func (view *MainView) ShowBare(roomView *RoomView) { - if roomView == nil { - return - } - _, height := view.parent.app.Screen().Size() - view.parent.app.Suspend(func() { - print("\033[2J\033[0;0H") - // We don't know how much space there exactly is. Too few messages looks weird, - // and too many messages shouldn't cause any problems, so we just show too many. - height *= 2 - fmt.Println(roomView.MessageView().CapturePlaintext(height)) - fmt.Println("Press enter to return to normal mode.") - reader := bufio.NewReader(os.Stdin) - _, _, _ = reader.ReadRune() - print("\033[2J\033[0;0H") - }) -} - -func (view *MainView) OpenSyncingModal() ifc.SyncingModal { - component, modal := NewSyncingModal(view) - view.ShowModal(component) - return modal -} - -func (view *MainView) OnKeyEvent(event mauview.KeyEvent) bool { - view.BumpFocus(view.currentRoom) - - if view.modal != nil { - return view.modal.OnKeyEvent(event) - } - - kb := config.Keybind{ - Key: event.Key(), - Ch: event.Rune(), - Mod: event.Modifiers(), - } - switch view.config.Keybindings.Main[kb] { - case "next_room": - view.SwitchRoom(view.roomList.Next()) - case "prev_room": - view.SwitchRoom(view.roomList.Previous()) - case "search_rooms": - view.ShowModal(NewFuzzySearchModal(view, 42, 12)) - case "scroll_up": - msgView := view.currentRoom.MessageView() - msgView.AddScrollOffset(msgView.TotalHeight()) - case "scroll_down": - msgView := view.currentRoom.MessageView() - msgView.AddScrollOffset(-msgView.TotalHeight()) - case "add_newline": - return view.flex.OnKeyEvent(tcell.NewEventKey(tcell.KeyEnter, '\n', event.Modifiers()|tcell.ModShift)) - case "next_active_room": - view.SwitchRoom(view.roomList.NextWithActivity()) - case "show_bare": - view.ShowBare(view.currentRoom) - default: - goto defaultHandler - } - return true -defaultHandler: - if view.config.Preferences.HideRoomList { - return view.roomView.OnKeyEvent(event) - } - return view.flex.OnKeyEvent(event) -} - -const WheelScrollOffsetDiff = 3 - -func (view *MainView) OnMouseEvent(event mauview.MouseEvent) bool { - if view.modal != nil { - return view.modal.OnMouseEvent(event) - } - if view.config.Preferences.HideRoomList { - return view.roomView.OnMouseEvent(event) - } - return view.flex.OnMouseEvent(event) -} - -func (view *MainView) OnPasteEvent(event mauview.PasteEvent) bool { - if view.modal != nil { - return view.modal.OnPasteEvent(event) - } else if view.config.Preferences.HideRoomList { - return view.roomView.OnPasteEvent(event) - } - return view.flex.OnPasteEvent(event) -} - -func (view *MainView) Focus() { - if view.focused != nil { - view.focused.Focus() - } -} - -func (view *MainView) Blur() { - if view.focused != nil { - view.focused.Blur() - } -} - -func (view *MainView) SwitchRoom(tag string, room *rooms.Room) { - view.switchRoom(tag, room, true) -} - -func (view *MainView) switchRoom(tag string, room *rooms.Room, lock bool) { - if room == nil { - return - } - room.Load() - - roomView, ok := view.getRoomView(room.ID, lock) - if !ok { - debug.Print("Tried to switch to room with nonexistent roomView!") - debug.Print(tag, room) - return - } - roomView.Update() - view.roomView.SetInnerComponent(roomView) - view.currentRoom = roomView - view.MarkRead(roomView) - view.roomList.SetSelected(tag, room) - view.flex.SetFocused(view.roomView) - view.focused = view.roomView - view.roomView.Focus() - view.parent.Render() - - if msgView := roomView.MessageView(); len(msgView.messages) < 20 && !msgView.initialHistoryLoaded { - msgView.initialHistoryLoaded = true - go view.LoadHistory(room.ID) - } - if !room.MembersFetched { - go func() { - err := view.matrix.FetchMembers(room) - if err != nil { - debug.Print("Error fetching members:", err) - return - } - roomView.UpdateUserList() - view.parent.Render() - }() - } -} - -func (view *MainView) addRoomPage(room *rooms.Room) *RoomView { - if _, ok := view.rooms[room.ID]; !ok { - roomView := NewRoomView(view, room). - SetInputChangedFunc(view.InputChanged) - view.rooms[room.ID] = roomView - return roomView - } - return nil -} - -func (view *MainView) GetRoom(roomID id.RoomID) ifc.RoomView { - room, ok := view.getRoomView(roomID, true) - if !ok { - return view.addRoom(view.matrix.GetOrCreateRoom(roomID)) - } - return room -} - -func (view *MainView) getRoomView(roomID id.RoomID, lock bool) (room *RoomView, ok bool) { - if lock { - view.roomsLock.RLock() - room, ok = view.rooms[roomID] - view.roomsLock.RUnlock() - } else { - room, ok = view.rooms[roomID] - } - return room, ok -} - -func (view *MainView) AddRoom(room *rooms.Room) { - view.addRoom(room) -} - -func (view *MainView) RemoveRoom(room *rooms.Room) { - view.roomsLock.Lock() - _, ok := view.getRoomView(room.ID, false) - if !ok { - view.roomsLock.Unlock() - debug.Print("Remove aborted (not found)", room.ID, room.GetTitle()) - return - } - debug.Print("Removing", room.ID, room.GetTitle()) - - view.roomList.Remove(room) - t, r := view.roomList.Selected() - view.switchRoom(t, r, false) - delete(view.rooms, room.ID) - view.roomsLock.Unlock() - - view.parent.Render() -} - -func (view *MainView) addRoom(room *rooms.Room) *RoomView { - if view.roomList.Contains(room.ID) { - debug.Print("Add aborted (room exists)", room.ID, room.GetTitle()) - return nil - } - debug.Print("Adding", room.ID, room.GetTitle()) - view.roomList.Add(room) - view.roomsLock.Lock() - roomView := view.addRoomPage(room) - if !view.roomList.HasSelected() { - t, r := view.roomList.First() - view.switchRoom(t, r, false) - } - view.roomsLock.Unlock() - return roomView -} - -func (view *MainView) SetRooms(rooms *rooms.RoomCache) { - view.roomList.Clear() - view.roomsLock.Lock() - view.rooms = make(map[id.RoomID]*RoomView) - for _, room := range rooms.Map { - if room.HasLeft { - continue - } - view.roomList.Add(room) - view.addRoomPage(room) - } - t, r := view.roomList.First() - view.switchRoom(t, r, false) - view.roomsLock.Unlock() -} - -func (view *MainView) UpdateTags(room *rooms.Room) { - if !view.roomList.Contains(room.ID) { - return - } - reselect := view.roomList.selected == room - view.roomList.Remove(room) - view.roomList.Add(room) - if reselect { - view.roomList.SetSelected(room.Tags()[0].Tag, room) - } - view.parent.Render() -} - -func (view *MainView) SetTyping(roomID id.RoomID, users []id.UserID) { - roomView, ok := view.getRoomView(roomID, true) - if ok { - roomView.SetTyping(users) - view.parent.Render() - } -} - -func sendNotification(room *rooms.Room, sender, text string, critical, sound bool) { - if room.GetTitle() != sender { - sender = fmt.Sprintf("%s (%s)", sender, room.GetTitle()) - } - debug.Printf("Sending notification with body \"%s\" from %s in room ID %s (critical=%v, sound=%v)", text, sender, room.ID, critical, sound) - notification.Send(sender, text, critical, sound) -} - -func (view *MainView) Bump(room *rooms.Room) { - view.roomList.Bump(room) -} - -func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) { - view.Bump(room) - uiMsg, ok := message.(*messages.UIMessage) - if ok && uiMsg.SenderID == view.config.UserID { - return - } - // Whether or not the room where the message came is the currently shown room. - isCurrent := room == view.roomList.SelectedRoom() - // Whether or not the terminal window is focused. - recentlyFocused := time.Now().Add(-30 * time.Second).Before(view.lastFocusTime) - isFocused := time.Now().Add(-5 * time.Second).Before(view.lastFocusTime) - - if !isCurrent || !isFocused { - // The message is not in the current room, show new message status in room list. - room.AddUnread(message.ID(), should.Notify, should.Highlight) - } else { - view.matrix.MarkRead(room.ID, message.ID()) - } - - if should.Notify && !recentlyFocused && !view.config.Preferences.DisableNotifications { - // Push rules say notify and the terminal is not focused, send desktop notification. - shouldPlaySound := should.PlaySound && - should.SoundName == "default" && - view.config.NotifySound - sendNotification(room, message.NotificationSenderName(), message.NotificationContent(), should.Highlight, shouldPlaySound) - } - - // TODO this should probably happen somewhere else - // (actually it's probably completely broken now) - message.SetIsHighlight(should.Highlight) -} - -func (view *MainView) LoadHistory(roomID id.RoomID) { - defer debug.Recover() - roomView, ok := view.getRoomView(roomID, true) - if !ok { - return - } - msgView := roomView.MessageView() - - if !atomic.CompareAndSwapInt32(&msgView.loadingMessages, 0, 1) { - // Locked - return - } - defer atomic.StoreInt32(&msgView.loadingMessages, 0) - // Update the "Loading more messages..." text - view.parent.Render() - - history, newLoadPtr, err := view.matrix.GetHistory(roomView.Room, 50, msgView.historyLoadPtr) - if err != nil { - roomView.AddServiceMessage("Failed to fetch history") - debug.Print("Failed to fetch history for", roomView.Room.ID, err) - view.parent.Render() - return - } - //debug.Printf("Load pointer %d -> %d", msgView.historyLoadPtr, newLoadPtr) - msgView.historyLoadPtr = newLoadPtr - for _, evt := range history { - roomView.AddHistoryEvent(evt) - } - view.parent.Render() -} diff --git a/ui/widget/border.go b/ui/widget/border.go deleted file mode 100644 index eab22dd..0000000 --- a/ui/widget/border.go +++ /dev/null @@ -1,63 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package widget - -import ( - "go.mau.fi/mauview" - "go.mau.fi/tcell" -) - -// Border is a simple tview widget that renders a horizontal or vertical bar. -// -// If the width of the box is 1, the bar will be vertical. -// If the height is 1, the bar will be horizontal. -// If the width nor the height are 1, nothing will be rendered. -type Border struct { - Style tcell.Style -} - -// NewBorder wraps a new tview Box into a new Border. -func NewBorder() *Border { - return &Border{ - Style: tcell.StyleDefault.Foreground(mauview.Styles.BorderColor), - } -} - -func (border *Border) Draw(screen mauview.Screen) { - width, height := screen.Size() - if width == 1 { - for borderY := 0; borderY < height; borderY++ { - screen.SetContent(0, borderY, mauview.Borders.Vertical, nil, border.Style) - } - } else if height == 1 { - for borderX := 0; borderX < width; borderX++ { - screen.SetContent(borderX, 0, mauview.Borders.Horizontal, nil, border.Style) - } - } -} - -func (border *Border) OnKeyEvent(event mauview.KeyEvent) bool { - return false -} - -func (border *Border) OnPasteEvent(event mauview.PasteEvent) bool { - return false -} - -func (border *Border) OnMouseEvent(event mauview.MouseEvent) bool { - return false -} diff --git a/ui/widget/color.go b/ui/widget/color.go deleted file mode 100644 index 398e43f..0000000 --- a/ui/widget/color.go +++ /dev/null @@ -1,224 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package widget - -import ( - "fmt" - "hash/fnv" - - "go.mau.fi/tcell" - - "maunium.net/go/mautrix/id" -) - -var colorNames = []string{ - "maroon", - "green", - "olive", - "navy", - "purple", - "teal", - "silver", - "gray", - "red", - "lime", - "yellow", - "blue", - "fuchsia", - "aqua", - "white", - "aliceblue", - "antiquewhite", - "aquamarine", - "azure", - "beige", - "bisque", - "blanchedalmond", - "blueviolet", - "brown", - "burlywood", - "cadetblue", - "chartreuse", - "chocolate", - "coral", - "cornflowerblue", - "cornsilk", - "crimson", - "darkblue", - "darkcyan", - "darkgoldenrod", - "darkgray", - "darkgreen", - "darkkhaki", - "darkmagenta", - "darkolivegreen", - "darkorange", - "darkorchid", - "darkred", - "darksalmon", - "darkseagreen", - "darkslateblue", - "darkslategray", - "darkturquoise", - "darkviolet", - "deeppink", - "deepskyblue", - "dimgray", - "dodgerblue", - "firebrick", - "floralwhite", - "forestgreen", - "gainsboro", - "ghostwhite", - "gold", - "goldenrod", - "greenyellow", - "honeydew", - "hotpink", - "indianred", - "indigo", - "ivory", - "khaki", - "lavender", - "lavenderblush", - "lawngreen", - "lemonchiffon", - "lightblue", - "lightcoral", - "lightcyan", - "lightgoldenrodyellow", - "lightgray", - "lightgreen", - "lightpink", - "lightsalmon", - "lightseagreen", - "lightskyblue", - "lightslategray", - "lightsteelblue", - "lightyellow", - "limegreen", - "linen", - "mediumaquamarine", - "mediumblue", - "mediumorchid", - "mediumpurple", - "mediumseagreen", - "mediumslateblue", - "mediumspringgreen", - "mediumturquoise", - "mediumvioletred", - "midnightblue", - "mintcream", - "mistyrose", - "moccasin", - "navajowhite", - "oldlace", - "olivedrab", - "orange", - "orangered", - "orchid", - "palegoldenrod", - "palegreen", - "paleturquoise", - "palevioletred", - "papayawhip", - "peachpuff", - "peru", - "pink", - "plum", - "powderblue", - "rebeccapurple", - "rosybrown", - "royalblue", - "saddlebrown", - "salmon", - "sandybrown", - "seagreen", - "seashell", - "sienna", - "skyblue", - "slateblue", - "slategray", - "snow", - "springgreen", - "steelblue", - "tan", - "thistle", - "tomato", - "turquoise", - "violet", - "wheat", - "whitesmoke", - "yellowgreen", - "grey", - "dimgrey", - "darkgrey", - "darkslategrey", - "lightgrey", - "lightslategrey", - "slategrey", -} - -// GetHashColorName gets a color name for the given string based on its FNV-1 hash. -// -// The array of possible color names are the alphabetically ordered color -// names specified in tcell.ColorNames. -// -// The algorithm to get the color is as follows: -// -// colorNames[ FNV1(string) % len(colorNames) ] -// -// With the exception of the three special cases: -// -// --> = green -// <-- = red -// --- = yellow -func GetHashColorName(s string) string { - switch s { - case "-->": - return "green" - case "<--": - return "red" - case "---": - return "yellow" - default: - h := fnv.New32a() - _, _ = h.Write([]byte(s)) - return colorNames[h.Sum32()%uint32(len(colorNames))] - } -} - -// GetHashColor gets the tcell Color value for the given string. -// -// GetHashColor calls GetHashColorName() and gets the Color value from the tcell.ColorNames map. -func GetHashColor(val interface{}) tcell.Color { - switch str := val.(type) { - case string: - return tcell.ColorNames[GetHashColorName(str)] - case *string: - return tcell.ColorNames[GetHashColorName(*str)] - case id.UserID: - return tcell.ColorNames[GetHashColorName(string(str))] - default: - return tcell.ColorNames["red"] - } -} - -// AddColor adds tview color tags to the given string. -func AddColor(s, color string) string { - return fmt.Sprintf("[%s]%s[white]", color, s) -} diff --git a/ui/widget/doc.go b/ui/widget/doc.go deleted file mode 100644 index 03d2060..0000000 --- a/ui/widget/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package widget contains additional tview widgets. -package widget diff --git a/ui/widget/util.go b/ui/widget/util.go deleted file mode 100644 index e26a23d..0000000 --- a/ui/widget/util.go +++ /dev/null @@ -1,73 +0,0 @@ -// gomuks - A terminal Matrix client written in Go. -// Copyright (C) 2020 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package widget - -import ( - "fmt" - "strconv" - - "github.com/mattn/go-runewidth" - - "go.mau.fi/mauview" - "go.mau.fi/tcell" -) - -func WriteLineSimple(screen mauview.Screen, line string, x, y int) { - WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault) -} - -func WriteLineSimpleColor(screen mauview.Screen, line string, x, y int, color tcell.Color) { - WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color)) -} - -func WriteLineColor(screen mauview.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) { - WriteLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color)) -} - -func WriteLine(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) { - offsetX := 0 - if align == mauview.AlignRight { - offsetX = maxWidth - runewidth.StringWidth(line) - } - if offsetX < 0 { - offsetX = 0 - } - for _, ch := range line { - chWidth := runewidth.RuneWidth(ch) - if chWidth == 0 { - continue - } - - for localOffset := 0; localOffset < chWidth; localOffset++ { - screen.SetContent(x+offsetX+localOffset, y, ch, nil, style) - } - offsetX += chWidth - if offsetX >= maxWidth { - break - } - } -} - -func WriteLinePadded(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) { - padding := strconv.Itoa(maxWidth) - if align == mauview.AlignRight { - line = fmt.Sprintf("%"+padding+"s", line) - } else { - line = fmt.Sprintf("%-"+padding+"s", line) - } - WriteLine(screen, mauview.AlignLeft, line, x, y, maxWidth, style) -}