aboutsummaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorTulir Asokan <tulir@maunium.net>2020-07-30 14:34:49 +0300
committerTulir Asokan <tulir@maunium.net>2020-07-30 14:34:49 +0300
commit0d12947b1f70745d8023c89e22432f2a1353dfbb (patch)
tree048d98d1484851caaffc3c5463619442d8fee734 /ui
parentecdd4f08cb262c8f0c988209e6296c068e1d4cf3 (diff)
parent2f5f0674b600f129204958c810843d998f6a2f6a (diff)
Merge branch 'verification'
Fixes #160 Fixes #161
Diffstat (limited to 'ui')
-rw-r--r--ui/command-processor.go62
-rw-r--r--ui/commands.go18
-rw-r--r--ui/crypto-commands.go231
-rw-r--r--ui/no-crypto-commands.go36
-rw-r--r--ui/room-view.go186
-rw-r--r--ui/verification-modal.go245
-rw-r--r--ui/view-main.go18
7 files changed, 702 insertions, 94 deletions
diff --git a/ui/command-processor.go b/ui/command-processor.go
index b8f41a2..514d67b 100644
--- a/ui/command-processor.go
+++ b/ui/command-processor.go
@@ -20,6 +20,8 @@ import (
"fmt"
"strings"
+ "github.com/mattn/go-runewidth"
+
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/interface"
@@ -45,6 +47,8 @@ type Command struct {
OrigText string
}
+type CommandAutocomplete Command
+
func (cmd *Command) Reply(message string, args ...interface{}) {
cmd.Room.AddServiceMessage(fmt.Sprintf(message, args...))
cmd.UI.Render()
@@ -60,12 +64,15 @@ func (alias *Alias) Process(cmd *Command) *Command {
}
type CommandHandler func(cmd *Command)
+type CommandAutocompleter func(cmd *CommandAutocomplete) (completions []string, newText string)
type CommandProcessor struct {
gomuksPointerContainer
aliases map[string]*Alias
commands map[string]CommandHandler
+
+ autocompleters map[string]CommandAutocompleter
}
func NewCommandProcessor(parent *MainView) *CommandProcessor {
@@ -97,6 +104,12 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor {
"dl": {"download"},
"o": {"open"},
},
+ autocompleters: map[string]CommandAutocompleter{
+ "devices": autocompleteDevice,
+ "device": autocompleteDevice,
+ "verify": autocompleteDevice,
+ "unverify": autocompleteDevice,
+ },
commands: map[string]CommandHandler{
"unknown-command": cmdUnknownCommand,
@@ -139,7 +152,13 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor {
"cprof": cmdCPUProfile,
"trace": cmdTrace,
- "fingerprint": cmdFingerprint,
+ "fingerprint": cmdFingerprint,
+ "devices": cmdDevices,
+ "verify": cmdVerify,
+ "device": cmdDevice,
+ "unverify": cmdUnverify,
+ "blacklist": cmdBlacklist,
+ "reset-session": cmdResetSession,
},
}
}
@@ -169,6 +188,47 @@ func (ch *CommandProcessor) ParseCommand(roomView *RoomView, text string) *Comma
}
}
+func (ch *CommandProcessor) Autocomplete(roomView *RoomView, text string, cursorOffset int) ([]string, string, bool) {
+ var completions []string
+ if cmd := (*CommandAutocomplete)(ch.ParseCommand(roomView, text)); cmd == nil {
+ return completions, text, false
+ } else if handler, ok := ch.autocompleters[cmd.Command]; !ok {
+ return completions, text, false
+ } else if cursorOffset != runewidth.StringWidth(text) {
+ return completions, text, false
+ } else {
+ completions, newText := handler(cmd)
+ if newText == "" {
+ newText = text
+ }
+ return completions, newText, true
+ }
+}
+
+func (ch *CommandProcessor) AutocompleteCommand(word string) (completions []string) {
+ if word[0] != '/' {
+ return
+ }
+ word = word[1:]
+ for alias := range ch.aliases {
+ if alias == word {
+ return []string{"/" + alias}
+ }
+ if strings.HasPrefix(alias, word) {
+ completions = append(completions, "/"+alias)
+ }
+ }
+ for command := range ch.commands {
+ if command == word {
+ return []string{"/" + command}
+ }
+ if strings.HasPrefix(command, word) {
+ completions = append(completions, "/"+command)
+ }
+ }
+ return
+}
+
func (ch *CommandProcessor) HandleCommand(cmd *Command) {
defer debug.Recover()
if cmd == nil {
diff --git a/ui/commands.go b/ui/commands.go
index 49bff0f..9d38396 100644
--- a/ui/commands.go
+++ b/ui/commands.go
@@ -440,7 +440,7 @@ func cmdHelp(cmd *Command) {
/logout - Log out of Matrix.
/toggle <thing> - Temporary command to toggle various UI features.
-Things: rooms, users, baremessages, images, typingnotif
+Things: rooms, users, baremessages, images, typingnotif, unverified
# Sending special messages
/me <message> - Send an emote message.
@@ -449,7 +449,18 @@ Things: rooms, users, baremessages, images, typingnotif
/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.
+/redact [reason] - Redact 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> <device id> [fingerprint]
+ - Verify a device. If the fingerprint is not provided,
+ interactive emoji verification will be started.
# Rooms
/pm <user id> <...> - Create a private chat with the given user(s).
@@ -710,6 +721,7 @@ var toggleMsg = map[string]ToggleMessage{
"markdown": SimpleToggleMessage("markdown input"),
"downloads": SimpleToggleMessage("automatic downloads"),
"notifications": SimpleToggleMessage("desktop notifications"),
+ "unverified": SimpleToggleMessage("sending messages to unverified devices"),
}
func makeUsage() string {
@@ -750,6 +762,8 @@ func cmdToggle(cmd *Command) {
val = &cmd.Config.Preferences.DisableDownloads
case "notifications":
val = &cmd.Config.Preferences.DisableNotifications
+ case "unverified":
+ val = &cmd.Config.SendToVerifiedOnly
default:
cmd.Reply("Unknown toggle %s. Use /toggle without arguments for a list of togglable things.", thing)
return
diff --git a/ui/crypto-commands.go b/ui/crypto-commands.go
new file mode 100644
index 0000000..232f508
--- /dev/null
+++ b/ui/crypto-commands.go
@@ -0,0 +1,231 @@
+// 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/>.
+
+// +build cgo
+
+package ui
+
+import (
+ "fmt"
+ "strings"
+ "time"
+ "unicode"
+
+ "maunium.net/go/mautrix/crypto"
+ "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 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)
+ } else if cmd.Command != "devices" {
+ return autocompleteDeviceDeviceID(cmd)
+ }
+ return []string{}, ""
+}
+
+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 {
+ _, _ = fmt.Fprintf(&buf, "%s (%s) - %s\n Fingerprint: %s\n", device.DeviceID, device.Name, device.Trust.String(), 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"
+ }
+ 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, device.Trust.String())
+}
+
+func cmdVerify(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"
+ }
+ device.Trust = crypto.TrustStateVerified
+ putDevice(cmd, device, action)
+ }
+}
+
+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")
+ }
+}
diff --git a/ui/no-crypto-commands.go b/ui/no-crypto-commands.go
new file mode 100644
index 0000000..dae85b4
--- /dev/null
+++ b/ui/no-crypto-commands.go
@@ -0,0 +1,36 @@
+// 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/>.
+
+// +build !cgo
+
+package ui
+
+func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) {
+ return []string{}, ""
+}
+
+func cmdNoCrypto(cmd *Command) {
+ cmd.Reply("This gomuks was built without encryption support")
+}
+
+var (
+ cmdDevices = cmdNoCrypto
+ cmdDevice = cmdNoCrypto
+ cmdVerify = cmdNoCrypto
+ cmdUnverify = cmdNoCrypto
+ cmdBlacklist = cmdNoCrypto
+ cmdResetSession = cmdNoCrypto
+)
diff --git a/ui/room-view.go b/ui/room-view.go
index dbdaccc..225e0a9 100644
--- a/ui/room-view.go
+++ b/ui/room-view.go
@@ -22,15 +22,18 @@ import (
"sort"
"strings"
"time"
+ "unicode"
"github.com/kyokomi/emoji"
"github.com/mattn/go-runewidth"
"github.com/zyedidia/clipboard"
- "maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mauview"
"maunium.net/go/tcell"
+ "maunium.net/go/gomuks/lib/util"
+ "maunium.net/go/mautrix/crypto/attachment"
+
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@@ -39,7 +42,6 @@ import (
"maunium.net/go/gomuks/debug"
"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"
@@ -420,65 +422,6 @@ func (view *RoomView) SetTyping(users []id.UserID) {
}
}
-type completion struct {
- displayName string
- id string
-}
-
-func (view *RoomView) autocompleteUser(existingText string) (completions []completion) {
- textWithoutPrefix := strings.TrimPrefix(existingText, "@")
- for userID, user := range view.Room.GetMembers() {
- if user.Displayname == textWithoutPrefix || string(userID) == existingText {
- // Exact match, return that.
- return []completion{{user.Displayname, string(userID)}}
- }
-
- if strings.HasPrefix(user.Displayname, textWithoutPrefix) || strings.HasPrefix(string(userID), existingText) {
- completions = append(completions, completion{user.Displayname, string(userID)})
- }
- }
- return
-}
-
-func (view *RoomView) autocompleteRoom(existingText string) (completions []completion) {
- for _, room := range view.parent.rooms {
- alias := string(room.Room.GetCanonicalAlias())
- if alias == existingText {
- // Exact match, return that.
- return []completion{{alias, string(room.Room.ID)}}
- }
- if strings.HasPrefix(alias, existingText) {
- completions = append(completions, completion{alias, string(room.Room.ID)})
- continue
- }
- }
- return
-}
-
-func (view *RoomView) autocompleteEmoji(word string) (completions []string) {
- if len(word) == 0 || word[0] != ':' {
- return
- }
- var valueCompletion1 string
- var manyValues bool
- for name, value := range emoji.CodeMap() {
- if name == word {
- return []string{value}
- } else if strings.HasPrefix(name, word) {
- completions = append(completions, name)
- if valueCompletion1 == "" {
- valueCompletion1 = value
- } else if valueCompletion1 != value {
- manyValues = true
- }
- }
- }
- if !manyValues && len(completions) > 0 {
- return []string{emoji.CodeMap()[completions[0]]}
- }
- return
-}
-
func (view *RoomView) SetEditing(evt *muksevt.Event) {
if evt == nil {
view.editing = nil
@@ -584,23 +527,90 @@ func (view *RoomView) SelectPrevious() {
}
}
-func (view *RoomView) InputTabComplete(text string, cursorOffset int) {
- debug.Print("Tab completing", cursorOffset, text)
- str := runewidth.Truncate(text, cursorOffset, "")
- word := findWordToTabComplete(str)
- startIndex := len(str) - len(word)
+type completion struct {
+ displayName string
+ id string
+}
- var strCompletions []string
- var strCompletion string
+func (view *RoomView) AutocompleteUser(existingText string) (completions []completion) {
+ textWithoutPrefix := strings.TrimPrefix(existingText, "@")
+ for userID, user := range view.Room.GetMembers() {
+ if user.Displayname == textWithoutPrefix || string(userID) == existingText {
+ // Exact match, return that.
+ return []completion{{user.Displayname, string(userID)}}
+ }
+
+ if strings.HasPrefix(user.Displayname, textWithoutPrefix) || strings.HasPrefix(string(userID), existingText) {
+ completions = append(completions, completion{user.Displayname, string(userID)})
+ }
+ }
+ return
+}
- completions := view.autocompleteUser(word)
- completions = append(completions, view.autocompleteRoom(word)...)
+func (view *RoomView) AutocompleteRoom(existingText string) (completions []completion) {
+ for _, room := range view.parent.rooms {
+ alias := string(room.Room.GetCanonicalAlias())
+ if alias == existingText {
+ // Exact match, return that.
+ return []completion{{alias, string(room.Room.ID)}}
+ }
+ if strings.HasPrefix(alias, existingText) {
+ completions = append(completions, completion{alias, string(room.Room.ID)})
+ continue
+ }
+ }
+ return
+}
+
+func (view *RoomView) AutocompleteEmoji(word string) (completions []string) {
+ if word[0] != ':' {
+ return
+ }
+ var valueCompletion1 string
+ var manyValues bool
+ for name, value := range emoji.CodeMap() {
+ if name == word {
+ return []string{value}
+ } else if strings.HasPrefix(name, word) {
+ completions = append(completions, name)
+ if valueCompletion1 == "" {
+ valueCompletion1 = value
+ } else if valueCompletion1 != value {
+ manyValues = true
+ }
+ }
+ }
+ if !manyValues && len(completions) > 0 {
+ return []string{emoji.CodeMap()[completions[0]]}
+ }
+ return
+}
+
+func findWordToTabComplete(text string) string {
+ output := ""
+ runes := []rune(text)
+ for i := len(runes) - 1; i >= 0; i-- {
+ if unicode.IsSpace(runes[i]) {
+ break
+ }
+ output = string(runes[i]) + output
+ }
+ return output
+}
+
+func (view *RoomView) defaultAutocomplete(word string, startIndex int) (strCompletions []string, strCompletion string) {
+ if len(word) == 0 {
+ return []string{}, ""
+ }
+
+ completions := view.AutocompleteUser(word)
+ completions = append(completions, view.AutocompleteRoom(word)...)
if len(completions) == 1 {
completion := completions[0]
strCompletion = fmt.Sprintf("[%s](https://matrix.to/#/%s)", completion.displayName, completion.id)
- if startIndex == 0 {
- strCompletion = strCompletion + ": "
+ if startIndex == 0 && completion.id[0] == '@' {
+ strCompletion = strCompletion + ":"
}
} else if len(completions) > 1 {
for _, completion := range completions {
@@ -608,18 +618,42 @@ func (view *RoomView) InputTabComplete(text string, cursorOffset int) {
}
}
- strCompletions = append(strCompletions, view.autocompleteEmoji(word)...)
+ strCompletions = append(strCompletions, view.parent.cmdProcessor.AutocompleteCommand(word)...)
+ strCompletions = append(strCompletions, view.AutocompleteEmoji(word)...)
+
+ return
+}
+
+func (view *RoomView) InputTabComplete(text string, cursorOffset int) {
+ if len(text) == 0 {
+ return
+ }
+
+ str := runewidth.Truncate(text, cursorOffset, "")
+ word := findWordToTabComplete(str)
+ startIndex := len(str) - len(word)
+
+ var strCompletion string
+
+ strCompletions, newText, ok := view.parent.cmdProcessor.Autocomplete(view, text, cursorOffset)
+ if !ok {
+ strCompletions, strCompletion = view.defaultAutocomplete(word, startIndex)
+ }
if len(strCompletions) > 0 {
strCompletion = util.LongestCommonPrefix(strCompletions)
sort.Sort(sort.StringSlice(strCompletions))
}
+ if len(strCompletion) > 0 && len(strCompletions) < 2 {
+ strCompletion += " "
+ strCompletions = []string{}
+ }
- if len(strCompletion) > 0 {
- text = str[0:startIndex] + strCompletion + text[len(str):]
+ if len(strCompletion) > 0 && newText == text {
+ newText = str[0:startIndex] + strCompletion + text[len(str):]
}
- view.input.SetTextAndMoveCursor(text)
+ view.input.SetTextAndMoveCursor(newText)
view.SetCompletions(strCompletions)
}
diff --git a/ui/verification-modal.go b/ui/verification-modal.go
new file mode 100644
index 0000000..bc529c9
--- /dev/null
+++ b/ui/verification-modal.go
@@ -0,0 +1,245 @@
+// 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/>.
+
+// +build cgo
+
+package ui
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "maunium.net/go/mauview"
+ "maunium.net/go/tcell"
+
+ "maunium.net/go/gomuks/debug"
+ "maunium.net/go/mautrix/crypto"
+ "maunium.net/go/mautrix/event"
+)
+
+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.ColorWhite)
+
+ flex := mauview.NewFlex().
+ SetDirection(mauview.FlexRow).
+ AddFixedComponent(vm.waitingBar, 1).
+ AddFixedComponent(vm.infoText, 4).
+ AddFixedComponent(vm.emojiText, 4).
+ AddFixedComponent(vm.inputBar, 1)
+
+ vm.container = mauview.NewBox(flex).
+ SetBorder(true).
+ SetTitle("Interactive verification")
+
+ vm.Component = mauview.Center(vm.container, 45, 12).SetAlwaysFocusChild(true)
+
+ go vm.decrementWaitingBar()
+
+ return vm
+}
+
+func (vm *VerificationModal) decrementWaitingBar() {
+ for {
+ select {
+ case <-time.Tick(time.Second):
+ if vm.progress <= 0 {
+ vm.waitingBar.SetIndeterminate(true)
+ vm.parent.parent.app.SetRedrawTicker(100 * time.Millisecond)
+ return
+ }
+ vm.progress--
+ vm.waitingBar.SetProgress(vm.progress)
+ vm.parent.parent.Render()
+ case <-vm.stopWaiting:
+ return
+ }
+ }
+}
+
+func (vm *VerificationModal) VerificationMethods() []crypto.VerificationMethod {
+ return []crypto.VerificationMethod{crypto.VerificationMethodEmoji{}, crypto.VerificationMethodDecimal{}}
+}
+
+func (vm *VerificationModal) VerifySASMatch(_ *crypto.DeviceIdentity, data crypto.SASData) bool {
+ 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.ColorWhite).
+ SetBackgroundColor(tcell.ColorDarkCyan).
+ SetPlaceholder("Type \"yes\" or \"no\"").
+ Focus()
+ vm.emojiText.Data = data
+ vm.parent.parent.Render()
+ vm.progress = vm.progressMax
+ confirm := <-vm.confirmChan
+ vm.progress = vm.progressMax
+ vm.emojiText.Data = nil
+ vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto confirm", vm.device.UserID))
+ vm.parent.parent.Render()
+ return confirm
+}
+
+func (vm *VerificationModal) OnCancel(cancelledByUs bool, reason string, _ event.VerificationCancelCode) {
+ vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100)
+ vm.parent.parent.app.SetRedrawTicker(1 * time.Minute)
+ if cancelledByUs {
+ vm.infoText.SetText(fmt.Sprintf("Verification failed: %s", reason))
+ } else {
+ vm.infoText.SetText(fmt.Sprintf("Verification cancelled by %s: %s", vm.device.UserID, reason))
+ }
+ vm.inputBar.SetPlaceholder("Press enter to close the dialog")
+ vm.stopWaiting <- struct{}{}
+ vm.done = true
+ vm.parent.parent.Render()
+}
+
+func (vm *VerificationModal) OnSuccess() {
+ vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100)
+ vm.parent.parent.app.SetRedrawTicker(1 * time.Minute)
+ vm.infoText.SetText(fmt.Sprintf("Successfully verified %s (%s) of %s", vm.device.Name, vm.device.DeviceID, vm.device.UserID))
+ vm.inputBar.SetPlaceholder("Press enter to close the dialog")
+ vm.stopWaiting <- struct{}{}
+ vm.done = true
+ vm.parent.parent.Render()
+ if vm.parent.config.SendToVerifiedOnly {
+ // Hacky way to make new group sessions after verified
+ vm.parent.matrix.Crypto().(*crypto.OlmMachine).OnDevicesChanged(vm.device.UserID)
+ }
+}
+
+func (vm *VerificationModal) OnKeyEvent(event mauview.KeyEvent) bool {
+ if vm.done {
+ if event.Key() == tcell.KeyEnter || event.Key() == tcell.KeyEsc {
+ vm.parent.HideModal()
+ return true
+ }
+ return false
+ } else if vm.emojiText.Data == nil {
+ debug.Print("Ignoring pre-emoji key event")
+ return false
+ }
+ if event.Key() == tcell.KeyEnter {
+ text := strings.ToLower(strings.TrimSpace(vm.inputBar.GetText()))
+ if text == "yes" {
+ debug.Print("Confirming verification")
+ vm.confirmChan <- true
+ } else if text == "no" {
+ debug.Print("Rejecting verification")
+ vm.confirmChan <- false
+ }
+ vm.inputBar.
+ SetPlaceholder("").
+ SetTextAndMoveCursor("").
+ SetBackgroundColor(tcell.ColorDefault).
+ SetTextColor(tcell.ColorDefault)
+ return true
+ } else {
+ return vm.inputBar.OnKeyEvent(event)
+ }
+}
+
+func (vm *VerificationModal) Focus() {
+ vm.container.Focus()
+}
+
+func (vm *VerificationModal) Blur() {
+ vm.container.Blur()
+}
diff --git a/ui/view-main.go b/ui/view-main.go
index 0f6098c..fc1b9c9 100644
--- a/ui/view-main.go
+++ b/ui/view-main.go
@@ -22,15 +22,15 @@ import (
"os"
"sync/atomic"
"time"
- "unicode"
sync "github.com/sasha-s/go-deadlock"
- "maunium.net/go/gomuks/ui/messages"
- "maunium.net/go/mautrix/id"
"maunium.net/go/mauview"
"maunium.net/go/tcell"
+ "maunium.net/go/gomuks/ui/messages"
+ "maunium.net/go/mautrix/id"
+
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/interface"
@@ -139,18 +139,6 @@ func (view *MainView) InputChanged(roomView *RoomView, text string) {
}
}
-func findWordToTabComplete(text string) string {
- output := ""
- runes := []rune(text)
- for i := len(runes) - 1; i >= 0; i-- {
- if unicode.IsSpace(runes[i]) {
- break
- }
- output = string(runes[i]) + output
- }
- return output
-}
-
func (view *MainView) ShowBare(roomView *RoomView) {
if roomView == nil {
return