support for contacts, multiple chat threads, and persistence (#77)
Co-authored-by: James Mills <prologic@shortcircuit.net.au> Co-authored-by: James Mills <james@mills.io> Co-authored-by: mlctrez <mlctrez@gmail.com> Reviewed-on: https://git.mills.io/saltyim/saltyim/pulls/77 Co-authored-by: mlctrez <mlctrez@noreply@mills.io> Co-committed-by: mlctrez <mlctrez@noreply@mills.io>
This commit is contained in:
parent
3a82188a5b
commit
969a263d06
|
@ -3,7 +3,6 @@
|
|||
*.bak
|
||||
*.key
|
||||
*.swp
|
||||
*.wasm
|
||||
|
||||
**/.envrc
|
||||
**/.DS_Store
|
||||
|
|
16
Makefile
16
Makefile
|
@ -36,6 +36,14 @@ dev : build ## Build debug versions of the cli and server
|
|||
@./salty-chat -v
|
||||
@./saltyd -v
|
||||
|
||||
pwa-dev : DEBUG=1
|
||||
pwa-dev : build ## Build debug version of saltyd and PWA
|
||||
@CGO_ENABLED=1 $(GOCMD) build -tags "embed" ./cmd/saltyd/...
|
||||
@./saltyd -D -b :https -u https://salty.home.arpa \
|
||||
--tls --tls-key ./certs/salty.home.arpa/key.pem \
|
||||
--tls-cert ./certs/salty.home.arpa/cert.pem \
|
||||
--svc-user salty@salty.home.arpa
|
||||
|
||||
cli: ## Build the salty-chat command-line client and tui
|
||||
@$(GOCMD) build -tags "netgo static_build" -installsuffix netgo \
|
||||
-ldflags "-w \
|
||||
|
@ -57,9 +65,13 @@ generate: ## Genereate any code required by the build
|
|||
echo 'Running in debug mode...'; \
|
||||
fi
|
||||
|
||||
pwa:
|
||||
PWA_SRCS = $(shell find ./internal/pwa -type f)
|
||||
|
||||
internal/web/app.wasm: $(PWA_SRCS)
|
||||
@GOARCH=wasm GOOS=js $(GOCMD) build -o ./internal/web/app.wasm ./internal/pwa/
|
||||
|
||||
pwa: internal/web/app.wasm
|
||||
|
||||
install: build ## Install salty-chat (cli) and saltyd (server) to $DESTDIR
|
||||
@install -D -m 755 salty-chat $(DESTDIR)/salty-chat
|
||||
@install -D -m 755 saltyd $(DESTDIR)/saltyd
|
||||
|
@ -87,7 +99,7 @@ coverage: ## Get test coverage report
|
|||
@$(GOCMD) tool cover -html=coverage.out
|
||||
|
||||
clean: ## Remove untracked files
|
||||
@git clean -f -d
|
||||
@git clean -f -d -x -e certs
|
||||
|
||||
clean-all: ## Remove untracked and Git ignores files
|
||||
@git clean -f -d -X
|
||||
|
|
213
client.go
213
client.go
|
@ -3,6 +3,7 @@ package saltyim
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
@ -18,12 +19,47 @@ import (
|
|||
"go.mills.io/saltyim/internal/exec"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultEnvPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
ServiceUser = "salty"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoMessages = errors.New("error: no messages found")
|
||||
)
|
||||
|
||||
type addrCache map[string]*Addr
|
||||
|
||||
type Message struct {
|
||||
Text string
|
||||
Key *keys.EdX25519PublicKey
|
||||
}
|
||||
|
||||
// TODO: Support shell quoting and escapes?
|
||||
func parseExtraEnvs(extraenvs string) map[string]string {
|
||||
env := make(map[string]string)
|
||||
for _, extraenv := range strings.Split(extraenvs, " ") {
|
||||
tokens := strings.SplitN(extraenv, "=", 2)
|
||||
switch len(tokens) {
|
||||
case 1:
|
||||
env[tokens[0]] = ""
|
||||
case 2:
|
||||
env[tokens[0]] = tokens[1]
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// PackMessage formts an outoing message in the Message Format
|
||||
// <timestamp>\t(<sender>) <message>
|
||||
func PackMessage(me *Addr, msg string) []byte {
|
||||
return []byte(fmt.Sprint(time.Now().UTC().Format(time.RFC3339), "\t", me.Formatted(), "\t", strings.TrimSpace(msg), "\n"))
|
||||
return []byte(
|
||||
fmt.Sprint(
|
||||
time.Now().UTC().Format(time.RFC3339), "\t",
|
||||
me.Formatted(), "\t",
|
||||
strings.TrimSpace(msg), "\n",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Send sends the encrypted message `msg` to the Endpoint `endpoint` using a
|
||||
|
@ -41,45 +77,39 @@ func Send(endpoint, msg string) error {
|
|||
// and Sedngina and Receiving messages to/from Salty IM Users.
|
||||
type Client struct {
|
||||
me *Addr
|
||||
id *Identity
|
||||
key *keys.EdX25519Key
|
||||
cache addrCache
|
||||
}
|
||||
|
||||
func (c *Client) String() string {
|
||||
b := &bytes.Buffer{}
|
||||
fmt.Fprintln(b, "Me: ", c.me)
|
||||
fmt.Fprintln(b, "Endpoint: ", c.me.Endpoint())
|
||||
fmt.Fprintln(b, "Key: ", c.key)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// NewClient reeturns a new Salty IM client for sending and receiving
|
||||
// encrypted messages to other Salty IM users as well as decrypting
|
||||
// and displaying messages of the user's own inbox.
|
||||
func NewClient(me *Addr, options ...IdentityOption) (*Client, error) {
|
||||
ident, err := GetIdentity(options...)
|
||||
id, err := GetIdentity(options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening identity %s: %w", ident.Source(), err)
|
||||
return nil, fmt.Errorf("error opening identity %s: %w", id.Source(), err)
|
||||
}
|
||||
if me == nil || me.IsZero() {
|
||||
me = ident.addr
|
||||
me = id.addr
|
||||
}
|
||||
|
||||
if me == nil || me.IsZero() {
|
||||
return nil, fmt.Errorf("unable to find your user addressn in %s", ident.Source())
|
||||
return nil, fmt.Errorf("unable to find your user addressn in %s", id.Source())
|
||||
}
|
||||
|
||||
if err := me.Refresh(); err != nil {
|
||||
return nil, fmt.Errorf("error looking up user endpoint %s: %w", me.HashURI(), err)
|
||||
log.WithError(err).Warn("error looking up user endpoint")
|
||||
}
|
||||
|
||||
log.Debugf("Using identity %s with public key %s", ident.Source(), ident.key)
|
||||
log.Debugf("Using identity %s with public key %s", id.Source(), id.key)
|
||||
log.Debugf("Salty Addr is %s", me)
|
||||
log.Debugf("Endpoint is %s", me.Endpoint())
|
||||
|
||||
return &Client{
|
||||
me: me,
|
||||
key: ident.key,
|
||||
id: id,
|
||||
key: id.key,
|
||||
cache: make(addrCache),
|
||||
}, nil
|
||||
}
|
||||
|
@ -100,36 +130,44 @@ func (cli *Client) getAddr(user string) (*Addr, error) {
|
|||
return addr, nil
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Text string
|
||||
Key *keys.EdX25519PublicKey
|
||||
}
|
||||
|
||||
func (cli *Client) handleMessage(prehook, posthook string, msgs chan Message) msgbus.HandlerFunc {
|
||||
return func(msg *msgbus.Message) error {
|
||||
if prehook != "" {
|
||||
out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, prehook, bytes.NewBuffer(msg.Payload))
|
||||
if err != nil {
|
||||
log.WithError(err).Debugf("error running pre-hook %s", prehook)
|
||||
}
|
||||
log.Debugf("pre-hook: %q", out)
|
||||
}
|
||||
|
||||
data, senderKey, err := salty.Decrypt(cli.key, msg.Payload)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error decrypting message")
|
||||
return err
|
||||
}
|
||||
|
||||
msgs <- Message{Text: string(data), Key: senderKey}
|
||||
func (cli *Client) processMessage(msg *msgbus.Message, extraenvs, prehook, posthook string) (Message, error) {
|
||||
var data []byte
|
||||
|
||||
defer func() {
|
||||
if posthook != "" {
|
||||
out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, posthook, bytes.NewBuffer(data))
|
||||
out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, cli.Env(extraenvs), posthook, bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
log.WithError(err).Debugf("error running post-hook %s", posthook)
|
||||
}
|
||||
log.Debugf("post-hook: %q", out)
|
||||
}
|
||||
}()
|
||||
|
||||
if prehook != "" {
|
||||
out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, cli.Env(extraenvs), prehook, bytes.NewBuffer(msg.Payload))
|
||||
if err != nil {
|
||||
log.WithError(err).Debugf("error running pre-hook %s", prehook)
|
||||
}
|
||||
log.Debugf("pre-hook: %q", out)
|
||||
}
|
||||
|
||||
unencrypted, senderKey, err := salty.Decrypt(cli.key, msg.Payload)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("error decrypting message: %w", err)
|
||||
}
|
||||
data = unencrypted[:]
|
||||
|
||||
return Message{Text: string(data), Key: senderKey}, nil
|
||||
}
|
||||
|
||||
func (cli *Client) messageHandler(extraenvs, prehook, posthook string, msgs chan Message) msgbus.HandlerFunc {
|
||||
return func(msg *msgbus.Message) error {
|
||||
message, err := cli.processMessage(msg, extraenvs, prehook, posthook)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error processing message: %w", err)
|
||||
}
|
||||
|
||||
msgs <- message
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -138,13 +176,104 @@ func (cli *Client) handleMessage(prehook, posthook string, msgs chan Message) ms
|
|||
func (cli *Client) Me() *Addr { return cli.me }
|
||||
func (cli *Client) Key() *keys.EdX25519PublicKey { return cli.key.PublicKey() }
|
||||
|
||||
// Read subscribers to this user's inbox for new messages
|
||||
func (cli *Client) Read(ctx context.Context, prehook, posthook string) chan Message {
|
||||
func (cli *Client) Env(extraenvs string) []string {
|
||||
Path := DefaultEnvPath
|
||||
GoPath := os.Getenv("GOPATH")
|
||||
if GoPath != "" {
|
||||
Path = fmt.Sprintf("%s/bin:%s", GoPath, Path)
|
||||
}
|
||||
|
||||
env := []string{
|
||||
fmt.Sprintf("PATH=%s", Path),
|
||||
fmt.Sprintf("PWD=%s", os.Getenv("PWD")),
|
||||
fmt.Sprintf("HOME=%s", os.Getenv("HOME")),
|
||||
|
||||
fmt.Sprintf("SALTY_USER=%s", cli.me.String()),
|
||||
fmt.Sprintf("SALTY_IDENTITY=%s", cli.id.Source()),
|
||||
}
|
||||
|
||||
for key, val := range parseExtraEnvs(extraenvs) {
|
||||
log.Debugf("key: %q", key)
|
||||
log.Debugf("val: %q", val)
|
||||
val = os.ExpandEnv(val)
|
||||
if val == "" {
|
||||
val = os.Getenv(key)
|
||||
}
|
||||
if val != "" {
|
||||
env = append(env, fmt.Sprintf("%s=%s", key, val))
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("env: #%v", env)
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
func (cli *Client) String() string {
|
||||
b := &bytes.Buffer{}
|
||||
fmt.Fprintln(b, "Me: ", cli.me)
|
||||
fmt.Fprintln(b, "Endpoint: ", cli.me.Endpoint())
|
||||
fmt.Fprintln(b, "Key: ", cli.key)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Drain drains this user's inbox by simulteneiously reading until empty anda
|
||||
// subscribing to the inbox for new messages.
|
||||
func (cli *Client) Drain(ctx context.Context, extraenvs, prehook, posthook string) chan Message {
|
||||
msgs := make(chan Message)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
msg, err := cli.Read(extraenvs, prehook, posthook)
|
||||
if err != nil {
|
||||
if err == ErrNoMessages {
|
||||
break
|
||||
}
|
||||
log.WithError(err).Warn("error reading inbox")
|
||||
} else {
|
||||
msgs <- msg
|
||||
}
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for msg := range cli.Subscribe(ctx, extraenvs, prehook, posthook) {
|
||||
msgs <- msg
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(msgs)
|
||||
}()
|
||||
|
||||
return msgs
|
||||
}
|
||||
|
||||
// Read reads a single message from this user's inbox
|
||||
func (cli *Client) Read(extraenvs, prehook, posthook string) (Message, error) {
|
||||
uri, inbox := SplitInbox(cli.me.Endpoint().String())
|
||||
bus := msgbus_client.NewClient(uri, nil)
|
||||
|
||||
msg, err := bus.Pull(inbox)
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("error reading inbox: %w", err)
|
||||
}
|
||||
if msg == nil {
|
||||
return Message{}, ErrNoMessages
|
||||
}
|
||||
|
||||
return cli.processMessage(msg, extraenvs, prehook, posthook)
|
||||
}
|
||||
|
||||
// Subscribe subscribers to this user's inbox for new messages
|
||||
func (cli *Client) Subscribe(ctx context.Context, extraenvs, prehook, posthook string) chan Message {
|
||||
uri, inbox := SplitInbox(cli.me.Endpoint().String())
|
||||
bus := msgbus_client.NewClient(uri, nil)
|
||||
|
||||
msgs := make(chan Message)
|
||||
s := bus.Subscribe(inbox, cli.handleMessage(prehook, posthook, msgs))
|
||||
s := bus.Subscribe(inbox, cli.messageHandler(extraenvs, prehook, posthook, msgs))
|
||||
s.Start()
|
||||
|
||||
log.Debugf("Connected to %s/%s", uri, inbox)
|
||||
|
|
|
@ -40,6 +40,16 @@ not specified defaults to the local user ($USER)`,
|
|||
}
|
||||
// XXX: What if me.IsZero()
|
||||
|
||||
follow, err := cmd.Flags().GetBool("follow")
|
||||
if err != nil {
|
||||
log.Fatal("error getting -f--follow flag")
|
||||
}
|
||||
|
||||
extraenvs, err := cmd.Flags().GetString("extra-envs")
|
||||
if err != nil {
|
||||
log.Fatal("error getting --extra-envs flag")
|
||||
}
|
||||
|
||||
prehook, err := cmd.Flags().GetString("pre-hook")
|
||||
if err != nil {
|
||||
log.Fatal("error getting --pre-hook flag")
|
||||
|
@ -50,7 +60,7 @@ not specified defaults to the local user ($USER)`,
|
|||
log.Fatal("error getting --post-hook flag")
|
||||
}
|
||||
|
||||
read(me, identity, prehook, posthook, args...)
|
||||
read(me, identity, follow, extraenvs, prehook, posthook, args...)
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -62,6 +72,16 @@ type profile struct {
|
|||
func init() {
|
||||
rootCmd.AddCommand(readCmd)
|
||||
|
||||
readCmd.Flags().BoolP(
|
||||
"follow", "f", false,
|
||||
"Subscribe to the inbox and follow all messages",
|
||||
)
|
||||
|
||||
readCmd.Flags().String(
|
||||
"extra-envs", "",
|
||||
"List of extra env vars to pass to pre/post hooks (KEY=[VALUE] ...)",
|
||||
)
|
||||
|
||||
readCmd.Flags().String(
|
||||
"pre-hook", "",
|
||||
"Execute pre-hook before message decryption",
|
||||
|
@ -73,7 +93,7 @@ func init() {
|
|||
)
|
||||
}
|
||||
|
||||
func read(me *saltyim.Addr, identity string, prehook, posthook string, args ...string) {
|
||||
func read(me *saltyim.Addr, identity string, follow bool, extraenvs, prehook, posthook string, args ...string) {
|
||||
cli, err := saltyim.NewClient(me, saltyim.WithIdentityPath(identity))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
||||
|
@ -91,11 +111,27 @@ func read(me *saltyim.Addr, identity string, prehook, posthook string, args ...s
|
|||
cancel()
|
||||
}()
|
||||
|
||||
for msg := range cli.Read(ctx, prehook, posthook) {
|
||||
if follow {
|
||||
for msg := range cli.Drain(ctx, extraenvs, prehook, posthook) {
|
||||
if term.IsTerminal(syscall.Stdin) {
|
||||
fmt.Println(saltyim.FormatMessage(msg.Text))
|
||||
} else {
|
||||
fmt.Println(msg.Text)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
msg, err := cli.Read(extraenvs, prehook, posthook)
|
||||
if err != nil {
|
||||
if err == saltyim.ErrNoMessages {
|
||||
os.Exit(0)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "error reading message: %s\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if term.IsTerminal(syscall.Stdin) {
|
||||
fmt.Println(saltyim.FormatMessage(msg.Text))
|
||||
} else {
|
||||
fmt.Println(msg)
|
||||
fmt.Println(msg.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
3
go.mod
3
go.mod
|
@ -7,6 +7,7 @@ go 1.17
|
|||
//)
|
||||
|
||||
require (
|
||||
github.com/avast/retry-go v2.7.0+incompatible
|
||||
github.com/likexian/doh-go v0.6.4
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/mlctrez/goapp-mdc v0.2.6
|
||||
|
@ -79,7 +80,7 @@ require (
|
|||
|
||||
require (
|
||||
git.mills.io/prologic/bitcask v1.0.2
|
||||
git.mills.io/prologic/msgbus v0.1.10
|
||||
git.mills.io/prologic/msgbus v0.1.12
|
||||
git.mills.io/prologic/observe v0.0.0-20210712230028-fc31c7aa2bd1
|
||||
git.mills.io/prologic/useragent v0.0.0-20210714100044-d249fe7921a0
|
||||
github.com/NYTimes/gziphandler v1.1.1
|
||||
|
|
14
go.sum
14
go.sum
|
@ -51,14 +51,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
|
|||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
git.mills.io/prologic/bitcask v1.0.2 h1:Iy9x3mVVd1fB+SWY0LTmsSDPGbzMrd7zCZPKbsb/tDA=
|
||||
git.mills.io/prologic/bitcask v1.0.2/go.mod h1:ppXpR3haeYrijyJDleAkSGH3p90w6sIHxEA/7UHMxH4=
|
||||
git.mills.io/prologic/msgbus v0.1.9-0.20220325123528-9e1d03846ecd h1:HyqAOVMpDIl+wylV1K8FySY9RZZaDL+bQNbqCISypms=
|
||||
git.mills.io/prologic/msgbus v0.1.9-0.20220325123528-9e1d03846ecd/go.mod h1:3HKT07iPSoi77CC3TpukUU5rkUErHcXThVHeYOej5kI=
|
||||
git.mills.io/prologic/msgbus v0.1.9-0.20220326234253-3502f7b24292 h1:4WDEWtE5gCJmzE3z5HJ2h4u+1pZ4oLkTEw26ld7EmFU=
|
||||
git.mills.io/prologic/msgbus v0.1.9-0.20220326234253-3502f7b24292/go.mod h1:2YmGBm9WJjfMTBki/PuD5eG0CUULXesaV6kpVF/jJ2g=
|
||||
git.mills.io/prologic/msgbus v0.1.9 h1:OIPW1B47wtoGwzYHPo9LQS8EwrDOVWDcn56VjtFwdGU=
|
||||
git.mills.io/prologic/msgbus v0.1.9/go.mod h1:3HKT07iPSoi77CC3TpukUU5rkUErHcXThVHeYOej5kI=
|
||||
git.mills.io/prologic/msgbus v0.1.10 h1:g9H7ea1lt1uHg6z43d4TaMjLaC2Ww4QzylZF0r128XI=
|
||||
git.mills.io/prologic/msgbus v0.1.10/go.mod h1:2YmGBm9WJjfMTBki/PuD5eG0CUULXesaV6kpVF/jJ2g=
|
||||
git.mills.io/prologic/msgbus v0.1.12 h1:EWK5GEJvi/H2Yt4k+FtpUzA2uw5P9u21LImUrW+0KV4=
|
||||
git.mills.io/prologic/msgbus v0.1.12/go.mod h1:2YmGBm9WJjfMTBki/PuD5eG0CUULXesaV6kpVF/jJ2g=
|
||||
git.mills.io/prologic/observe v0.0.0-20210712230028-fc31c7aa2bd1 h1:e6ZyAOFGLZJZYL2galNvfuNMqeQDdilmQ5WRBXCNL5s=
|
||||
git.mills.io/prologic/observe v0.0.0-20210712230028-fc31c7aa2bd1/go.mod h1:/rNXqsTHGrilgNJYH/8wsIRDScyxXUhpbSdNbBatAKY=
|
||||
git.mills.io/prologic/useragent v0.0.0-20210714100044-d249fe7921a0 h1:MojWEgZyiugUbgyjydrdSAkHlADnbt90dXyURRYFzQ4=
|
||||
|
@ -85,6 +79,8 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV
|
|||
github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/avast/retry-go v2.7.0+incompatible h1:XaGnzl7gESAideSjr+I8Hki/JBi+Yb9baHlMRPeSC84=
|
||||
github.com/avast/retry-go v2.7.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/badgerodon/ioutil v0.0.0-20150716134133-06e58e34b867 h1:nsDNoesoGwPzPkcrR1w1uzPUtiqwCXoNnkWC7nUuRHI=
|
||||
github.com/badgerodon/ioutil v0.0.0-20150716134133-06e58e34b867/go.mod h1:Ctq1YQi0dOq7QgBLZZ7p1Fr3IbAAqL/yMqDIHoe9WtE=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
|
@ -366,7 +362,6 @@ github.com/keys-pub/secretservice v0.0.0-20200519003656-26e44b8df47f/go.mod h1:Y
|
|||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
|
||||
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.15.1 h1:y9FcTHGyrebwfP0ZZqFiaxTaiDnUrGkJkI+f583BL1A=
|
||||
github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
|
@ -860,7 +855,6 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs=
|
||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220325203850-36772127a21f h1:TrmogKRsSOxRMJbLYGrB4SBbW+LJcEllYBLME5Zk5pU=
|
||||
golang.org/x/sys v0.0.0-20220325203850-36772127a21f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
|
@ -4,17 +4,11 @@
|
|||
#
|
||||
# Setup:
|
||||
#
|
||||
# $ salty-chat -i ~/.config/salty/echobot.key -u echo@yourdomain.tld make-user
|
||||
# $ salty-chat -i ~/.config/salty/echobot.key -u echo@yourdomain.tld read --post-hook ./echobot.sh
|
||||
# $ salty-chat -i ~/.config/salty/echo.key -u echo@yourdomain.tld make-user
|
||||
# $ salty-chat -i ~/.config/salty/echo.key -u echo@yourdomain.tld read --post-hook ./hooks/echobot.sh
|
||||
|
||||
set -e
|
||||
|
||||
# XXX: Set this to the echobot's key
|
||||
identity=
|
||||
|
||||
# XXX: Set this to the echobot's addr
|
||||
user=
|
||||
|
||||
tmpfile="$(mktemp -t "echobot-XXXXXX")"
|
||||
trap 'rm $tmpfile' EXIT
|
||||
|
||||
|
@ -24,4 +18,4 @@ sender="$(head -n 1 < "$tmpfile" | awk '{ print $2 }')"
|
|||
sender="$(echo "$sender" | sed 's/[)(]//g')"
|
||||
message="$(head -n 1 < "$tmpfile" | awk '{ $1 = ""; $2 = ""; print $0; }')"
|
||||
|
||||
echo "$message" | salty-chat -d -i "$identity" -u "$user" send "$sender"
|
||||
echo "$message" | salty-chat send "$sender"
|
||||
|
|
|
@ -5,13 +5,16 @@ if ! command -v pushover-cli > /dev/null; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
echo "PUSHOVER_CLI_API: $PUSHOVER_CLI_API"
|
||||
echo "PUSHOVER_CLI_USER: $PUSHOVER_CLI_USER"
|
||||
|
||||
if [ -z "$PUSHOVER_CLI_API" ]; then
|
||||
echo "$$PUSHOVER_CLI_API not set"
|
||||
echo "PUSHOVER_CLI_API not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$PUSHOVER_CLI_USER" ]; then
|
||||
echo "$$PUSHOVER_CLI_USER not set"
|
||||
echo "PUSHOVER_CLI_USER not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ const (
|
|||
|
||||
// RunCmd executes the given command and arguments and stdin and ensures the
|
||||
// command takes no longer than the timeout before the command is terminated.
|
||||
func RunCmd(timeout time.Duration, command string, stdin io.Reader, args ...string) (string, error) {
|
||||
func RunCmd(timeout time.Duration, env []string, command string, stdin io.Reader, args ...string) (string, error) {
|
||||
var (
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
@ -28,6 +28,7 @@ func RunCmd(timeout time.Duration, command string, stdin io.Reader, args ...stri
|
|||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, command, args...)
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
cmd.Stdin = stdin
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
@ -40,7 +41,7 @@ func RunCmd(timeout time.Duration, command string, stdin io.Reader, args ...stri
|
|||
}
|
||||
}
|
||||
|
||||
return "", err
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
return string(out), nil
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/base"
|
||||
"go.mills.io/saltyim/internal/pwa/storage"
|
||||
)
|
||||
|
||||
type ChatBox struct {
|
||||
app.Compo
|
||||
base.JsUtil
|
||||
// User is who we're conversing with
|
||||
User string
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (c *ChatBox) OnMount(ctx app.Context) {
|
||||
ctx.Handle("chatbox", c.actionHandler)
|
||||
if c.User != "" {
|
||||
c.messages = storage.ConversationsLocalStorage(ctx, c.User).Read()
|
||||
}
|
||||
ctx.Defer(func(context app.Context) {
|
||||
c.Update()
|
||||
c.scrollChatPane(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// actionHandler receives messages intended to update this component
|
||||
func (c *ChatBox) actionHandler(ctx app.Context, action app.Action) {
|
||||
c.User = action.Tags.Get("user")
|
||||
c.messages = storage.ConversationsLocalStorage(ctx, c.User).Read()
|
||||
c.Update()
|
||||
}
|
||||
|
||||
func (c *ChatBox) UpdateMessages(ctx app.Context) {
|
||||
if c.User == "" {
|
||||
return
|
||||
}
|
||||
ctx.NewAction("chatbox", app.T("user", c.User))
|
||||
c.scrollChatPane(ctx)
|
||||
}
|
||||
|
||||
func (c *ChatBox) Render() app.UI {
|
||||
return app.Div().ID("chatbox").Body(c.body())
|
||||
}
|
||||
|
||||
func (c *ChatBox) body() app.UI {
|
||||
if len(c.messages) == 0 {
|
||||
return app.P().Text(fmt.Sprintf("no messages for user = %q", c.User))
|
||||
} else {
|
||||
return app.Range(c.messages).Slice(func(i int) app.UI {
|
||||
return app.P().Class("chat-paragraph").Text(c.messages[i])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ChatBox) OnResize(ctx app.Context) {
|
||||
c.scrollChatPane(ctx)
|
||||
}
|
||||
|
||||
func (c *ChatBox) scrollChatPane(ctx app.Context) {
|
||||
ctx.Defer(func(context app.Context) {
|
||||
chatBoxDiv := c.JsUtil.JsValueAtPath("chatbox")
|
||||
chatBoxDiv.Set("scrollTop", chatBoxDiv.Get("scrollHeight"))
|
||||
})
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/bar"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/base"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/button"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/icon"
|
||||
|
@ -17,6 +17,8 @@ type Configuration struct {
|
|||
app.Compo
|
||||
base.JsUtil
|
||||
|
||||
navigation *Navigation
|
||||
|
||||
// components
|
||||
user *textfield.TextField
|
||||
identity *textarea.TextArea
|
||||
|
@ -41,27 +43,48 @@ func (c *Configuration) OnMount(ctx app.Context) {
|
|||
}
|
||||
|
||||
func (c *Configuration) Render() app.UI {
|
||||
fmt.Println("render")
|
||||
|
||||
topBar := &bar.TopAppBar{Title: "Salty IM",
|
||||
Navigation: []app.HTMLButton{icon.MIMenu.Button().OnClick(func(ctx app.Context, e app.Event) {
|
||||
c.navigation.drawer.ActionOpen(ctx)
|
||||
})},
|
||||
Fixed: true,
|
||||
ScrollTarget: "main-content",
|
||||
Actions: c.topActions(),
|
||||
}
|
||||
|
||||
if c.user == nil {
|
||||
c.user = &textfield.TextField{Id: "config-user", Label: "User in the form user@domain"}
|
||||
c.identity = textarea.New("identity").Size(5, 100).Label("identity").MaxLength(1024)
|
||||
c.identity.WithCallback(func(in app.HTMLTextarea) {
|
||||
in.OnChange(c.identity.ValueTo(&c.identity.Value))
|
||||
})
|
||||
c.navigation = &Navigation{}
|
||||
}
|
||||
|
||||
return app.Div().Body(
|
||||
&AppUpdateBanner{},
|
||||
app.H4().Text("configuration"),
|
||||
c.user,
|
||||
&button.Button{Icon: string(icon.MICreate), Label: "new identity",
|
||||
Outlined: true, Raised: true, Callback: c.newIdentity()},
|
||||
app.Br(),
|
||||
c.identity,
|
||||
&button.Button{Icon: string(icon.MIUpdate), Label: "update identity",
|
||||
Outlined: true, Raised: true, Callback: c.updateIdentity()},
|
||||
app.Hr(),
|
||||
c.navigation,
|
||||
app.Div().Class("mdc-drawer-app-content").Body(
|
||||
&AppUpdateBanner{},
|
||||
topBar,
|
||||
app.Div().Class("main-content").ID("main-content").Body(
|
||||
topBar.Main().Body(
|
||||
app.Div().ID("wrapper").Body(
|
||||
app.H4().Text("configuration"),
|
||||
c.user,
|
||||
&button.Button{Icon: string(icon.MICreate), Label: "new identity",
|
||||
Outlined: true, Raised: true, Callback: c.newIdentity()},
|
||||
app.Br(),
|
||||
c.identity,
|
||||
&button.Button{Icon: string(icon.MIUpdate), Label: "update identity",
|
||||
Outlined: true, Raised: true, Callback: c.updateIdentity()},
|
||||
app.Hr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func (c *Configuration) newIdentity() func(button app.HTMLButton) {
|
||||
|
@ -107,3 +130,10 @@ func (c *Configuration) updateIdentity() func(button app.HTMLButton) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Configuration) topActions() (actions []app.HTMLButton) {
|
||||
actions = append(actions, icon.MIRefresh.Button().Title("reload").
|
||||
OnClick(func(ctx app.Context, e app.Event) { ctx.Reload() }))
|
||||
|
||||
return actions
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/button"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/dialog"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type ModalDialog struct {
|
||||
app.Compo
|
||||
dialog *dialog.Dialog
|
||||
}
|
||||
|
||||
func (m *ModalDialog) Render() app.UI {
|
||||
if m.dialog == nil {
|
||||
m.dialog = &dialog.Dialog{Id: "notification-dialog"}
|
||||
m.dialog.Title = []app.UI{app.Div().Text("Error")}
|
||||
m.dialog.Content = []app.UI{}
|
||||
m.dialog.Buttons = []app.UI{
|
||||
&button.Button{Id: "notification-dialog-dismiss",
|
||||
Dialog: true, DialogAction: "dismiss", Label: "dismiss"},
|
||||
}
|
||||
}
|
||||
return m.dialog
|
||||
}
|
||||
|
||||
func (m *ModalDialog) ShowDialog(msg ...string) {
|
||||
if m.dialog == nil {
|
||||
log.Debug("ModalDialog.dialog is nil, unable to display message", msg)
|
||||
return
|
||||
}
|
||||
m.dialog.Content = []app.UI{}
|
||||
for _, s := range msg {
|
||||
m.dialog.Content = append(m.dialog.Content, app.Div().Text(s))
|
||||
}
|
||||
m.dialog.Update()
|
||||
m.dialog.Open()
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/drawer"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/icon"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/list"
|
||||
"go.mills.io/saltyim/internal/pwa/storage"
|
||||
)
|
||||
|
||||
type Navigation struct {
|
||||
app.Compo
|
||||
drawer *drawer.Drawer
|
||||
items list.Items
|
||||
Contacts []string
|
||||
}
|
||||
|
||||
func (n *Navigation) Render() app.UI {
|
||||
if n.drawer == nil {
|
||||
n.drawer = &drawer.Drawer{
|
||||
Type: drawer.Dismissible,
|
||||
Id: "navigationDrawer",
|
||||
List: &list.List{Type: list.Navigation, Id: "navigationList"},
|
||||
}
|
||||
}
|
||||
|
||||
items := list.Items{
|
||||
{Type: list.ItemTypeAnchor, Graphic: icon.MISettings, Href: "/config", Text: "settings"},
|
||||
{Type: list.ItemTypeAnchor, Graphic: icon.MIPersonAdd, Href: "/newchat", Text: "new chat"},
|
||||
{Type: list.ItemTypeDivider},
|
||||
}
|
||||
|
||||
for _, contact := range n.Contacts {
|
||||
i := &list.Item{Type: list.ItemTypeAnchor, Graphic: icon.MIPerson, Href: "/#" + contact, Text: contact}
|
||||
items = append(items, i)
|
||||
}
|
||||
|
||||
n.drawer.List.Items = items.UIList()
|
||||
|
||||
return n.drawer
|
||||
}
|
||||
|
||||
func (n *Navigation) OnMount(ctx app.Context) {
|
||||
n.items.SelectHref(ctx.Page().URL().Path)
|
||||
n.Contacts = storage.ContactsLocalStorage(ctx).List()
|
||||
ctx.Handle(string(list.Select), func(context app.Context, action app.Action) {
|
||||
if action.Value == n.drawer.List {
|
||||
n.drawer.ActionClose(context)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/bar"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/base"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/button"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/icon"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/textfield"
|
||||
"go.mills.io/saltyim"
|
||||
)
|
||||
|
||||
type NewChat struct {
|
||||
app.Compo
|
||||
base.JsUtil
|
||||
|
||||
navigation *Navigation
|
||||
dialog *ModalDialog
|
||||
|
||||
user *textfield.TextField
|
||||
}
|
||||
|
||||
func (n *NewChat) Render() app.UI {
|
||||
|
||||
topBar := &bar.TopAppBar{Title: "Salty IM",
|
||||
Navigation: []app.HTMLButton{icon.MIMenu.Button().OnClick(func(ctx app.Context, e app.Event) {
|
||||
n.navigation.drawer.ActionOpen(ctx)
|
||||
})},
|
||||
Fixed: true,
|
||||
ScrollTarget: "main-content",
|
||||
Actions: n.topActions(),
|
||||
}
|
||||
|
||||
if n.user == nil {
|
||||
n.user = &textfield.TextField{Id: "add-user", Label: "Start Chat with user@domain"}
|
||||
n.dialog = &ModalDialog{}
|
||||
n.navigation = &Navigation{}
|
||||
}
|
||||
|
||||
return app.Div().Body(
|
||||
n.navigation,
|
||||
app.Div().Class("mdc-drawer-app-content").Body(
|
||||
&AppUpdateBanner{},
|
||||
topBar,
|
||||
app.Div().Class("main-content").ID("main-content").Body(
|
||||
topBar.Main().Body(
|
||||
app.Div().ID("wrapper").Body(
|
||||
app.H4().Text("new chat"),
|
||||
n.user,
|
||||
&button.Button{Icon: string(icon.MICreate), Label: "new chat",
|
||||
Outlined: true, Raised: true, Callback: n.newChat()},
|
||||
n.dialog,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (n *NewChat) newChat() func(button app.HTMLButton) {
|
||||
return func(button app.HTMLButton) {
|
||||
button.OnClick(func(ctx app.Context, e app.Event) {
|
||||
addr, err := saltyim.LookupAddr(n.user.Value)
|
||||
if err != nil {
|
||||
n.dialog.ShowDialog("error", err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Navigate("/#" + addr.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NewChat) topActions() (actions []app.HTMLButton) {
|
||||
actions = append(actions, icon.MIRefresh.Button().Title("reload").
|
||||
OnClick(func(ctx app.Context, e app.Event) { ctx.Reload() }))
|
||||
|
||||
return actions
|
||||
}
|
|
@ -3,37 +3,36 @@ package components
|
|||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/bar"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/base"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/button"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/dialog"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/icon"
|
||||
"github.com/mlctrez/goapp-mdc/pkg/textfield"
|
||||
"go.mills.io/saltyim"
|
||||
"go.mills.io/saltyim/internal/pwa/storage"
|
||||
"go.yarn.social/lextwt"
|
||||
)
|
||||
|
||||
const (
|
||||
menuWidth = 282
|
||||
introText = "Hello! Welcome to Salty IM 🧂 This PWA App is not quite ready yet! Come back later 🤞"
|
||||
descText = `salty.im is an open specification for a new Saltpack based e2e encrypted messaging protocol and platform for secure communications with a focus on privacy, security and being self-hosted.`
|
||||
descText = `salty.im is an open specification for a new Saltpack based e2e encrypted messaging protocol and platform for secure communications with a focus on privacy, security and being self-hosted.`
|
||||
)
|
||||
|
||||
var client *saltyim.Client
|
||||
|
||||
// SaltyChat ...
|
||||
type SaltyChat struct {
|
||||
app.Compo
|
||||
base.JsUtil
|
||||
isAppInstallable bool
|
||||
|
||||
dialog *dialog.Dialog
|
||||
navigation *Navigation
|
||||
dialog *ModalDialog
|
||||
chatBox *ChatBox
|
||||
|
||||
messages []string
|
||||
friend *textfield.TextField
|
||||
Friend string
|
||||
chatInput *textfield.TextField
|
||||
|
||||
client *saltyim.Client
|
||||
incoming chan string
|
||||
}
|
||||
|
||||
|
@ -53,93 +52,121 @@ func (h *SaltyChat) OnPreRender(ctx app.Context) {
|
|||
|
||||
func (h *SaltyChat) OnResize(ctx app.Context) {
|
||||
h.ResizeContent()
|
||||
h.scrollChatPane(ctx)
|
||||
h.navigation.drawer.ActionClose(ctx)
|
||||
}
|
||||
|
||||
func (h *SaltyChat) OnAppInstallChange(ctx app.Context) {
|
||||
h.isAppInstallable = ctx.IsAppInstallable()
|
||||
}
|
||||
|
||||
func (h *SaltyChat) OnNav(ctx app.Context) {
|
||||
h.refreshMessages(ctx)
|
||||
}
|
||||
|
||||
func (h *SaltyChat) refreshMessages(ctx app.Context) {
|
||||
|
||||
if ctx.Page().URL().Fragment == "" {
|
||||
return
|
||||
}
|
||||
|
||||
h.Friend = ctx.Page().URL().Fragment
|
||||
h.chatBox.User = h.Friend
|
||||
h.chatBox.UpdateMessages(ctx)
|
||||
|
||||
}
|
||||
|
||||
func (h *SaltyChat) OnMount(ctx app.Context) {
|
||||
|
||||
h.isAppInstallable = ctx.IsAppInstallable()
|
||||
|
||||
h.refreshMessages(ctx)
|
||||
if app.IsClient {
|
||||
h.connect(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SaltyChat) connect(ctx app.Context) {
|
||||
// TODO: how is client affected with mount / unmount / navigation
|
||||
if h.client != nil {
|
||||
|
||||
if client != nil {
|
||||
return
|
||||
}
|
||||
|
||||
identity, err := GetIdentityFromState(ctx)
|
||||
if err != nil {
|
||||
h.showDialog("missing identity, please configure", err.Error())
|
||||
h.dialog.ShowDialog("missing identity, please configure", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
client, err := saltyim.NewClient(identity.Addr(), saltyim.WithIdentityBytes(identity.Contents()))
|
||||
newClient, err := saltyim.NewClient(identity.Addr(), saltyim.WithIdentityBytes(identity.Contents()))
|
||||
if err != nil {
|
||||
h.showDialog("error setting up client", err.Error())
|
||||
h.dialog.ShowDialog("error setting up client", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.client = client
|
||||
client = newClient
|
||||
|
||||
ctx.Async(func() {
|
||||
for msg := range client.Read(context.Background(), "", "") {
|
||||
log.Println("incoming message", msg)
|
||||
ctx.Dispatch(func(ctx app.Context) {
|
||||
h.incomingMessage(ctx, msg.Text)
|
||||
})
|
||||
for msg := range client.Drain(context.Background(), "", "", "") {
|
||||
s, err := lextwt.ParseSalty(msg.Text)
|
||||
if err != nil {
|
||||
log.Println("incoming message error", err)
|
||||
continue
|
||||
}
|
||||
switch s := s.(type) {
|
||||
case *lextwt.SaltyText:
|
||||
user := s.User.String()
|
||||
storage.ConversationsLocalStorage(ctx, user).Append(msg.Text)
|
||||
// only update when incoming user's message is the active chat
|
||||
if h.Friend == user {
|
||||
h.chatBox.UpdateMessages(ctx)
|
||||
} else {
|
||||
// TODO: how to notify message received in background
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SaltyChat) Render() app.UI {
|
||||
|
||||
topBar := &bar.TopAppBar{Title: "Salty IM",
|
||||
Navigation: []app.HTMLButton{icon.MIMenu.Button()},
|
||||
Navigation: []app.HTMLButton{icon.MIMenu.Button().OnClick(func(ctx app.Context, e app.Event) {
|
||||
h.navigation.drawer.ActionOpen(ctx)
|
||||
})},
|
||||
Fixed: true,
|
||||
ScrollTarget: "main-content",
|
||||
Actions: h.topActions(),
|
||||
}
|
||||
|
||||
if h.chatInput == nil {
|
||||
if h.chatBox == nil {
|
||||
h.chatBox = &ChatBox{}
|
||||
h.chatInput = &textfield.TextField{Id: "chat-input", Placeholder: ">"}
|
||||
h.friend = &textfield.TextField{Id: "friend-input", Placeholder: "send-to"}
|
||||
h.dialog = &dialog.Dialog{Id: "notification-dialog"}
|
||||
h.dialog.Title = []app.UI{app.Div().Text("Error")}
|
||||
h.dialog.Content = []app.UI{}
|
||||
h.dialog.Buttons = []app.UI{&button.Button{Id: "notification-dialog-dismiss",
|
||||
Dialog: true, DialogAction: "dismiss", Label: "dismiss"}}
|
||||
//h.friend = &textfield.TextField{Id: "friend-input", Placeholder: "send-to"}
|
||||
h.dialog = &ModalDialog{}
|
||||
h.navigation = &Navigation{}
|
||||
}
|
||||
|
||||
h.chatBox.User = h.Friend
|
||||
|
||||
return app.Div().Body(
|
||||
&AppUpdateBanner{},
|
||||
topBar,
|
||||
h.dialog,
|
||||
app.Div().Class("main-content").ID("main-content").Body(
|
||||
topBar.Main().Body(
|
||||
app.Div().ID("wrapper").Body(
|
||||
h.buildChatList(),
|
||||
app.Form().OnSubmit(h.handleSendMessage).Body(
|
||||
h.chatInput,
|
||||
icon.MISend.Button().ID("chat-send"),
|
||||
h.friend,
|
||||
h.navigation,
|
||||
app.Div().Class("mdc-drawer-app-content").Body(
|
||||
&AppUpdateBanner{},
|
||||
topBar,
|
||||
app.Div().Class("main-content").ID("main-content").Body(
|
||||
topBar.Main().Body(
|
||||
app.Div().ID("wrapper").Body(
|
||||
h.chatBox,
|
||||
app.Form().OnSubmit(h.handleSendMessage).Body(
|
||||
h.chatInput,
|
||||
),
|
||||
h.dialog,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (h *SaltyChat) buildChatList() app.UI {
|
||||
return app.Div().ID("chatbox").Body(
|
||||
app.Range(h.messages).Slice(func(i int) app.UI {
|
||||
return app.P().Class("chat-paragraph").Text(h.messages[i])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (h *SaltyChat) handleSendMessage(ctx app.Context, e app.Event) {
|
||||
|
@ -147,35 +174,35 @@ func (h *SaltyChat) handleSendMessage(ctx app.Context, e app.Event) {
|
|||
msg := h.chatInput.Value
|
||||
h.chatInput.Value = ""
|
||||
h.focusChatInput()
|
||||
if msg == "" {
|
||||
|
||||
//friendAddress := strings.TrimSpace(h.friend.Value)
|
||||
|
||||
if msg == "" || h.Friend == "" {
|
||||
// nothing to send
|
||||
return
|
||||
}
|
||||
|
||||
storage.ContactsLocalStorage(ctx).Add(h.Friend)
|
||||
|
||||
// determine current user to send message to and use client to send the message
|
||||
if h.client != nil {
|
||||
friendAddress := strings.TrimSpace(h.friend.Value)
|
||||
h.friend.Value = friendAddress
|
||||
if _, err := saltyim.LookupAddr(friendAddress); err != nil {
|
||||
h.showDialog("problem with send-to address", err.Error())
|
||||
if client != nil {
|
||||
//h.friend.Value = friendAddress
|
||||
h.chatBox.User = h.Friend
|
||||
h.chatBox.Update()
|
||||
if _, err := saltyim.LookupAddr(h.Friend); err != nil {
|
||||
h.dialog.ShowDialog("problem with send-to address", err.Error())
|
||||
} else {
|
||||
if err := h.client.Send(friendAddress, msg); err == nil {
|
||||
h.incomingMessage(ctx, string(saltyim.PackMessage(h.client.Me(), msg)))
|
||||
if err := client.Send(h.Friend, msg); err == nil {
|
||||
storage.ConversationsLocalStorage(ctx, h.Friend).
|
||||
Append(string(saltyim.PackMessage(client.Me(), msg)))
|
||||
h.chatBox.UpdateMessages(ctx)
|
||||
} else {
|
||||
h.showDialog("error sending message", err.Error())
|
||||
h.dialog.ShowDialog("error sending message", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
func (h *SaltyChat) showDialog(msg ...string) {
|
||||
h.dialog.Content = []app.UI{}
|
||||
for _, s := range msg {
|
||||
h.dialog.Content = append(h.dialog.Content, app.Div().Text(s))
|
||||
}
|
||||
h.dialog.Update()
|
||||
h.dialog.Open()
|
||||
}
|
||||
|
||||
func (h *SaltyChat) focusChatInput() {
|
||||
chatInputValue := h.JsUtil.JsValueAtPath(h.chatInput.Id + "-input")
|
||||
|
@ -183,33 +210,14 @@ func (h *SaltyChat) focusChatInput() {
|
|||
chatInputValue.Call("focus")
|
||||
}
|
||||
|
||||
// incomingMessage adds a new message to the chat window and scrolls the window to the bottom
|
||||
// TODO: better formatting
|
||||
func (h *SaltyChat) incomingMessage(ctx app.Context, msg string) {
|
||||
h.messages = append(h.messages, msg)
|
||||
|
||||
h.scrollChatPane(ctx)
|
||||
}
|
||||
|
||||
func (h *SaltyChat) scrollChatPane(ctx app.Context) {
|
||||
ctx.Defer(func(context app.Context) {
|
||||
chatBoxDiv := h.JsUtil.JsValueAtPath("chatbox")
|
||||
chatBoxDiv.Set("scrollTop", chatBoxDiv.Get("scrollHeight"))
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SaltyChat) topActions() (actions []app.HTMLButton) {
|
||||
if h.isAppInstallable {
|
||||
actions = append(actions, icon.MIDownload.Button().Title("Install PWA").
|
||||
OnClick(func(ctx app.Context, e app.Event) { ctx.ShowAppInstallPrompt() }))
|
||||
}
|
||||
|
||||
actions = append(actions, icon.MIRefresh.Button().Title("reload the page").
|
||||
actions = append(actions, icon.MIRefresh.Button().Title("reload").
|
||||
OnClick(func(ctx app.Context, e app.Event) { ctx.Reload() }))
|
||||
|
||||
actions = append(actions, icon.MISettings.Button().Title("settings").OnClick(func(ctx app.Context, e app.Event) {
|
||||
ctx.Navigate("/config")
|
||||
}))
|
||||
|
||||
return actions
|
||||
}
|
||||
|
|
|
@ -13,26 +13,7 @@ func init() {
|
|||
saltyim.SetResolver(&saltyim.DNSOverHTTPResolver{})
|
||||
}
|
||||
|
||||
// The main function is the entry point where the app is configured and started.
|
||||
// It is executed in 2 different environments: A client (the web browser) and a
|
||||
// server.
|
||||
func main() {
|
||||
// The first thing to do is to associate the hello component with a path.
|
||||
//
|
||||
// This is done by calling the Route() function, which tells go-app what
|
||||
// component to display for a given path, on both client and server-side.
|
||||
routes.AddRoutes()
|
||||
|
||||
// Once the routes set up, the next thing to do is to either launch the app
|
||||
// or the server that serves the app.
|
||||
//
|
||||
// When executed on the client-side, the RunWhenOnBrowser() function
|
||||
// launches the app, starting a loop that listens for app events and
|
||||
// executes client instructions. Since it is a blocking call, the code below
|
||||
// it will never be executed.
|
||||
//
|
||||
// When executed on the server-side, RunWhenOnBrowser() does nothing, which
|
||||
// lets room for server implementation without the need for precompiling
|
||||
// instructions.
|
||||
app.RunWhenOnBrowser()
|
||||
}
|
||||
|
|
|
@ -9,4 +9,5 @@ import (
|
|||
func AddRoutes() {
|
||||
app.Route("/", &components.SaltyChat{})
|
||||
app.Route("/config", &components.Configuration{})
|
||||
app.Route("/newchat", &components.NewChat{})
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
package storage
|
||||
|
||||
import "github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||
|
||||
// Actions defines the action operations
|
||||
type Actions interface {
|
||||
Handle(actionName string, h app.ActionHandler)
|
||||
NewAction(name string, tags ...app.Tagger)
|
||||
NewActionWithValue(name string, v interface{}, tags ...app.Tagger)
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||
)
|
||||
|
||||
const (
|
||||
ContactsKey = "saltyim-contacts"
|
||||
)
|
||||
|
||||
type Contacts interface {
|
||||
Add(addr string)
|
||||
Remove(addr string)
|
||||
List() []string
|
||||
}
|
||||
|
||||
type contacts struct {
|
||||
state StateOperations
|
||||
lock *sync.Mutex
|
||||
}
|
||||
|
||||
func readContacts(state StateOperations) map[string]interface{} {
|
||||
contacts := make(map[string]interface{})
|
||||
state.GetState(ContactsKey, &contacts)
|
||||
return contacts
|
||||
}
|
||||
func updateContacts(state StateOperations, contacts map[string]interface{}) {
|
||||
state.SetState(ContactsKey, contacts, app.Persist, app.Encrypt)
|
||||
}
|
||||
|
||||
func (c *contacts) Add(addr string) {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
contacts := readContacts(c.state)
|
||||
contacts[addr] = true
|
||||
updateContacts(c.state, contacts)
|
||||
}
|
||||
|
||||
func (c *contacts) Remove(addr string) {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
contacts := make(map[string]interface{})
|
||||
c.state.GetState(ContactsKey, &contacts)
|
||||
delete(contacts, addr)
|
||||
updateContacts(c.state, contacts)
|
||||
}
|
||||
|
||||
func (c *contacts) List() []string {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
contacts := readContacts(c.state)
|
||||
var result []string
|
||||
for s := range contacts {
|
||||
result = append(result, s)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func ContactsLocalStorage(state StateOperations) Contacts {
|
||||
return &contacts{state: state, lock: &sync.Mutex{}}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||
)
|
||||
|
||||
const (
|
||||
ConversationsKey = "saltyim-conversations-%x"
|
||||
)
|
||||
|
||||
type Conversations interface {
|
||||
Read() []string
|
||||
Append(line string)
|
||||
Update(lines []string)
|
||||
}
|
||||
|
||||
type conversations struct {
|
||||
state StateOperations
|
||||
lock *sync.Mutex
|
||||
addr string
|
||||
}
|
||||
|
||||
func (c *conversations) Read() []string {
|
||||
return readConversations(c.state, c.addr)
|
||||
}
|
||||
|
||||
func (c *conversations) Update(lines []string) {
|
||||
updateConversations(c.state, c.addr, lines)
|
||||
}
|
||||
|
||||
func (c *conversations) Append(line string) {
|
||||
c.Update(append(readConversations(c.state, c.addr), line))
|
||||
}
|
||||
|
||||
func readConversations(state StateOperations, addr string) []string {
|
||||
conversationKey := fmt.Sprintf(ConversationsKey, sha256.Sum256([]byte(addr)))
|
||||
var conversations []string
|
||||
state.GetState(conversationKey, &conversations)
|
||||
return conversations
|
||||
}
|
||||
func updateConversations(state StateOperations, addr string, conversations []string) {
|
||||
conversationKey := fmt.Sprintf(ConversationsKey, sha256.Sum256([]byte(addr)))
|
||||
state.SetState(conversationKey, conversations, app.Persist, app.Encrypt)
|
||||
}
|
||||
|
||||
func ConversationsLocalStorage(state StateOperations, addr string) Conversations {
|
||||
return &conversations{state: state, lock: &sync.Mutex{}, addr: addr}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package storage
|
||||
|
||||
import "github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||
|
||||
// StateOperations are the state operations present in app.Context
|
||||
type StateOperations interface {
|
||||
SetState(state string, v interface{}, opts ...app.StateOption)
|
||||
GetState(state string, recv interface{})
|
||||
DelState(state string)
|
||||
}
|
|
@ -108,7 +108,11 @@ func (s *Server) Run() (err error) {
|
|||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
go s.svc.Run(ctx)
|
||||
go func() {
|
||||
if err := s.svc.Run(ctx); err != nil {
|
||||
log.WithError(err).Error("error running service user")
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Infof("Received signal %s", ctx.Err())
|
||||
|
@ -257,7 +261,7 @@ func (s *Server) setupServiceUser() error {
|
|||
return err
|
||||
}
|
||||
|
||||
err = CreateConfig(s.config, addr.Hash(), key.String())
|
||||
err = CreateConfig(s.config, addr.Hash(), key.ID().String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -314,6 +318,7 @@ func (s *Server) initRoutes() {
|
|||
}
|
||||
s.router.Handler(http.MethodGet, "/", app)
|
||||
s.router.Handler(http.MethodGet, "/config", app)
|
||||
s.router.Handler(http.MethodGet, "/newchat", app)
|
||||
s.router.Handler(http.MethodGet, "/app.js", app)
|
||||
s.router.Handler(http.MethodGet, "/app.css", app)
|
||||
s.router.Handler(http.MethodGet, "/web/*static", app)
|
||||
|
|
|
@ -33,7 +33,6 @@ type ChatTUI struct {
|
|||
cli *saltyim.Client
|
||||
user string
|
||||
addr *saltyim.Addr
|
||||
config *saltyim.Config
|
||||
|
||||
// Configurations.
|
||||
palette map[string]string
|
||||
|
@ -158,7 +157,7 @@ func (c *ChatTUI) RunChat(inCh chan<- string, outCh <-chan string) {
|
|||
|
||||
// Receives incoming messages on a separate goroutine to be non-blocking.
|
||||
go func() {
|
||||
for msg := range c.cli.Read(ctx, "", "") {
|
||||
for msg := range c.cli.Subscribe(ctx, "", "", "") {
|
||||
inCh <- msg.Text
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:87dcce2255e923ae98fdbbcd2bca06f3520f8bdda214ecab177b32b76737d58d
|
||||
size 26749737
|
41
service.go
41
service.go
|
@ -6,8 +6,8 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/avast/retry-go"
|
||||
"github.com/keys-pub/keys"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
|
@ -37,12 +37,19 @@ func NewService(me *Addr, id *Identity) (*Service, error) {
|
|||
eventFns: make(map[string]MessageEventHandlerFunc),
|
||||
}
|
||||
svc.TextFunc("ping", func(ctx context.Context, svc *Service, key *keys.EdX25519PublicKey, msg *lextwt.SaltyText) error {
|
||||
return svc.Respond(msg.User.String(), "Pong!")
|
||||
return svc.Respond(msg.User.String(), "pong")
|
||||
})
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
func (svc *Service) SetClient(cli *Client) {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
|
||||
svc.cli = cli
|
||||
}
|
||||
|
||||
func (svc *Service) String() string {
|
||||
svc.mu.RLock()
|
||||
defer svc.mu.RUnlock()
|
||||
|
@ -65,26 +72,36 @@ func (svc *Service) Respond(user, msg string) error {
|
|||
return svc.cli.Send(user, msg)
|
||||
}
|
||||
|
||||
func (svc *Service) Run(ctx context.Context) {
|
||||
func (svc *Service) Run(ctx context.Context) error {
|
||||
// create the service user's client in a loop until successful
|
||||
// TODO: Should this timeout? Use a context?
|
||||
for {
|
||||
if err := retry.Do(func() error {
|
||||
cli, err := NewClient(svc.me, WithIdentity(svc.id))
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("error creating service user client")
|
||||
time.Sleep(time.Second * 3)
|
||||
continue
|
||||
return err
|
||||
}
|
||||
svc.cli = cli
|
||||
break
|
||||
if err := cli.me.Refresh(); err != nil {
|
||||
return err
|
||||
}
|
||||
svc.SetClient(cli)
|
||||
return nil
|
||||
},
|
||||
retry.LastErrorOnly(true),
|
||||
retry.OnRetry(func(n uint, err error) {
|
||||
log.Debugf("retrying service user setup (try #%d): %s", n, err)
|
||||
}),
|
||||
); err != nil {
|
||||
return fmt.Errorf("error setting up service user %s: %w", svc.me, err)
|
||||
}
|
||||
|
||||
log.Println("listining for bot: ", svc.me)
|
||||
msgch := svc.cli.Read(ctx, "", "")
|
||||
log.Debugf("listening for service requests as %s", svc.me)
|
||||
|
||||
msgch := svc.cli.Drain(ctx, "", "", "")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
return nil
|
||||
case msg := <-msgch:
|
||||
if err := svc.handle(ctx, msg); err != nil {
|
||||
log.WithError(err).Println("failed to handle message")
|
||||
|
|
Loading…
Reference in New Issue