all: delete old code

This commit is contained in:
Tulir Asokan 2024-10-04 17:25:25 +03:00
parent e6a2c3ff85
commit 4767def4b5
94 changed files with 37 additions and 16227 deletions

View file

@ -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 ./...

View file

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

View file

@ -3,15 +3,9 @@
[![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases)
[![GitLab CI](https://mau.dev/tulir/gomuks/badges/master/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines)
[![Maintainability](https://img.shields.io/codeclimate/maintainability/tulir/gomuks.svg)](https://codeclimate.com/github/tulir/gomuks)
[![Packaging status](https://repology.org/badge/tiny-repos/gomuks.svg)](https://repology.org/project/gomuks/versions)
![Chat Preview](chat-preview.png)
A terminal Matrix client written in Go using [mautrix](https://github.com/tulir/mautrix-go) and [mauview](https://github.com/tulir/mauview).
## Docs
For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/).
A Matrix client written in Go using [mautrix](https://github.com/tulir/mautrix-go).
## Discussion
Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net)

View file

@ -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'`'" "$@"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

View file

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

View file

@ -1,2 +0,0 @@
// Package config contains the wrappers for gomuks configurations and sessions.
package config

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
// Package debug contains utilities to log debug messages and display panics nicely.
package debug

52
go.mod
View file

@ -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
View file

@ -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
View file

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

View file

@ -1,2 +0,0 @@
// Package ifc contains interfaces to allow circular function calls without circular imports.
package ifc

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
// Package notification contains a simple cross-platform desktop notification sending function.
package notification

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
package open
const Command = "open"
var Args []string

View file

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

View file

@ -1,7 +0,0 @@
//go:build !windows && !darwin
package open
const Command = "xdg-open"
var Args []string

View file

@ -1,2 +0,0 @@
// Package util contains miscellaneous utilities
package util

View file

@ -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
View file

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

View file

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

View file

@ -1,2 +0,0 @@
// Package matrix contains wrappers for mautrix for use by the UI of gomuks.
package matrix

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
// Package rooms contains a representation for Matrix rooms and utilities to parse state events.
package rooms

View file

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

View file

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

View file

@ -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("*")},
},
}
}

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,2 +0,0 @@
// Package ui contains the main gomuks UI.
package ui

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
// Package messages contains different message types and code to generate and render them.
package messages

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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] == ' '
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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]
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
// Package widget contains additional tview widgets.
package widget

View file

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