mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 18:43:41 -05:00
added 'some' files
This commit is contained in:
parent
dd3245eb90
commit
aa7ca67976
45 changed files with 6805 additions and 137 deletions
89
tui/autocomplete.go
Normal file
89
tui/autocomplete.go
Normal file
|
@ -0,0 +1,89 @@
|
|||
// 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 tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"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 := os.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
|
||||
}
|
|
@ -22,17 +22,11 @@ import (
|
|||
|
||||
"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
|
||||
TUI *GomuksTUI
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
|
@ -54,7 +48,7 @@ func (cmd *Command) Reply(message string, args ...interface{}) {
|
|||
message = fmt.Sprintf(message, args...)
|
||||
}
|
||||
cmd.Room.AddServiceMessage(message)
|
||||
cmd.UI.Render()
|
||||
cmd.TUI.App.Redraw()
|
||||
}
|
||||
|
||||
type Alias struct {
|
||||
|
@ -82,10 +76,7 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor {
|
|||
return &CommandProcessor{
|
||||
gomuksPointerContainer: gomuksPointerContainer{
|
||||
MainView: parent,
|
||||
UI: parent.parent,
|
||||
Matrix: parent.matrix,
|
||||
Config: parent.config,
|
||||
Gomuks: parent.gmx,
|
||||
TUI: parent.parent,
|
||||
},
|
||||
aliases: map[string]*Alias{
|
||||
"part": {"leave"},
|
||||
|
|
1058
tui/commands.go
Normal file
1058
tui/commands.go
Normal file
File diff suppressed because it is too large
Load diff
699
tui/crypto-commands.go
Normal file
699
tui/crypto-commands.go
Normal file
|
@ -0,0 +1,699 @@
|
|||
// 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 tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"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"
|
||||
)
|
||||
|
||||
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 := os.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(context.TODO)
|
||||
} else {
|
||||
keyData, err = mach.SSSS.GetKeyData(context.TODO, 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO(), 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(context.TODO())
|
||||
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(context.TODO(), 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")
|
||||
}
|
||||
|
||||
// korjaa
|
||||
func cmdCrossSigningGenerate(cmd *Command, container ifc.MatrixContainer, mach *crypto.OlmMachine, client *mautrix.Client, force bool) {
|
||||
if !force {
|
||||
existingKeys := mach.GetOwnCrossSigningPublicKeys(context.TODO())
|
||||
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(context.TODO(), 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(context.TODO())
|
||||
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(context.TODO())
|
||||
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(context.TODO(), 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(context.TODO(), 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")
|
||||
}
|
||||
}
|
2
tui/doc.go
Normal file
2
tui/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
|||
// Package tui contains the main gomuks TUI.
|
||||
package tui
|
161
tui/fuzzy-search-modal.go
Normal file
161
tui/fuzzy-search-modal.go
Normal file
|
@ -0,0 +1,161 @@
|
|||
// 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 tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
"maunium.net/go/gomuks/debug"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
115
tui/help-modal.go
Normal file
115
tui/help-modal.go
Normal file
|
@ -0,0 +1,115 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
|
@ -23,14 +23,11 @@ import (
|
|||
|
||||
"github.com/mattn/go-runewidth"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"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 {
|
||||
|
|
|
@ -23,20 +23,14 @@ import (
|
|||
"sync/atomic"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
sync "github.com/sasha-s/go-deadlock"
|
||||
|
||||
"go.mau.fi/mauview"
|
||||
"go.mau.fi/tcell"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"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 {
|
||||
|
@ -652,7 +646,6 @@ func (view *MessageView) Draw(screen mauview.Screen) {
|
|||
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 {
|
||||
|
|
397
tui/messages/base.go
Normal file
397
tui/messages/base.go
Normal file
|
@ -0,0 +1,397 @@
|
|||
// 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"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
/*
|
||||
"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()
|
||||
}
|
2
tui/messages/doc.go
Normal file
2
tui/messages/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
|||
// Package messages contains different message types and code to generate and render them.
|
||||
package messages
|
102
tui/messages/expandedtextmessage.go
Normal file
102
tui/messages/expandedtextmessage.go
Normal file
|
@ -0,0 +1,102 @@
|
|||
// 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"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
/*
|
||||
"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)
|
||||
}
|
||||
}
|
190
tui/messages/filemessage.go
Normal file
190
tui/messages/filemessage.go
Normal file
|
@ -0,0 +1,190 @@
|
|||
// 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"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
/*
|
||||
"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)
|
||||
}
|
||||
}
|
101
tui/messages/html/base.go
Normal file
101
tui/messages/html/base.go
Normal file
|
@ -0,0 +1,101 @@
|
|||
// 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"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
88
tui/messages/html/blockquote.go
Normal file
88
tui/messages/html/blockquote.go
Normal file
|
@ -0,0 +1,88 @@
|
|||
// 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)
|
||||
}
|
54
tui/messages/html/break.go
Normal file
54
tui/messages/html/break.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
// 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
|
||||
}
|
59
tui/messages/html/codeblock.go
Normal file
59
tui/messages/html/codeblock.go
Normal file
|
@ -0,0 +1,59 @@
|
|||
// 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 (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
156
tui/messages/html/colormap.go
Normal file
156
tui/messages/html/colormap.go
Normal file
|
@ -0,0 +1,156 @@
|
|||
// 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)
|
||||
}
|
148
tui/messages/html/container.go
Normal file
148
tui/messages/html/container.go
Normal file
|
@ -0,0 +1,148 @@
|
|||
// 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
|
||||
}
|
63
tui/messages/html/entity.go
Normal file
63
tui/messages/html/entity.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
// 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 (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
61
tui/messages/html/horizontalline.go
Normal file
61
tui/messages/html/horizontalline.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
// 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"
|
||||
}
|
123
tui/messages/html/list.go
Normal file
123
tui/messages/html/list.go
Normal file
|
@ -0,0 +1,123 @@
|
|||
// 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/tui/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)
|
||||
}
|
555
tui/messages/html/parser.go
Normal file
555
tui/messages/html/parser.go
Normal file
|
@ -0,0 +1,555 @@
|
|||
// 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"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"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
|
||||
}
|
120
tui/messages/html/spoiler.go
Normal file
120
tui/messages/html/spoiler.go
Normal file
|
@ -0,0 +1,120 @@
|
|||
// 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"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
156
tui/messages/html/text.go
Normal file
156
tui/messages/html/text.go
Normal file
|
@ -0,0 +1,156 @@
|
|||
// 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/tui/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] == ' '
|
||||
}
|
100
tui/messages/htmlmessage.go
Normal file
100
tui/messages/htmlmessage.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
// 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"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
/*
|
||||
"maunium.net/go/gomuks/matrix/muksevt"
|
||||
|
||||
"maunium.net/go/gomuks/config"
|
||||
"maunium.net/go/gomuks/tui/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()
|
||||
}
|
324
tui/messages/parser.go
Normal file
324
tui/messages/parser.go
Normal file
|
@ -0,0 +1,324 @@
|
|||
// 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"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
||||
/* "maunium.net/go/gomuks/debug"
|
||||
"maunium.net/go/gomuks/matrix/muksevt"
|
||||
"maunium.net/go/gomuks/matrix/rooms"
|
||||
"maunium.net/go/gomuks/tui/messages/html"
|
||||
"maunium.net/go/gomuks/tui/messages/tstring"
|
||||
"maunium.net/go/gomuks/tui/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)
|
||||
}
|
66
tui/messages/redactedmessage.go
Normal file
66
tui/messages/redactedmessage.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
// 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 (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
/* "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)
|
||||
}
|
||||
}
|
96
tui/messages/textbase.go
Normal file
96
tui/messages/textbase.go
Normal file
|
@ -0,0 +1,96 @@
|
|||
// 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/tui/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
|
||||
}
|
53
tui/messages/tstring/cell.go
Normal file
53
tui/messages/tstring/cell.go
Normal file
|
@ -0,0 +1,53 @@
|
|||
// 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"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
4
tui/messages/tstring/doc.go
Normal file
4
tui/messages/tstring/doc.go
Normal file
|
@ -0,0 +1,4 @@
|
|||
// 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
|
270
tui/messages/tstring/string.go
Normal file
270
tui/messages/tstring/string.go
Normal file
|
@ -0,0 +1,270 @@
|
|||
// 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"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
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]
|
||||
}
|
46
tui/no-crypto-commands.go
Normal file
46
tui/no-crypto-commands.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
// 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 tui
|
||||
|
||||
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
|
||||
)
|
143
tui/password-modal.go
Normal file
143
tui/password-modal.go
Normal file
|
@ -0,0 +1,143 @@
|
|||
// 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 tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
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.App.Redraw()
|
||||
return pwm.Wait()
|
||||
}
|
||||
|
||||
func NewPasswordModal(parent *MainView, title, thing, placeholder string, isNew bool) *PasswordModal {
|
||||
if placeholder == "" {
|
||||
placeholder = "correct horse battery staple"
|
||||
}
|
||||
if thing == "" {
|
||||
thing = strings.ToLower(title)
|
||||
}
|
||||
pwm := &PasswordModal{
|
||||
parent: parent,
|
||||
form: mauview.NewForm(),
|
||||
outputChan: make(chan string, 1),
|
||||
cancelChan: make(chan struct{}, 1),
|
||||
}
|
||||
|
||||
pwm.form.
|
||||
SetColumns([]int{1, 20, 1, 20, 1}).
|
||||
SetRows([]int{1, 1, 1, 0, 0, 0, 1, 1, 1})
|
||||
|
||||
width := 45
|
||||
height := 8
|
||||
|
||||
pwm.text = mauview.NewTextField()
|
||||
if isNew {
|
||||
pwm.text.SetText(fmt.Sprintf("Create a %s", thing))
|
||||
} else {
|
||||
pwm.text.SetText(fmt.Sprintf("Enter the %s", thing))
|
||||
}
|
||||
pwm.input = mauview.NewInputField().
|
||||
SetMaskCharacter('*').
|
||||
SetPlaceholder(placeholder)
|
||||
pwm.form.AddComponent(pwm.text, 1, 1, 3, 1)
|
||||
pwm.form.AddFormItem(pwm.input, 1, 2, 3, 1)
|
||||
|
||||
if isNew {
|
||||
height += 3
|
||||
pwm.confirmInput = mauview.NewInputField().
|
||||
SetMaskCharacter('*').
|
||||
SetPlaceholder(placeholder).
|
||||
SetChangedFunc(pwm.HandleChange)
|
||||
pwm.input.SetChangedFunc(pwm.HandleChange)
|
||||
pwm.confirmText = mauview.NewTextField().SetText(fmt.Sprintf("Confirm %s", thing))
|
||||
|
||||
pwm.form.SetRow(3, 1).SetRow(4, 1).SetRow(5, 1)
|
||||
pwm.form.AddComponent(pwm.confirmText, 1, 4, 3, 1)
|
||||
pwm.form.AddFormItem(pwm.confirmInput, 1, 5, 3, 1)
|
||||
}
|
||||
|
||||
pwm.cancel = mauview.NewButton("Cancel").SetOnClick(pwm.ClickCancel)
|
||||
pwm.submit = mauview.NewButton("Submit").SetOnClick(pwm.ClickSubmit)
|
||||
|
||||
pwm.form.AddFormItem(pwm.submit, 3, 7, 1, 1)
|
||||
pwm.form.AddFormItem(pwm.cancel, 1, 7, 1, 1)
|
||||
|
||||
box := mauview.NewBox(pwm.form).SetTitle(title)
|
||||
center := mauview.Center(box, width, height).SetAlwaysFocusChild(true)
|
||||
center.Focus()
|
||||
pwm.form.FocusNextItem()
|
||||
pwm.Component = center
|
||||
|
||||
return pwm
|
||||
}
|
||||
|
||||
func (pwm *PasswordModal) HandleChange(_ string) {
|
||||
if pwm.input.GetText() == pwm.confirmInput.GetText() {
|
||||
pwm.submit.SetBackgroundColor(mauview.Styles.ContrastBackgroundColor)
|
||||
} else {
|
||||
pwm.submit.SetBackgroundColor(tcell.ColorDefault)
|
||||
}
|
||||
}
|
||||
|
||||
func (pwm *PasswordModal) ClickCancel() {
|
||||
pwm.parent.HideModal()
|
||||
pwm.cancelChan <- struct{}{}
|
||||
}
|
||||
|
||||
func (pwm *PasswordModal) ClickSubmit() {
|
||||
if pwm.confirmInput == nil || pwm.input.GetText() == pwm.confirmInput.GetText() {
|
||||
pwm.parent.HideModal()
|
||||
pwm.outputChan <- pwm.input.GetText()
|
||||
}
|
||||
}
|
||||
|
||||
func (pwm *PasswordModal) Wait() (string, bool) {
|
||||
select {
|
||||
case result := <-pwm.outputChan:
|
||||
return result, true
|
||||
case <-pwm.cancelChan:
|
||||
return "", false
|
||||
}
|
||||
}
|
135
tui/rainbow.go
Normal file
135
tui/rainbow.go
Normal file
|
@ -0,0 +1,135 @@
|
|||
// 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 tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"crypto/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
|
||||
}
|
|
@ -22,15 +22,12 @@ import (
|
|||
"sort"
|
||||
"strings"
|
||||
|
||||
sync "github.com/sasha-s/go-deadlock"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"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{
|
||||
|
|
|
@ -23,29 +23,21 @@ import (
|
|||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kyokomi/emoji/v2"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/zyedidia/clipboard"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"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"
|
||||
"maunium.net/go/gomuks/tui/messages"
|
||||
"maunium.net/go/gomuks/tui/widget"
|
||||
)
|
||||
|
||||
type RoomView struct {
|
||||
|
@ -106,7 +98,6 @@ func NewRoomView(parent *MainView, room *rooms.Room) *RoomView {
|
|||
ulScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListWidth},
|
||||
|
||||
parent: parent,
|
||||
config: parent.config,
|
||||
}
|
||||
view.content = NewMessageView(view)
|
||||
view.Room.SetPreUnload(func() bool {
|
||||
|
@ -338,6 +329,7 @@ func (view *RoomView) ClearAllContext() {
|
|||
|
||||
func (view *RoomView) OnKeyEvent(event mauview.KeyEvent) bool {
|
||||
msgView := view.MessageView()
|
||||
//helpp
|
||||
kb := config.Keybind{
|
||||
Key: event.Key(),
|
||||
Ch: event.Rune(),
|
||||
|
@ -711,11 +703,11 @@ func (view *RoomView) CopyToClipboard(text string, register string) {
|
|||
err := clipboard.WriteAll(text, register)
|
||||
if err != nil {
|
||||
view.AddServiceMessage(fmt.Sprintf("Clipboard unsupported: %v", err))
|
||||
view.parent.parent.Render()
|
||||
view.parent.parent.App.Redraw()
|
||||
}
|
||||
} else {
|
||||
view.AddServiceMessage(fmt.Sprintf("Clipboard register %v unsupported", register))
|
||||
view.parent.parent.Render()
|
||||
view.parent.parent.App.Redraw()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -723,11 +715,11 @@ func (view *RoomView) Download(url id.ContentURI, file *attachment.EncryptedFile
|
|||
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()
|
||||
view.parent.parent.App.Redraw()
|
||||
return
|
||||
}
|
||||
view.AddServiceMessage(fmt.Sprintf("File downloaded to %s", path))
|
||||
view.parent.parent.Render()
|
||||
view.parent.parent.App.Redraw()
|
||||
if openFile {
|
||||
debug.Print("Opening file", path)
|
||||
open.Open(path)
|
||||
|
@ -745,7 +737,7 @@ func (view *RoomView) Redact(eventID id.EventID, reason string) {
|
|||
}
|
||||
}
|
||||
view.AddServiceMessage(fmt.Sprintf("Failed to redact message: %v", err))
|
||||
view.parent.parent.Render()
|
||||
view.parent.parent.App.Redraw()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -775,7 +767,7 @@ func (view *RoomView) SendReaction(eventID id.EventID, reaction string) {
|
|||
}
|
||||
}
|
||||
view.AddServiceMessage(fmt.Sprintf("Failed to send reaction: %v", err))
|
||||
view.parent.parent.Render()
|
||||
view.parent.parent.App.Redraw()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -816,7 +808,7 @@ func (view *RoomView) SendMessageMedia(path string) {
|
|||
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()
|
||||
view.parent.parent.App.Redraw()
|
||||
return
|
||||
}
|
||||
view.addLocalEcho(evt)
|
||||
|
@ -838,13 +830,13 @@ func (view *RoomView) addLocalEcho(evt *muksevt.Event) {
|
|||
}
|
||||
}
|
||||
view.AddServiceMessage(fmt.Sprintf("Failed to send message: %v", err))
|
||||
view.parent.parent.Render()
|
||||
view.parent.parent.App.Redraw()
|
||||
} else {
|
||||
debug.Print("Event ID received:", eventID)
|
||||
msg.EventID = eventID
|
||||
msg.State = muksevt.StateDefault
|
||||
view.MessageView().setMessageID(msg)
|
||||
view.parent.parent.Render()
|
||||
view.parent.parent.App.Redraw()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -51,15 +51,15 @@ func (sm *SyncingModal) SetMessage(text string) {
|
|||
|
||||
func (sm *SyncingModal) SetIndeterminate() {
|
||||
sm.progress.SetIndeterminate(true)
|
||||
sm.parent.parent.app.SetRedrawTicker(100 * time.Millisecond)
|
||||
sm.parent.parent.app.Redraw()
|
||||
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()
|
||||
sm.parent.parent.App.SetRedrawTicker(1 * time.Minute)
|
||||
sm.parent.parent.App.Redraw()
|
||||
}
|
||||
|
||||
func (sm *SyncingModal) Step() {
|
||||
|
|
329
tui/tag-room-list.go
Normal file
329
tui/tag-room-list.go
Normal file
|
@ -0,0 +1,329 @@
|
|||
// 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 tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
|
||||
"maunium.net/go/gomuks/debug"
|
||||
|
||||
)
|
||||
|
||||
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++
|
||||
}
|
||||
}
|
252
tui/verification-modal.go
Normal file
252
tui/verification-modal.go
Normal file
|
@ -0,0 +1,252 @@
|
|||
// 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 tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
|
||||
"maunium.net/go/mautrix/crypto"
|
||||
"maunium.net/go/mautrix/event"
|
||||
|
||||
"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.App.Redraw()
|
||||
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.App.Redraw()
|
||||
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.App.Redraw()
|
||||
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.App.Redraw()
|
||||
}
|
||||
|
||||
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.App.Redraw()
|
||||
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()
|
||||
}
|
187
tui/view-main.go
187
tui/view-main.go
|
@ -18,25 +18,29 @@ package tui
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
sync "github.com/sasha-s/go-deadlock"
|
||||
|
||||
/*
|
||||
sync "github.com/sasha-s/go-deadlock"
|
||||
*/
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"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"
|
||||
"maunium.net/go/gomuks/lib/notification"
|
||||
"maunium.net/go/gomuks/matrix/rooms"
|
||||
|
||||
//find what to use
|
||||
"maunium.net/go/gomuks/gt/messages"
|
||||
"maunium.net/go/gomuks/gt/widget"
|
||||
"maunium.net/go/gomuks/lib/notification"
|
||||
|
||||
"maunium.net/go/gomuks/matrix/rooms"
|
||||
)
|
||||
|
||||
type MainView struct {
|
||||
|
@ -54,20 +58,19 @@ type MainView struct {
|
|||
|
||||
lastFocusTime time.Time
|
||||
|
||||
matrix ifc.MatrixContainer
|
||||
gmx ifc.Gomuks
|
||||
parent *GomuksTUI
|
||||
}
|
||||
|
||||
func (gt *GomuksTUI) NewMainView() mauview.Component {
|
||||
|
||||
mainView := &MainView{
|
||||
flex: mauview.NewFlex().SetDirection(mauview.FlexColumn),
|
||||
roomView: mauview.NewBox(nil).SetBorder(false),
|
||||
rooms: make(map[id.RoomID]*RoomView),
|
||||
|
||||
matrix: gt.gmx.Matrix(),
|
||||
gmx: gt.gmx,
|
||||
config: gt.gmx.Config(),
|
||||
/*crying face*
|
||||
matrix: gt.Gomuks.Matrix(),
|
||||
gmx: gt.Gomuks,
|
||||
config: gt.Gomuks.Config(),
|
||||
*/
|
||||
parent: gt,
|
||||
}
|
||||
mainView.roomList = NewRoomList(mainView)
|
||||
|
@ -101,6 +104,7 @@ func (view *MainView) HideModal() {
|
|||
}
|
||||
|
||||
func (view *MainView) Draw(screen mauview.Screen) {
|
||||
//config does not exist
|
||||
if view.config.Preferences.HideRoomList {
|
||||
view.roomView.Draw(screen)
|
||||
} else {
|
||||
|
@ -125,7 +129,8 @@ func (view *MainView) MarkRead(roomView *RoomView) {
|
|||
if len(msgList) > 0 {
|
||||
msg := msgList[len(msgList)-1]
|
||||
if roomView.Room.MarkRead(msg.ID()) {
|
||||
view.matrix.MarkRead(roomView.Room.ID, msg.ID())
|
||||
//? receipt type
|
||||
view.parent.Client.MarkRead(context.TODO(), roomView.Room.ID, msg.ID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -133,7 +138,8 @@ func (view *MainView) MarkRead(roomView *RoomView) {
|
|||
|
||||
func (view *MainView) InputChanged(roomView *RoomView, text string) {
|
||||
if !roomView.config.Preferences.DisableTypingNotifs {
|
||||
view.matrix.SendTyping(roomView.Room.ID, len(text) > 0 && text[0] != '/')
|
||||
//fix args in this too
|
||||
view.parent.Client.SetTyping(context.TODO(), roomView.Room.ID, len(text) > 0 && text[0] != '/')
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -141,8 +147,8 @@ func (view *MainView) ShowBare(roomView *RoomView) {
|
|||
if roomView == nil {
|
||||
return
|
||||
}
|
||||
_, height := view.parent.app.Screen().Size()
|
||||
view.parent.app.Suspend(func() {
|
||||
_, 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.
|
||||
|
@ -155,75 +161,84 @@ func (view *MainView) ShowBare(roomView *RoomView) {
|
|||
})
|
||||
}
|
||||
|
||||
/*//???? idk what it does
|
||||
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)
|
||||
/*
|
||||
//umm this is some keyboard magic which idk how to implement
|
||||
|
||||
if view.modal != nil {
|
||||
return view.modal.OnKeyEvent(event)
|
||||
}
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
/*
|
||||
//more :( key things
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
return view.flex.OnPasteEvent(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()
|
||||
|
@ -260,7 +275,7 @@ func (view *MainView) switchRoom(tag string, room *rooms.Room, lock bool) {
|
|||
view.flex.SetFocused(view.roomView)
|
||||
view.focused = view.roomView
|
||||
view.roomView.Focus()
|
||||
view.parent.Render()
|
||||
view.parent.App.Redraw()
|
||||
|
||||
if msgView := roomView.MessageView(); len(msgView.messages) < 20 && !msgView.initialHistoryLoaded {
|
||||
msgView.initialHistoryLoaded = true
|
||||
|
@ -268,13 +283,14 @@ func (view *MainView) switchRoom(tag string, room *rooms.Room, lock bool) {
|
|||
}
|
||||
if !room.MembersFetched {
|
||||
go func() {
|
||||
err := view.matrix.FetchMembers(room)
|
||||
//another args thing
|
||||
err := view.parent.Client.GetRoomState(context.TODO())
|
||||
if err != nil {
|
||||
debug.Print("Error fetching members:", err)
|
||||
return
|
||||
}
|
||||
roomView.UpdateUserList()
|
||||
view.parent.Render()
|
||||
view.parent.App.Redraw()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
@ -289,6 +305,7 @@ func (view *MainView) addRoomPage(room *rooms.Room) *RoomView {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ifc + what do i get?
|
||||
func (view *MainView) GetRoom(roomID id.RoomID) ifc.RoomView {
|
||||
room, ok := view.getRoomView(roomID, true)
|
||||
if !ok {
|
||||
|
@ -328,7 +345,7 @@ func (view *MainView) RemoveRoom(room *rooms.Room) {
|
|||
delete(view.rooms, room.ID)
|
||||
view.roomsLock.Unlock()
|
||||
|
||||
view.parent.Render()
|
||||
view.parent.App.Redraw()
|
||||
}
|
||||
|
||||
func (view *MainView) addRoom(room *rooms.Room) *RoomView {
|
||||
|
@ -374,14 +391,14 @@ func (view *MainView) UpdateTags(room *rooms.Room) {
|
|||
if reselect {
|
||||
view.roomList.SetSelected(room.Tags()[0].Tag, room)
|
||||
}
|
||||
view.parent.Render()
|
||||
view.parent.App.Redraw()
|
||||
}
|
||||
|
||||
func (view *MainView) SetTyping(roomID id.RoomID, users []id.UserID) {
|
||||
roomView, ok := view.getRoomView(roomID, true)
|
||||
if ok {
|
||||
roomView.SetTyping(users)
|
||||
view.parent.Render()
|
||||
view.parent.App.Redraw()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -397,10 +414,11 @@ func (view *MainView) Bump(room *rooms.Room) {
|
|||
view.roomList.Bump(room)
|
||||
}
|
||||
|
||||
// another arg mess. Did not find what is userID
|
||||
func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) {
|
||||
view.Bump(room)
|
||||
gtMsg, ok := message.(*messages.UIMessage)
|
||||
if ok && gtMsg.SenderID == view.config.UserID {
|
||||
if ok && gtMsg.SenderID == view.UserID {
|
||||
return
|
||||
}
|
||||
// Whether or not the room where the message came is the currently shown room.
|
||||
|
@ -413,9 +431,10 @@ func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, shoul
|
|||
// 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())
|
||||
//args & also why not use the func in here
|
||||
view.parent.Client.MarkRead(room.ID, message.ID())
|
||||
}
|
||||
|
||||
//config
|
||||
if should.Notify && !recentlyFocused && !view.config.Preferences.DisableNotifications {
|
||||
// Push rules say notify and the terminal is not focused, send desktop notification.
|
||||
shouldPlaySound := should.PlaySound &&
|
||||
|
@ -443,13 +462,13 @@ func (view *MainView) LoadHistory(roomID id.RoomID) {
|
|||
}
|
||||
defer atomic.StoreInt32(&msgView.loadingMessages, 0)
|
||||
// Update the "Loading more messages..." text
|
||||
view.parent.Render()
|
||||
|
||||
view.parent.App.Redraw()
|
||||
//history?????
|
||||
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()
|
||||
view.parent.App.Redraw()
|
||||
return
|
||||
}
|
||||
//debug.Printf("Load pointer %d -> %d", msgView.historyLoadPtr, newLoadPtr)
|
||||
|
@ -457,5 +476,5 @@ func (view *MainView) LoadHistory(roomID id.RoomID) {
|
|||
for _, evt := range history {
|
||||
roomView.AddHistoryEvent(evt)
|
||||
}
|
||||
view.parent.Render()
|
||||
view.parent.App.Redraw()
|
||||
}
|
||||
|
|
63
tui/widget/border.go
Normal file
63
tui/widget/border.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
// 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 (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
224
tui/widget/color.go
Normal file
224
tui/widget/color.go
Normal file
|
@ -0,0 +1,224 @@
|
|||
// 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"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
||||
"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)
|
||||
}
|
2
tui/widget/doc.go
Normal file
2
tui/widget/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
|||
// Package widget contains additional tview widgets.
|
||||
package widget
|
73
tui/widget/util.go
Normal file
73
tui/widget/util.go
Normal file
|
@ -0,0 +1,73 @@
|
|||
// 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"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"go.mau.fi/mauview"
|
||||
)
|
||||
|
||||
func WriteLineSimple(screen mauview.Screen, line string, x, y int) {
|
||||
WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault)
|
||||
}
|
||||
|
||||
func WriteLineSimpleColor(screen mauview.Screen, line string, x, y int, color tcell.Color) {
|
||||
WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color))
|
||||
}
|
||||
|
||||
func WriteLineColor(screen mauview.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) {
|
||||
WriteLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color))
|
||||
}
|
||||
|
||||
func WriteLine(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) {
|
||||
offsetX := 0
|
||||
if align == mauview.AlignRight {
|
||||
offsetX = maxWidth - runewidth.StringWidth(line)
|
||||
}
|
||||
if offsetX < 0 {
|
||||
offsetX = 0
|
||||
}
|
||||
for _, ch := range line {
|
||||
chWidth := runewidth.RuneWidth(ch)
|
||||
if chWidth == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for localOffset := 0; localOffset < chWidth; localOffset++ {
|
||||
screen.SetContent(x+offsetX+localOffset, y, ch, nil, style)
|
||||
}
|
||||
offsetX += chWidth
|
||||
if offsetX >= maxWidth {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WriteLinePadded(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) {
|
||||
padding := strconv.Itoa(maxWidth)
|
||||
if align == mauview.AlignRight {
|
||||
line = fmt.Sprintf("%"+padding+"s", line)
|
||||
} else {
|
||||
line = fmt.Sprintf("%-"+padding+"s", line)
|
||||
}
|
||||
WriteLine(screen, mauview.AlignLeft, line, x, y, maxWidth, style)
|
||||
}
|
Loading…
Add table
Reference in a new issue