mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 02:03:40 -05:00
all: delete old code
This commit is contained in:
parent
e6a2c3ff85
commit
4767def4b5
94 changed files with 37 additions and 16227 deletions
45
.github/workflows/go.yml
vendored
45
.github/workflows/go.yml
vendored
|
@ -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 ./...
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -3,15 +3,9 @@
|
|||
[](LICENSE)
|
||||
[](https://github.com/tulir/gomuks/releases)
|
||||
[](https://mau.dev/tulir/gomuks/pipelines)
|
||||
[](https://codeclimate.com/github/tulir/gomuks)
|
||||
[](https://repology.org/project/gomuks/versions)
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
|
2
build.sh
2
build.sh
|
@ -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'`'" "$@"
|
BIN
chat-preview.png
BIN
chat-preview.png
Binary file not shown.
Before Width: | Height: | Size: 163 KiB |
401
config/config.go
401
config/config.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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")
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
// Package config contains the wrappers for gomuks configurations and sessions.
|
||||
package config
|
|
@ -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
|
|
@ -1,7 +0,0 @@
|
|||
Package: gomuks
|
||||
Version: 0.3.1-1
|
||||
Section: net
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Maintainer: Tulir Asokan <tulir@maunium.net>
|
||||
Description: A terminal based Matrix client written in Go.
|
184
debug/debug.go
184
debug/debug.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
// Package debug contains utilities to log debug messages and display panics nicely.
|
||||
package debug
|
52
go.mod
52
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
|
||||
|
|
129
go.sum
129
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=
|
192
gomuks.go
192
gomuks.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
// Package ifc contains interfaces to allow circular function calls without circular imports.
|
||||
package ifc
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
|
@ -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.
|
|
@ -1,297 +0,0 @@
|
|||
// ___ _____ ____
|
||||
// / _ \/ _/ |/_/ /____ ______ _
|
||||
// / ___// /_> </ __/ -_) __/ ' \
|
||||
// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
|
||||
//
|
||||
// Copyright 2017 Eliuk Blau
|
||||
//
|
||||
// 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/.
|
||||
|
||||
package ansimage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
_ "image/gif" // initialize decoder
|
||||
_ "image/jpeg" // initialize decoder
|
||||
_ "image/png" // initialize decoder
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
_ "golang.org/x/image/bmp" // initialize decoder
|
||||
_ "golang.org/x/image/tiff" // initialize decoder
|
||||
_ "golang.org/x/image/webp" // initialize decoder
|
||||
|
||||
"go.mau.fi/tcell"
|
||||
|
||||
"maunium.net/go/gomuks/debug"
|
||||
"maunium.net/go/gomuks/ui/messages/tstring"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrHeightNonMoT happens when ANSImage height is not a Multiple of Two value.
|
||||
ErrHeightNonMoT = errors.New("ANSImage: height must be a Multiple of Two value")
|
||||
|
||||
// ErrInvalidBoundsMoT happens when ANSImage height or width are invalid values (Multiple of Two).
|
||||
ErrInvalidBoundsMoT = errors.New("ANSImage: height or width must be >=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
|
||||
}
|
|
@ -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.
|
||||
//
|
||||
// ___ _____ ____
|
||||
// / _ \/ _/ |/_/ /____ ______ _
|
||||
// / ___// /_> </ __/ -_) __/ ' \
|
||||
// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
|
||||
//
|
||||
// This package is licensed under the Mozilla Public License v2.0.
|
||||
package ansimage
|
|
@ -1,49 +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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
// Package notification contains a simple cross-platform desktop notification sending function.
|
||||
package notification
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
}
|
|
@ -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
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package open
|
||||
|
||||
const Command = "open"
|
||||
|
||||
var Args []string
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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}
|
|
@ -1,7 +0,0 @@
|
|||
//go:build !windows && !darwin
|
||||
|
||||
package open
|
||||
|
||||
const Command = "xdg-open"
|
||||
|
||||
var Args []string
|
|
@ -1,2 +0,0 @@
|
|||
// Package util contains miscellaneous utilities
|
||||
package util
|
|
@ -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
|
||||
}
|
203
main.go
203
main.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
100
matrix/crypto.go
100
matrix/crypto.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
//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)
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
// Package matrix contains wrappers for mautrix for use by the UI of gomuks.
|
||||
package matrix
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
1418
matrix/matrix.go
1418
matrix/matrix.go
File diff suppressed because it is too large
Load diff
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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{})
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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() {}
|
|
@ -1,2 +0,0 @@
|
|||
// Package rooms contains a representation for Matrix rooms and utilities to parse state events.
|
||||
package rooms
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
267
matrix/sync.go
267
matrix/sync.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
// 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("*")},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>gomuks user-interactive auth</title>
|
||||
<meta charset="utf-8"/>
|
||||
<style>
|
||||
body {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Please complete the login in the popup window</h2>
|
||||
<p>Keep this page open while logging in, it will close automatically after the login finishes.</p>
|
||||
<button onclick="openPopup()">Open popup</button>
|
||||
<button onclick="finish(false)">Cancel</button>
|
||||
<script>
|
||||
const url = location.hash.substr(1)
|
||||
let popupWindow
|
||||
|
||||
function finish(success) {
|
||||
if (popupWindow) {
|
||||
popupWindow.close()
|
||||
}
|
||||
fetch("", {method: success ? "POST" : "DELETE"}).then(() => window.close())
|
||||
}
|
||||
|
||||
function openPopup() {
|
||||
popupWindow = window.open(url)
|
||||
}
|
||||
|
||||
window.addEventListener("message", evt => evt.data === "authDone" && finish(true))
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
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
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
1046
ui/commands.go
1046
ui/commands.go
File diff suppressed because it is too large
Load diff
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
//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 <user id> <device id> [fingerprint]", cmd.Command)
|
||||
return nil
|
||||
}
|
||||
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||
device, err := mach.GetOrFetchDevice(id.UserID(cmd.Args[0]), id.DeviceID(cmd.Args[1]))
|
||||
if err != nil {
|
||||
cmd.Reply("Failed to get device: %v", err)
|
||||
return nil
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
func putDevice(cmd *Command, device *crypto.DeviceIdentity, action string) {
|
||||
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||
err := mach.CryptoStore.PutDevice(device.UserID, device)
|
||||
if err != nil {
|
||||
cmd.Reply("Failed to save device: %v", err)
|
||||
} else {
|
||||
cmd.Reply("Successfully %s %s/%s (%s)", action, device.UserID, device.DeviceID, device.Name)
|
||||
}
|
||||
mach.OnDevicesChanged(device.UserID)
|
||||
}
|
||||
|
||||
func cmdDevices(cmd *Command) {
|
||||
if len(cmd.Args) == 0 {
|
||||
cmd.Reply("Usage: /devices <user id>")
|
||||
return
|
||||
}
|
||||
userID := id.UserID(cmd.Args[0])
|
||||
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||
devices, err := mach.CryptoStore.GetDevices(userID)
|
||||
if err != nil {
|
||||
cmd.Reply("Failed to get device list: %v", err)
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
cmd.Reply("Fetching device list from server...")
|
||||
devices = mach.LoadDevices(userID)
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
cmd.Reply("No devices found for %s", userID)
|
||||
return
|
||||
}
|
||||
var buf strings.Builder
|
||||
for _, device := range devices {
|
||||
trust := device.Trust.String()
|
||||
if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) {
|
||||
trust = "verified (transitive)"
|
||||
}
|
||||
_, _ = fmt.Fprintf(&buf, "%s (%s) - %s\n Fingerprint: %s\n", device.DeviceID, device.Name, trust, device.Fingerprint())
|
||||
}
|
||||
resp := buf.String()
|
||||
cmd.Reply("%s", resp[:len(resp)-1])
|
||||
}
|
||||
|
||||
func cmdDevice(cmd *Command) {
|
||||
device := getDevice(cmd)
|
||||
if device == nil {
|
||||
return
|
||||
}
|
||||
deviceType := "Device"
|
||||
if device.Deleted {
|
||||
deviceType = "Deleted device"
|
||||
}
|
||||
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||
trustState := device.Trust.String()
|
||||
if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) {
|
||||
trustState = "verified (transitive)"
|
||||
}
|
||||
cmd.Reply("%s %s of %s\nFingerprint: %s\nIdentity key: %s\nDevice name: %s\nTrust state: %s",
|
||||
deviceType, device.DeviceID, device.UserID,
|
||||
device.Fingerprint(), device.IdentityKey,
|
||||
device.Name, trustState)
|
||||
}
|
||||
|
||||
func crossSignDevice(cmd *Command, device *crypto.DeviceIdentity) {
|
||||
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||
err := mach.SignOwnDevice(device)
|
||||
if err != nil {
|
||||
cmd.Reply("Failed to upload cross-signing signature: %v", err)
|
||||
} else {
|
||||
cmd.Reply("Successfully cross-signed %s (%s)", device.DeviceID, device.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdVerifyDevice(cmd *Command) {
|
||||
device := getDevice(cmd)
|
||||
if device == nil {
|
||||
return
|
||||
}
|
||||
if device.Trust == crypto.TrustStateVerified {
|
||||
cmd.Reply("That device is already verified")
|
||||
return
|
||||
}
|
||||
if len(cmd.Args) == 2 {
|
||||
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||
mach.DefaultSASTimeout = 120 * time.Second
|
||||
modal := NewVerificationModal(cmd.MainView, device, mach.DefaultSASTimeout)
|
||||
cmd.MainView.ShowModal(modal)
|
||||
_, err := mach.NewSimpleSASVerificationWith(device, modal)
|
||||
if err != nil {
|
||||
cmd.Reply("Failed to start interactive verification: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fingerprint := strings.Join(cmd.Args[2:], "")
|
||||
if string(device.SigningKey) != fingerprint {
|
||||
cmd.Reply("Mismatching fingerprint")
|
||||
return
|
||||
}
|
||||
action := "verified"
|
||||
if device.Trust == crypto.TrustStateBlacklisted {
|
||||
action = "unblacklisted and verified"
|
||||
}
|
||||
if device.UserID == cmd.Matrix.Client().UserID {
|
||||
crossSignDevice(cmd, device)
|
||||
device.Trust = crypto.TrustStateVerified
|
||||
putDevice(cmd, device, action)
|
||||
} else {
|
||||
putDevice(cmd, device, action)
|
||||
cmd.Reply("Warning: verifying individual devices of other users is not synced with cross-signing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cmdVerify(cmd *Command) {
|
||||
if len(cmd.Args) < 1 {
|
||||
cmd.Reply("Usage: /%s <user ID> [--force]", cmd.OrigCommand)
|
||||
return
|
||||
}
|
||||
force := len(cmd.Args) >= 2 && strings.ToLower(cmd.Args[1]) == "--force"
|
||||
userID := id.UserID(cmd.Args[0])
|
||||
room := cmd.Room.Room
|
||||
if !room.Encrypted {
|
||||
cmd.Reply("In-room verification is only supported in encrypted rooms")
|
||||
return
|
||||
}
|
||||
if (!room.IsDirect || room.OtherUser != userID) && !force {
|
||||
cmd.Reply("This doesn't seem to be a direct chat. Either switch to a direct chat with %s, "+
|
||||
"or use `--force` to start the verification anyway.", userID)
|
||||
return
|
||||
}
|
||||
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||
if mach.CrossSigningKeys == nil && !force {
|
||||
cmd.Reply("Cross-signing private keys not cached. Generate or fetch cross-signing keys with `/cross-signing`, " +
|
||||
"or use `--force` to start the verification anyway")
|
||||
return
|
||||
}
|
||||
modal := NewVerificationModal(cmd.MainView, &crypto.DeviceIdentity{UserID: userID}, mach.DefaultSASTimeout)
|
||||
_, err := mach.NewInRoomSASVerificationWith(cmd.Room.Room.ID, userID, modal, 120*time.Second)
|
||||
if err != nil {
|
||||
cmd.Reply("Failed to start in-room verification: %v", err)
|
||||
return
|
||||
}
|
||||
cmd.MainView.ShowModal(modal)
|
||||
}
|
||||
|
||||
func cmdUnverify(cmd *Command) {
|
||||
device := getDevice(cmd)
|
||||
if device == nil {
|
||||
return
|
||||
}
|
||||
if device.Trust == crypto.TrustStateUnset {
|
||||
cmd.Reply("That device is already not verified")
|
||||
return
|
||||
}
|
||||
action := "unverified"
|
||||
if device.Trust == crypto.TrustStateBlacklisted {
|
||||
action = "unblacklisted"
|
||||
}
|
||||
device.Trust = crypto.TrustStateUnset
|
||||
putDevice(cmd, device, action)
|
||||
}
|
||||
|
||||
func cmdBlacklist(cmd *Command) {
|
||||
device := getDevice(cmd)
|
||||
if device == nil {
|
||||
return
|
||||
}
|
||||
if device.Trust == crypto.TrustStateBlacklisted {
|
||||
cmd.Reply("That device is already blacklisted")
|
||||
return
|
||||
}
|
||||
action := "blacklisted"
|
||||
if device.Trust == crypto.TrustStateVerified {
|
||||
action = "unverified and blacklisted"
|
||||
}
|
||||
device.Trust = crypto.TrustStateBlacklisted
|
||||
putDevice(cmd, device, action)
|
||||
}
|
||||
|
||||
func cmdResetSession(cmd *Command) {
|
||||
err := cmd.Matrix.Crypto().(*crypto.OlmMachine).CryptoStore.RemoveOutboundGroupSession(cmd.Room.Room.ID)
|
||||
if err != nil {
|
||||
cmd.Reply("Failed to remove outbound group session: %v", err)
|
||||
} else {
|
||||
cmd.Reply("Removed outbound group session for this room")
|
||||
}
|
||||
}
|
||||
|
||||
func cmdImportKeys(cmd *Command) {
|
||||
path, err := filepath.Abs(cmd.RawArgs)
|
||||
if err != nil {
|
||||
cmd.Reply("Failed to get absolute path: %v", err)
|
||||
return
|
||||
}
|
||||
data, err := 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 <subcommand> [...]
|
||||
|
||||
Subcommands:
|
||||
* status [key ID] - Check the status of your SSSS.
|
||||
* generate [--set-default] - Generate a SSSS key and optionally set it as the default.
|
||||
* set-default <key ID> - Set a SSSS key as the default.`
|
||||
|
||||
func cmdSSSS(cmd *Command) {
|
||||
if len(cmd.Args) == 0 {
|
||||
cmd.Reply(ssssHelp, cmd.OrigCommand)
|
||||
return
|
||||
}
|
||||
|
||||
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||
|
||||
switch strings.ToLower(cmd.Args[0]) {
|
||||
case "status":
|
||||
keyID := ""
|
||||
if len(cmd.Args) > 1 {
|
||||
keyID = cmd.Args[1]
|
||||
}
|
||||
cmdS4Status(cmd, mach, keyID)
|
||||
case "generate":
|
||||
setDefault := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--set-default"
|
||||
cmdS4Generate(cmd, mach, setDefault)
|
||||
case "set-default":
|
||||
if len(cmd.Args) < 2 {
|
||||
cmd.Reply("Usage: /%s set-default <key ID>", cmd.OrigCommand)
|
||||
return
|
||||
}
|
||||
cmdS4SetDefault(cmd, mach, cmd.Args[1])
|
||||
default:
|
||||
cmd.Reply(ssssHelp, cmd.OrigCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdS4Status(cmd *Command, mach *crypto.OlmMachine, keyID string) {
|
||||
var keyData *ssss.KeyMetadata
|
||||
var err error
|
||||
if len(keyID) == 0 {
|
||||
keyID, keyData, err = mach.SSSS.GetDefaultKeyData()
|
||||
} 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 <subcommand> [...]
|
||||
|
||||
Subcommands:
|
||||
* status
|
||||
Check the status of your own cross-signing keys.
|
||||
* generate [--force]
|
||||
Generate and upload new cross-signing keys.
|
||||
This will prompt you to enter your account password.
|
||||
If you already have existing keys, --force is required.
|
||||
* self-sign
|
||||
Sign the current device with cached cross-signing keys.
|
||||
* fetch [--save-to-disk]
|
||||
Fetch your cross-signing keys from SSSS and decrypt them.
|
||||
If --save-to-disk is specified, the keys are saved to disk.
|
||||
* upload
|
||||
Upload your cross-signing keys to SSSS.`
|
||||
|
||||
func cmdCrossSigning(cmd *Command) {
|
||||
if len(cmd.Args) == 0 {
|
||||
cmd.Reply(crossSigningHelp, cmd.OrigCommand)
|
||||
return
|
||||
}
|
||||
|
||||
client := cmd.Matrix.Client()
|
||||
mach := cmd.Matrix.Crypto().(*crypto.OlmMachine)
|
||||
|
||||
switch strings.ToLower(cmd.Args[0]) {
|
||||
case "status":
|
||||
cmdCrossSigningStatus(cmd, mach)
|
||||
case "generate":
|
||||
force := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--force"
|
||||
cmdCrossSigningGenerate(cmd, cmd.Matrix, mach, client, force)
|
||||
case "fetch":
|
||||
saveToDisk := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--save-to-disk"
|
||||
cmdCrossSigningFetch(cmd, mach, saveToDisk)
|
||||
case "upload":
|
||||
cmdCrossSigningUpload(cmd, mach)
|
||||
case "self-sign":
|
||||
cmdCrossSigningSelfSign(cmd, mach)
|
||||
default:
|
||||
cmd.Reply(crossSigningHelp, cmd.OrigCommand)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCrossSigningStatus(cmd *Command, mach *crypto.OlmMachine) {
|
||||
keys := mach.GetOwnCrossSigningPublicKeys()
|
||||
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")
|
||||
}
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
// Package ui contains the main gomuks UI.
|
||||
package ui
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
117
ui/help-modal.go
117
ui/help-modal.go
|
@ -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 <thing> - Temporary command to toggle various UI features.
|
||||
Run /toggle without arguments to see the list of toggles.
|
||||
|
||||
# Media
|
||||
/download [path] - Downloads file from selected message.
|
||||
/open [path] - Download file from selected message and open it with xdg-open.
|
||||
/upload <path> - Upload the file at the given path to the current room.
|
||||
|
||||
# Sending special messages
|
||||
/me <message> - Send an emote message.
|
||||
/notice <message> - Send a notice (generally used for bot messages).
|
||||
/rainbow <message> - Send rainbow text.
|
||||
/rainbowme <message> - Send rainbow text in an emote.
|
||||
/reply [text] - Reply to the selected message.
|
||||
/react <reaction> - React to the selected message.
|
||||
/redact [reason] - Redact the selected message.
|
||||
/edit - Edit the selected message.
|
||||
|
||||
# Encryption
|
||||
/fingerprint - View the fingerprint of your device.
|
||||
|
||||
/devices <user id> - View the device list of a user.
|
||||
/device <user id> <device id> - Show info about a specific device.
|
||||
/unverify <user id> <device id> - Un-verify a device.
|
||||
/blacklist <user id> <device id> - Blacklist a device.
|
||||
/verify <user id> - Verify a user with in-room verification. Probably broken.
|
||||
/verify-device <user id> <device id> [fingerprint]
|
||||
- Verify a device. If the fingerprint is not provided,
|
||||
interactive emoji verification will be started.
|
||||
/reset-session - Reset the outbound Megolm session in the current room.
|
||||
|
||||
/import <file> - Import encryption keys
|
||||
/export <file> - Export encryption keys
|
||||
/export-room <file> - Export encryption keys for the current room.
|
||||
|
||||
/cross-signing <subcommand> [...]
|
||||
- Cross-signing commands. Somewhat experimental.
|
||||
Run without arguments for help. (alias: /cs)
|
||||
/ssss <subcommand> [...]
|
||||
- Secure Secret Storage (and Sharing) commands. Very experimental.
|
||||
Run without arguments for help.
|
||||
|
||||
# Rooms
|
||||
/pm <user id> <...> - Create a private chat with the given user(s).
|
||||
/create [room name] - Create a room.
|
||||
|
||||
/join <room> [server] - Join a room.
|
||||
/accept - Accept the invite.
|
||||
/reject - Reject the invite.
|
||||
|
||||
/invite <user id> - Invite the given user to the room.
|
||||
/roomnick <name> - Change your per-room displayname.
|
||||
/tag <tag> <priority> - Add the room to <tag>.
|
||||
/untag <tag> - Remove the room from <tag>.
|
||||
/tags - List the tags the room is in.
|
||||
/alias <act> <name> - Add or remove local addresses.
|
||||
|
||||
/leave - Leave the current room.
|
||||
/kick <user id> [reason] - Kick a user.
|
||||
/ban <user id> [reason] - Ban a user.
|
||||
/unban <user id> - Unban a user.`
|
||||
|
||||
type HelpModal struct {
|
||||
mauview.FocusableComponent
|
||||
parent *MainView
|
||||
}
|
||||
|
||||
func NewHelpModal(parent *MainView) *HelpModal {
|
||||
hm := &HelpModal{parent: parent}
|
||||
|
||||
text := mauview.NewTextView().
|
||||
SetText(helpText).
|
||||
SetScrollable(true).
|
||||
SetWrap(false).
|
||||
SetTextColor(tcell.ColorDefault)
|
||||
|
||||
box := mauview.NewBox(text).
|
||||
SetBorder(true).
|
||||
SetTitle("Help").
|
||||
SetBlurCaptureFunc(func() bool {
|
||||
hm.parent.HideModal()
|
||||
return true
|
||||
})
|
||||
box.Focus()
|
||||
|
||||
hm.FocusableComponent = mauview.FractionalCenter(box, 42, 10, 0.5, 0.5)
|
||||
|
||||
return hm
|
||||
}
|
||||
|
||||
func (hm *HelpModal) OnKeyEvent(event mauview.KeyEvent) bool {
|
||||
kb := config.Keybind{
|
||||
Key: event.Key(),
|
||||
Ch: event.Rune(),
|
||||
Mod: event.Modifiers(),
|
||||
}
|
||||
// TODO unhardcode q
|
||||
if hm.parent.config.Keybindings.Modal[kb] == "cancel" || event.Rune() == 'q' {
|
||||
hm.parent.HideModal()
|
||||
return true
|
||||
}
|
||||
return hm.FocusableComponent.OnKeyEvent(event)
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
// Package messages contains different message types and code to generate and render them.
|
||||
package messages
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package messages
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"maunium.net/go/mautrix/crypto/attachment"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"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)
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
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")
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
type BlockquoteEntity struct {
|
||||
*ContainerEntity
|
||||
}
|
||||
|
||||
const BlockQuoteChar = '>'
|
||||
|
||||
func NewBlockquoteEntity(children []Entity) *BlockquoteEntity {
|
||||
return &BlockquoteEntity{&ContainerEntity{
|
||||
BaseEntity: &BaseEntity{
|
||||
Tag: "blockquote",
|
||||
Block: true,
|
||||
},
|
||||
Children: children,
|
||||
Indent: 2,
|
||||
}}
|
||||
}
|
||||
|
||||
func (be *BlockquoteEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||
be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
|
||||
return be
|
||||
}
|
||||
|
||||
func (be *BlockquoteEntity) Clone() Entity {
|
||||
return &BlockquoteEntity{ContainerEntity: be.ContainerEntity.Clone().(*ContainerEntity)}
|
||||
}
|
||||
|
||||
func (be *BlockquoteEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||
be.ContainerEntity.Draw(screen, ctx)
|
||||
for y := 0; y < be.height; y++ {
|
||||
screen.SetContent(0, y, BlockQuoteChar, nil, be.Style)
|
||||
}
|
||||
}
|
||||
|
||||
func (be *BlockquoteEntity) PlainText() string {
|
||||
if len(be.Children) == 0 {
|
||||
return ""
|
||||
}
|
||||
var buf strings.Builder
|
||||
newlined := false
|
||||
for i, child := range be.Children {
|
||||
if i != 0 && child.IsBlock() && !newlined {
|
||||
buf.WriteRune('\n')
|
||||
}
|
||||
newlined = false
|
||||
for i, row := range strings.Split(child.PlainText(), "\n") {
|
||||
if i != 0 {
|
||||
buf.WriteRune('\n')
|
||||
}
|
||||
buf.WriteRune('>')
|
||||
buf.WriteRune(' ')
|
||||
buf.WriteString(row)
|
||||
}
|
||||
if child.IsBlock() {
|
||||
buf.WriteRune('\n')
|
||||
newlined = true
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
|
||||
func (be *BlockquoteEntity) String() string {
|
||||
return fmt.Sprintf("&html.BlockquoteEntity{%s},\n", be.BaseEntity)
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
type BreakEntity struct {
|
||||
*BaseEntity
|
||||
}
|
||||
|
||||
func NewBreakEntity() *BreakEntity {
|
||||
return &BreakEntity{&BaseEntity{
|
||||
Tag: "br",
|
||||
Block: true,
|
||||
}}
|
||||
}
|
||||
|
||||
// AdjustStyle changes the style of this text entity.
|
||||
func (be *BreakEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||
be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
|
||||
return be
|
||||
}
|
||||
|
||||
func (be *BreakEntity) Clone() Entity {
|
||||
return NewBreakEntity()
|
||||
}
|
||||
|
||||
func (be *BreakEntity) PlainText() string {
|
||||
return "\n"
|
||||
}
|
||||
|
||||
func (be *BreakEntity) String() string {
|
||||
return "&html.BreakEntity{},\n"
|
||||
}
|
||||
|
||||
func (be *BreakEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||
// No-op, the logic happens in containers
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
type ContainerEntity struct {
|
||||
*BaseEntity
|
||||
|
||||
// The children of this container entity.
|
||||
Children []Entity
|
||||
// Number of cells to indent children.
|
||||
Indent int
|
||||
}
|
||||
|
||||
func (ce *ContainerEntity) IsEmpty() bool {
|
||||
return len(ce.Children) == 0
|
||||
}
|
||||
|
||||
// PlainText returns the plaintext content in this entity and all its children.
|
||||
func (ce *ContainerEntity) PlainText() string {
|
||||
if len(ce.Children) == 0 {
|
||||
return ""
|
||||
}
|
||||
var buf strings.Builder
|
||||
newlined := false
|
||||
for _, child := range ce.Children {
|
||||
text := child.PlainText()
|
||||
if !strings.HasPrefix(text, "\n") && child.IsBlock() && !newlined {
|
||||
buf.WriteRune('\n')
|
||||
}
|
||||
newlined = false
|
||||
buf.WriteString(text)
|
||||
if child.IsBlock() {
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
buf.WriteRune('\n')
|
||||
}
|
||||
newlined = true
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
|
||||
// AdjustStyle recursively changes the style of this entity and all its children.
|
||||
func (ce *ContainerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||
for _, child := range ce.Children {
|
||||
child.AdjustStyle(fn, reason)
|
||||
}
|
||||
ce.Style = fn(ce.Style)
|
||||
return ce
|
||||
}
|
||||
|
||||
// Clone creates a deep copy of this base entity.
|
||||
func (ce *ContainerEntity) Clone() Entity {
|
||||
children := make([]Entity, len(ce.Children))
|
||||
for i, child := range ce.Children {
|
||||
children[i] = child.Clone()
|
||||
}
|
||||
return &ContainerEntity{
|
||||
BaseEntity: ce.BaseEntity.Clone().(*BaseEntity),
|
||||
Children: children,
|
||||
Indent: ce.Indent,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns a textual representation of this BaseEntity struct.
|
||||
func (ce *ContainerEntity) String() string {
|
||||
if len(ce.Children) == 0 {
|
||||
return fmt.Sprintf(`&html.ContainerEntity{Base=%s, Indent=%d, Children=[]}`, ce.BaseEntity, ce.Indent)
|
||||
}
|
||||
var buf strings.Builder
|
||||
_, _ = fmt.Fprintf(&buf, `&html.ContainerEntity{Base=%s, Indent=%d, Children=[`, ce.BaseEntity, ce.Indent)
|
||||
for _, child := range ce.Children {
|
||||
buf.WriteString("\n ")
|
||||
buf.WriteString(strings.Join(strings.Split(strings.TrimRight(child.String(), "\n"), "\n"), "\n "))
|
||||
}
|
||||
buf.WriteString("\n]},")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// Draw draws this entity onto the given mauview Screen.
|
||||
func (ce *ContainerEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||
if len(ce.Children) == 0 {
|
||||
return
|
||||
}
|
||||
width, _ := screen.Size()
|
||||
prevBreak := false
|
||||
proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: ce.Indent, Width: width - ce.Indent, Style: ce.Style}
|
||||
for i, entity := range ce.Children {
|
||||
if i != 0 && entity.getStartX() == 0 {
|
||||
proxyScreen.OffsetY++
|
||||
}
|
||||
proxyScreen.Height = entity.Height()
|
||||
entity.Draw(proxyScreen, ctx)
|
||||
proxyScreen.SetStyle(ce.Style)
|
||||
proxyScreen.OffsetY += entity.Height() - 1
|
||||
_, isBreak := entity.(*BreakEntity)
|
||||
if prevBreak && isBreak {
|
||||
proxyScreen.OffsetY++
|
||||
}
|
||||
prevBreak = isBreak
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateBuffer prepares this entity and all its children for rendering with the given parameters
|
||||
func (ce *ContainerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int {
|
||||
ce.BaseEntity.CalculateBuffer(width, startX, ctx)
|
||||
if len(ce.Children) > 0 {
|
||||
ce.height = 0
|
||||
childStartX := ce.startX
|
||||
prevBreak := false
|
||||
for _, entity := range ce.Children {
|
||||
if entity.IsBlock() || childStartX == 0 || ce.height == 0 {
|
||||
ce.height++
|
||||
}
|
||||
childStartX = entity.CalculateBuffer(width-ce.Indent, childStartX, ctx)
|
||||
ce.height += entity.Height() - 1
|
||||
_, isBreak := entity.(*BreakEntity)
|
||||
if prevBreak && isBreak {
|
||||
ce.height++
|
||||
}
|
||||
prevBreak = isBreak
|
||||
}
|
||||
if !ce.Block {
|
||||
return childStartX
|
||||
}
|
||||
}
|
||||
return ce.startX
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
type HorizontalLineEntity struct {
|
||||
*BaseEntity
|
||||
}
|
||||
|
||||
const HorizontalLineChar = '━'
|
||||
|
||||
func NewHorizontalLineEntity() *HorizontalLineEntity {
|
||||
return &HorizontalLineEntity{&BaseEntity{
|
||||
Tag: "hr",
|
||||
Block: true,
|
||||
DefaultHeight: 1,
|
||||
}}
|
||||
}
|
||||
|
||||
func (he *HorizontalLineEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity {
|
||||
he.BaseEntity = he.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity)
|
||||
return he
|
||||
}
|
||||
|
||||
func (he *HorizontalLineEntity) Clone() Entity {
|
||||
return NewHorizontalLineEntity()
|
||||
}
|
||||
|
||||
func (he *HorizontalLineEntity) Draw(screen mauview.Screen, ctx DrawContext) {
|
||||
width, _ := screen.Size()
|
||||
for x := 0; x < width; x++ {
|
||||
screen.SetContent(x, 0, HorizontalLineChar, nil, he.Style)
|
||||
}
|
||||
}
|
||||
|
||||
func (he *HorizontalLineEntity) PlainText() string {
|
||||
return strings.Repeat(string(HorizontalLineChar), 5)
|
||||
}
|
||||
|
||||
func (he *HorizontalLineEntity) String() string {
|
||||
return "&html.HorizontalLineEntity{},\n"
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/chroma"
|
||||
"github.com/alecthomas/chroma/lexers"
|
||||
"github.com/alecthomas/chroma/styles"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
"golang.org/x/net/html"
|
||||
"mvdan.cc/xurls/v2"
|
||||
|
||||
"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", "<br/>", -1)
|
||||
}
|
||||
htmlData = strings.Replace(htmlData, "\t", strings.Repeat(" ", TabLength), -1)
|
||||
|
||||
parser := htmlParser{room: room, prefs: prefs, evt: evt}
|
||||
root := parser.Parse(htmlData)
|
||||
if root == nil {
|
||||
return nil
|
||||
}
|
||||
beRoot, ok := root.(*ContainerEntity)
|
||||
if ok {
|
||||
beRoot.Block = false
|
||||
if len(beRoot.Children) > 0 {
|
||||
beChild, ok := beRoot.Children[0].(*ContainerEntity)
|
||||
if ok && beChild.Tag == "p" {
|
||||
// Hacky fix for m.emote
|
||||
beChild.Block = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if content.MsgType == event.MsgEmote {
|
||||
root = &ContainerEntity{
|
||||
BaseEntity: &BaseEntity{
|
||||
Tag: "emote",
|
||||
},
|
||||
Children: []Entity{
|
||||
NewTextEntity("* "),
|
||||
NewTextEntity(senderDisplayname).AdjustStyle(AdjustStyleTextColor(widget.GetHashColor(evt.Sender)), AdjustStyleReasonNormal),
|
||||
NewTextEntity(" "),
|
||||
root,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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] == ' '
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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]
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
//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
|
||||
)
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
}
|
135
ui/rainbow.go
135
ui/rainbow.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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, "<font color=\"%s\">%s</font>", rbw.ColorID, graphemes.Str())
|
||||
i += i2
|
||||
if err != nil {
|
||||
return i, err
|
||||
}
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (rbw rainbowBufWriter) Write(data []byte) (int, error) {
|
||||
return rbw.WriteString(string(data))
|
||||
}
|
||||
|
||||
func (rbw rainbowBufWriter) WriteByte(c byte) error {
|
||||
_, err := rbw.WriteRune(rune(c))
|
||||
return err
|
||||
}
|
||||
|
||||
func (rbw rainbowBufWriter) WriteRune(r rune) (int, error) {
|
||||
if unicode.IsSpace(r) {
|
||||
return rbw.BufWriter.WriteRune(r)
|
||||
} else {
|
||||
return fmt.Fprintf(rbw.BufWriter, "<font color=\"%s\">%c</font>", rbw.ColorID, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (rb *rainbowRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
segment := n.Segment
|
||||
if n.IsRaw() {
|
||||
html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, segment.Value(source))
|
||||
} else {
|
||||
html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, segment.Value(source))
|
||||
if n.HardLineBreak() || (n.SoftLineBreak() && rb.HardWraps) {
|
||||
_, _ = w.WriteString("<br>\n")
|
||||
} else if n.SoftLineBreak() {
|
||||
_ = w.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (rb *rainbowRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.String)
|
||||
if n.IsCode() {
|
||||
_, _ = w.Write(n.Value)
|
||||
} else {
|
||||
if n.IsRaw() {
|
||||
html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, n.Value)
|
||||
} else {
|
||||
html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, n.Value)
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
592
ui/room-list.go
592
ui/room-list.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
}
|
937
ui/room-view.go
937
ui/room-view.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 = `<a href="https://matrix.to/#/%[2]s">%[1]s</a>`
|
||||
mentionPlaintext = "%[1]s"
|
||||
)
|
||||
|
||||
func (view *RoomView) defaultAutocomplete(word string, startIndex int) (strCompletions []string, strCompletion string) {
|
||||
if len(word) == 0 {
|
||||
return []string{}, ""
|
||||
}
|
||||
|
||||
completions := view.AutocompleteUser(word)
|
||||
completions = append(completions, view.AutocompleteRoom(word)...)
|
||||
|
||||
if len(completions) == 1 {
|
||||
completion := completions[0]
|
||||
template := mentionMarkdown
|
||||
if view.config.Preferences.DisableMarkdown {
|
||||
if view.config.Preferences.DisableHTML {
|
||||
template = mentionPlaintext
|
||||
} else {
|
||||
template = mentionHTML
|
||||
}
|
||||
}
|
||||
strCompletion = fmt.Sprintf(template, completion.displayName, completion.id)
|
||||
if startIndex == 0 && completion.id[0] == '@' {
|
||||
strCompletion = strCompletion + ":"
|
||||
}
|
||||
} else if len(completions) > 1 {
|
||||
for _, completion := range completions {
|
||||
strCompletions = append(strCompletions, completion.displayName)
|
||||
}
|
||||
}
|
||||
|
||||
strCompletions = append(strCompletions, view.parent.cmdProcessor.AutocompleteCommand(word)...)
|
||||
strCompletions = append(strCompletions, view.AutocompleteEmoji(word)...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (view *RoomView) InputTabComplete(text string, cursorOffset int) {
|
||||
if len(text) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
str := runewidth.Truncate(text, cursorOffset, "")
|
||||
word := findWordToTabComplete(str)
|
||||
startIndex := len(str) - len(word)
|
||||
|
||||
var strCompletion string
|
||||
|
||||
strCompletions, newText, ok := view.parent.cmdProcessor.Autocomplete(view, text, cursorOffset)
|
||||
if !ok {
|
||||
strCompletions, strCompletion = view.defaultAutocomplete(word, startIndex)
|
||||
}
|
||||
|
||||
if len(strCompletions) > 0 {
|
||||
strCompletion = util.LongestCommonPrefix(strCompletions)
|
||||
sort.Sort(sort.StringSlice(strCompletions))
|
||||
}
|
||||
if len(strCompletion) > 0 && len(strCompletions) < 2 {
|
||||
strCompletion += " "
|
||||
strCompletions = []string{}
|
||||
}
|
||||
|
||||
if len(strCompletion) > 0 && newText == text {
|
||||
newText = str[0:startIndex] + strCompletion + text[len(str):]
|
||||
}
|
||||
|
||||
view.input.SetTextAndMoveCursor(newText)
|
||||
view.SetCompletions(strCompletions)
|
||||
}
|
||||
|
||||
func (view *RoomView) InputSubmit(text string) {
|
||||
if len(text) == 0 {
|
||||
return
|
||||
} else if cmd := view.parent.cmdProcessor.ParseCommand(view, text); cmd != nil {
|
||||
go view.parent.cmdProcessor.HandleCommand(cmd)
|
||||
} else {
|
||||
go view.SendMessage(event.MsgText, text)
|
||||
}
|
||||
view.editMoveText = ""
|
||||
view.SetInputText("")
|
||||
}
|
||||
|
||||
func (view *RoomView) CopyToClipboard(text string, register string) {
|
||||
if register == "clipboard" || register == "primary" {
|
||||
err := clipboard.WriteAll(text, register)
|
||||
if err != nil {
|
||||
view.AddServiceMessage(fmt.Sprintf("Clipboard unsupported: %v", err))
|
||||
view.parent.parent.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
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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++
|
||||
}
|
||||
}
|
128
ui/ui.go
128
ui/ui.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
//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()
|
||||
}
|
196
ui/view-login.go
196
ui/view-login.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
463
ui/view-main.go
463
ui/view-main.go
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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()
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
// Package widget contains additional tview widgets.
|
||||
package widget
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
}
|
Loading…
Add table
Reference in a new issue