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: #77
Co-authored-by: mlctrez <mlctrez@noreply@mills.io>
Co-committed-by: mlctrez <mlctrez@noreply@mills.io>
pull/84/head
mlctrez 6 months ago committed by xuu
parent 3a82188a5b
commit 969a263d06
  1. 1
      .gitignore
  2. 16
      Makefile
  3. 207
      client.go
  4. 44
      cmd/salty-chat/read.go
  5. 3
      go.mod
  6. 14
      go.sum
  7. 12
      hooks/echobot.sh
  8. 7
      hooks/pushover-prehook.sh
  9. 5
      internal/exec/cmd.go
  10. 68
      internal/pwa/components/chatbox.go
  11. 54
      internal/pwa/components/configuration.go
  12. 39
      internal/pwa/components/dialog.go
  13. 51
      internal/pwa/components/navigation.go
  14. 78
      internal/pwa/components/newchat.go
  15. 178
      internal/pwa/components/saltychat.go
  16. 19
      internal/pwa/main.go
  17. 1
      internal/pwa/routes/routes.go
  18. 10
      internal/pwa/storage/actions.go
  19. 65
      internal/pwa/storage/contacts.go
  20. 52
      internal/pwa/storage/conversations.go
  21. 10
      internal/pwa/storage/operations.go
  22. 9
      internal/server.go
  23. 3
      internal/tui/tui.go
  24. BIN
      internal/web/app.wasm
  25. 41
      service.go

1
.gitignore vendored

@ -3,7 +3,6 @@
*.bak
*.key
*.swp
*.wasm
**/.envrc
**/.DS_Store

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

@ -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,37 +130,45 @@ func (cli *Client) getAddr(user string) (*Addr, error) {
return addr, nil
}
type Message struct {
Text string
Key *keys.EdX25519PublicKey
}
func (cli *Client) processMessage(msg *msgbus.Message, extraenvs, prehook, posthook string) (Message, error) {
var data []byte
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))
defer func() {
if posthook != "" {
out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, cli.Env(extraenvs), posthook, bytes.NewBuffer(data))
if err != nil {
log.WithError(err).Debugf("error running pre-hook %s", prehook)
log.WithError(err).Debugf("error running post-hook %s", posthook)
}
log.Debugf("pre-hook: %q", out)
log.Debugf("post-hook: %q", out)
}
}()
data, senderKey, err := salty.Decrypt(cli.key, msg.Payload)
if prehook != "" {
out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, cli.Env(extraenvs), prehook, bytes.NewBuffer(msg.Payload))
if err != nil {
fmt.Fprintf(os.Stderr, "error decrypting message")
return err
log.WithError(err).Debugf("error running pre-hook %s", prehook)
}
log.Debugf("pre-hook: %q", out)
}
msgs <- Message{Text: string(data), Key: senderKey}
unencrypted, senderKey, err := salty.Decrypt(cli.key, msg.Payload)
if err != nil {
return Message{}, fmt.Errorf("error decrypting message: %w", err)
}
data = unencrypted[:]
if posthook != "" {
out, err := exec.RunCmd(exec.DefaultRunCmdTimeout, posthook, bytes.NewBuffer(data))
if err != nil {
log.WithError(err).Debugf("error running post-hook %s", posthook)
}
log.Debugf("post-hook: %q", out)
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)
}
}
}

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

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