mirror of
https://git.mills.io/saltyim/saltyim.git
synced 2024-06-16 03:48:24 +00:00
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
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,7 +3,6 @@
|
|||||||
*.bak
|
*.bak
|
||||||
*.key
|
*.key
|
||||||
*.swp
|
*.swp
|
||||||
*.wasm
|
|
||||||
|
|
||||||
**/.envrc
|
**/.envrc
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
16
Makefile
16
Makefile
@ -36,6 +36,14 @@ dev : build ## Build debug versions of the cli and server
|
|||||||
@./salty-chat -v
|
@./salty-chat -v
|
||||||
@./saltyd -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
|
cli: ## Build the salty-chat command-line client and tui
|
||||||
@$(GOCMD) build -tags "netgo static_build" -installsuffix netgo \
|
@$(GOCMD) build -tags "netgo static_build" -installsuffix netgo \
|
||||||
-ldflags "-w \
|
-ldflags "-w \
|
||||||
@ -57,9 +65,13 @@ generate: ## Genereate any code required by the build
|
|||||||
echo 'Running in debug mode...'; \
|
echo 'Running in debug mode...'; \
|
||||||
fi
|
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/
|
@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: build ## Install salty-chat (cli) and saltyd (server) to $DESTDIR
|
||||||
@install -D -m 755 salty-chat $(DESTDIR)/salty-chat
|
@install -D -m 755 salty-chat $(DESTDIR)/salty-chat
|
||||||
@install -D -m 755 saltyd $(DESTDIR)/saltyd
|
@install -D -m 755 saltyd $(DESTDIR)/saltyd
|
||||||
@ -87,7 +99,7 @@ coverage: ## Get test coverage report
|
|||||||
@$(GOCMD) tool cover -html=coverage.out
|
@$(GOCMD) tool cover -html=coverage.out
|
||||||
|
|
||||||
clean: ## Remove untracked files
|
clean: ## Remove untracked files
|
||||||
@git clean -f -d
|
@git clean -f -d -x -e certs
|
||||||
|
|
||||||
clean-all: ## Remove untracked and Git ignores files
|
clean-all: ## Remove untracked and Git ignores files
|
||||||
@git clean -f -d -X
|
@git clean -f -d -X
|
||||||
|
213
client.go
213
client.go
@ -3,6 +3,7 @@ package saltyim
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@ -18,12 +19,47 @@ import (
|
|||||||
"go.mills.io/saltyim/internal/exec"
|
"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 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
|
// PackMessage formts an outoing message in the Message Format
|
||||||
// <timestamp>\t(<sender>) <message>
|
// <timestamp>\t(<sender>) <message>
|
||||||
func PackMessage(me *Addr, msg string) []byte {
|
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
|
// 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.
|
// and Sedngina and Receiving messages to/from Salty IM Users.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
me *Addr
|
me *Addr
|
||||||
|
id *Identity
|
||||||
key *keys.EdX25519Key
|
key *keys.EdX25519Key
|
||||||
cache addrCache
|
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
|
// NewClient reeturns a new Salty IM client for sending and receiving
|
||||||
// encrypted messages to other Salty IM users as well as decrypting
|
// encrypted messages to other Salty IM users as well as decrypting
|
||||||
// and displaying messages of the user's own inbox.
|
// and displaying messages of the user's own inbox.
|
||||||
func NewClient(me *Addr, options ...IdentityOption) (*Client, error) {
|
func NewClient(me *Addr, options ...IdentityOption) (*Client, error) {
|
||||||
ident, err := GetIdentity(options...)
|
id, err := GetIdentity(options...)
|
||||||
if err != nil {
|
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() {
|
if me == nil || me.IsZero() {
|
||||||
me = ident.addr
|
me = id.addr
|
||||||
}
|
}
|
||||||
|
|
||||||
if me == nil || me.IsZero() {
|
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 {
|
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("Salty Addr is %s", me)
|
||||||
log.Debugf("Endpoint is %s", me.Endpoint())
|
log.Debugf("Endpoint is %s", me.Endpoint())
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
me: me,
|
me: me,
|
||||||
key: ident.key,
|
id: id,
|
||||||
|
key: id.key,
|
||||||
cache: make(addrCache),
|
cache: make(addrCache),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@ -100,36 +130,44 @@ func (cli *Client) getAddr(user string) (*Addr, error) {
|
|||||||
return addr, nil
|
return addr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type Message struct {
|
func (cli *Client) processMessage(msg *msgbus.Message, extraenvs, prehook, posthook string) (Message, error) {
|
||||||
Text string
|
var data []byte
|
||||||
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}
|
|
||||||
|
|
||||||
|
defer func() {
|
||||||
if posthook != "" {
|
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 {
|
if err != nil {
|
||||||
log.WithError(err).Debugf("error running post-hook %s", posthook)
|
log.WithError(err).Debugf("error running post-hook %s", posthook)
|
||||||
}
|
}
|
||||||
log.Debugf("post-hook: %q", out)
|
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
|
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) Me() *Addr { return cli.me }
|
||||||
func (cli *Client) Key() *keys.EdX25519PublicKey { return cli.key.PublicKey() }
|
func (cli *Client) Key() *keys.EdX25519PublicKey { return cli.key.PublicKey() }
|
||||||
|
|
||||||
// Read subscribers to this user's inbox for new messages
|
func (cli *Client) Env(extraenvs string) []string {
|
||||||
func (cli *Client) Read(ctx context.Context, prehook, posthook string) chan Message {
|
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())
|
uri, inbox := SplitInbox(cli.me.Endpoint().String())
|
||||||
bus := msgbus_client.NewClient(uri, nil)
|
bus := msgbus_client.NewClient(uri, nil)
|
||||||
|
|
||||||
msgs := make(chan Message)
|
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()
|
s.Start()
|
||||||
|
|
||||||
log.Debugf("Connected to %s/%s", uri, inbox)
|
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()
|
// 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")
|
prehook, err := cmd.Flags().GetString("pre-hook")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("error getting --pre-hook flag")
|
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")
|
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() {
|
func init() {
|
||||||
rootCmd.AddCommand(readCmd)
|
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(
|
readCmd.Flags().String(
|
||||||
"pre-hook", "",
|
"pre-hook", "",
|
||||||
"Execute pre-hook before message decryption",
|
"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))
|
cli, err := saltyim.NewClient(me, saltyim.WithIdentityPath(identity))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err)
|
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()
|
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) {
|
if term.IsTerminal(syscall.Stdin) {
|
||||||
fmt.Println(saltyim.FormatMessage(msg.Text))
|
fmt.Println(saltyim.FormatMessage(msg.Text))
|
||||||
} else {
|
} else {
|
||||||
fmt.Println(msg)
|
fmt.Println(msg.Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3
go.mod
3
go.mod
@ -7,6 +7,7 @@ go 1.17
|
|||||||
//)
|
//)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/avast/retry-go v2.7.0+incompatible
|
||||||
github.com/likexian/doh-go v0.6.4
|
github.com/likexian/doh-go v0.6.4
|
||||||
github.com/mitchellh/go-homedir v1.1.0
|
github.com/mitchellh/go-homedir v1.1.0
|
||||||
github.com/mlctrez/goapp-mdc v0.2.6
|
github.com/mlctrez/goapp-mdc v0.2.6
|
||||||
@ -79,7 +80,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
git.mills.io/prologic/bitcask v1.0.2
|
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/observe v0.0.0-20210712230028-fc31c7aa2bd1
|
||||||
git.mills.io/prologic/useragent v0.0.0-20210714100044-d249fe7921a0
|
git.mills.io/prologic/useragent v0.0.0-20210714100044-d249fe7921a0
|
||||||
github.com/NYTimes/gziphandler v1.1.1
|
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=
|
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 h1:Iy9x3mVVd1fB+SWY0LTmsSDPGbzMrd7zCZPKbsb/tDA=
|
||||||
git.mills.io/prologic/bitcask v1.0.2/go.mod h1:ppXpR3haeYrijyJDleAkSGH3p90w6sIHxEA/7UHMxH4=
|
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.12 h1:EWK5GEJvi/H2Yt4k+FtpUzA2uw5P9u21LImUrW+0KV4=
|
||||||
git.mills.io/prologic/msgbus v0.1.9-0.20220325123528-9e1d03846ecd/go.mod h1:3HKT07iPSoi77CC3TpukUU5rkUErHcXThVHeYOej5kI=
|
git.mills.io/prologic/msgbus v0.1.12/go.mod h1:2YmGBm9WJjfMTBki/PuD5eG0CUULXesaV6kpVF/jJ2g=
|
||||||
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/observe v0.0.0-20210712230028-fc31c7aa2bd1 h1:e6ZyAOFGLZJZYL2galNvfuNMqeQDdilmQ5WRBXCNL5s=
|
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/observe v0.0.0-20210712230028-fc31c7aa2bd1/go.mod h1:/rNXqsTHGrilgNJYH/8wsIRDScyxXUhpbSdNbBatAKY=
|
||||||
git.mills.io/prologic/useragent v0.0.0-20210714100044-d249fe7921a0 h1:MojWEgZyiugUbgyjydrdSAkHlADnbt90dXyURRYFzQ4=
|
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-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 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/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 h1:nsDNoesoGwPzPkcrR1w1uzPUtiqwCXoNnkWC7nUuRHI=
|
||||||
github.com/badgerodon/ioutil v0.0.0-20150716134133-06e58e34b867/go.mod h1:Ctq1YQi0dOq7QgBLZZ7p1Fr3IbAAqL/yMqDIHoe9WtE=
|
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=
|
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.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
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/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.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 h1:y9FcTHGyrebwfP0ZZqFiaxTaiDnUrGkJkI+f583BL1A=
|
||||||
github.com/klauspost/compress v1.15.1/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
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-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-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-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-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 h1:TrmogKRsSOxRMJbLYGrB4SBbW+LJcEllYBLME5Zk5pU=
|
||||||
golang.org/x/sys v0.0.0-20220325203850-36772127a21f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220325203850-36772127a21f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
@ -4,17 +4,11 @@
|
|||||||
#
|
#
|
||||||
# Setup:
|
# Setup:
|
||||||
#
|
#
|
||||||
# $ salty-chat -i ~/.config/salty/echobot.key -u echo@yourdomain.tld make-user
|
# $ salty-chat -i ~/.config/salty/echo.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 read --post-hook ./hooks/echobot.sh
|
||||||
|
|
||||||
set -e
|
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")"
|
tmpfile="$(mktemp -t "echobot-XXXXXX")"
|
||||||
trap 'rm $tmpfile' EXIT
|
trap 'rm $tmpfile' EXIT
|
||||||
|
|
||||||
@ -24,4 +18,4 @@ sender="$(head -n 1 < "$tmpfile" | awk '{ print $2 }')"
|
|||||||
sender="$(echo "$sender" | sed 's/[)(]//g')"
|
sender="$(echo "$sender" | sed 's/[)(]//g')"
|
||||||
message="$(head -n 1 < "$tmpfile" | awk '{ $1 = ""; $2 = ""; print $0; }')"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "PUSHOVER_CLI_API: $PUSHOVER_CLI_API"
|
||||||
|
echo "PUSHOVER_CLI_USER: $PUSHOVER_CLI_USER"
|
||||||
|
|
||||||
if [ -z "$PUSHOVER_CLI_API" ]; then
|
if [ -z "$PUSHOVER_CLI_API" ]; then
|
||||||
echo "$$PUSHOVER_CLI_API not set"
|
echo "PUSHOVER_CLI_API not set"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$PUSHOVER_CLI_USER" ]; then
|
if [ -z "$PUSHOVER_CLI_USER" ]; then
|
||||||
echo "$$PUSHOVER_CLI_USER not set"
|
echo "PUSHOVER_CLI_USER not set"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ const (
|
|||||||
|
|
||||||
// RunCmd executes the given command and arguments and stdin and ensures the
|
// RunCmd executes the given command and arguments and stdin and ensures the
|
||||||
// command takes no longer than the timeout before the command is terminated.
|
// 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 (
|
var (
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@ -28,6 +28,7 @@ func RunCmd(timeout time.Duration, command string, stdin io.Reader, args ...stri
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, command, args...)
|
cmd := exec.CommandContext(ctx, command, args...)
|
||||||
|
cmd.Env = append(cmd.Env, env...)
|
||||||
cmd.Stdin = stdin
|
cmd.Stdin = stdin
|
||||||
|
|
||||||
out, err := cmd.CombinedOutput()
|
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
|
return string(out), nil
|
||||||
|
68
internal/pwa/components/chatbox.go
Normal file
68
internal/pwa/components/chatbox.go
Normal file
@ -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
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
"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/base"
|
||||||
"github.com/mlctrez/goapp-mdc/pkg/button"
|
"github.com/mlctrez/goapp-mdc/pkg/button"
|
||||||
"github.com/mlctrez/goapp-mdc/pkg/icon"
|
"github.com/mlctrez/goapp-mdc/pkg/icon"
|
||||||
@ -17,6 +17,8 @@ type Configuration struct {
|
|||||||
app.Compo
|
app.Compo
|
||||||
base.JsUtil
|
base.JsUtil
|
||||||
|
|
||||||
|
navigation *Navigation
|
||||||
|
|
||||||
// components
|
// components
|
||||||
user *textfield.TextField
|
user *textfield.TextField
|
||||||
identity *textarea.TextArea
|
identity *textarea.TextArea
|
||||||
@ -41,27 +43,48 @@ func (c *Configuration) OnMount(ctx app.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Configuration) Render() app.UI {
|
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 {
|
if c.user == nil {
|
||||||
c.user = &textfield.TextField{Id: "config-user", Label: "User in the form user@domain"}
|
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 = textarea.New("identity").Size(5, 100).Label("identity").MaxLength(1024)
|
||||||
c.identity.WithCallback(func(in app.HTMLTextarea) {
|
c.identity.WithCallback(func(in app.HTMLTextarea) {
|
||||||
in.OnChange(c.identity.ValueTo(&c.identity.Value))
|
in.OnChange(c.identity.ValueTo(&c.identity.Value))
|
||||||
})
|
})
|
||||||
|
c.navigation = &Navigation{}
|
||||||
}
|
}
|
||||||
|
|
||||||
return app.Div().Body(
|
return app.Div().Body(
|
||||||
&AppUpdateBanner{},
|
c.navigation,
|
||||||
app.H4().Text("configuration"),
|
app.Div().Class("mdc-drawer-app-content").Body(
|
||||||
c.user,
|
&AppUpdateBanner{},
|
||||||
&button.Button{Icon: string(icon.MICreate), Label: "new identity",
|
topBar,
|
||||||
Outlined: true, Raised: true, Callback: c.newIdentity()},
|
app.Div().Class("main-content").ID("main-content").Body(
|
||||||
app.Br(),
|
topBar.Main().Body(
|
||||||
c.identity,
|
app.Div().ID("wrapper").Body(
|
||||||
&button.Button{Icon: string(icon.MIUpdate), Label: "update identity",
|
app.H4().Text("configuration"),
|
||||||
Outlined: true, Raised: true, Callback: c.updateIdentity()},
|
c.user,
|
||||||
app.Hr(),
|
&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) {
|
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
|
||||||
|
}
|
||||||
|
39
internal/pwa/components/dialog.go
Normal file
39
internal/pwa/components/dialog.go
Normal file
@ -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()
|
||||||
|
}
|
51
internal/pwa/components/navigation.go
Normal file
51
internal/pwa/components/navigation.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
78
internal/pwa/components/newchat.go
Normal file
78
internal/pwa/components/newchat.go
Normal file
@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
"github.com/maxence-charriere/go-app/v9/pkg/app"
|
||||||
"github.com/mlctrez/goapp-mdc/pkg/bar"
|
"github.com/mlctrez/goapp-mdc/pkg/bar"
|
||||||
"github.com/mlctrez/goapp-mdc/pkg/base"
|
"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/icon"
|
||||||
"github.com/mlctrez/goapp-mdc/pkg/textfield"
|
"github.com/mlctrez/goapp-mdc/pkg/textfield"
|
||||||
"go.mills.io/saltyim"
|
"go.mills.io/saltyim"
|
||||||
|
"go.mills.io/saltyim/internal/pwa/storage"
|
||||||
|
"go.yarn.social/lextwt"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
menuWidth = 282
|
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.`
|
||||||
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.`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var client *saltyim.Client
|
||||||
|
|
||||||
// SaltyChat ...
|
// SaltyChat ...
|
||||||
type SaltyChat struct {
|
type SaltyChat struct {
|
||||||
app.Compo
|
app.Compo
|
||||||
base.JsUtil
|
base.JsUtil
|
||||||
isAppInstallable bool
|
isAppInstallable bool
|
||||||
|
|
||||||
dialog *dialog.Dialog
|
navigation *Navigation
|
||||||
|
dialog *ModalDialog
|
||||||
|
chatBox *ChatBox
|
||||||
|
|
||||||
messages []string
|
Friend string
|
||||||
friend *textfield.TextField
|
|
||||||
chatInput *textfield.TextField
|
chatInput *textfield.TextField
|
||||||
|
|
||||||
client *saltyim.Client
|
|
||||||
incoming chan string
|
incoming chan string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,93 +52,121 @@ func (h *SaltyChat) OnPreRender(ctx app.Context) {
|
|||||||
|
|
||||||
func (h *SaltyChat) OnResize(ctx app.Context) {
|
func (h *SaltyChat) OnResize(ctx app.Context) {
|
||||||
h.ResizeContent()
|
h.ResizeContent()
|
||||||
h.scrollChatPane(ctx)
|
h.navigation.drawer.ActionClose(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SaltyChat) OnAppInstallChange(ctx app.Context) {
|
func (h *SaltyChat) OnAppInstallChange(ctx app.Context) {
|
||||||
h.isAppInstallable = ctx.IsAppInstallable()
|
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) {
|
func (h *SaltyChat) OnMount(ctx app.Context) {
|
||||||
|
|
||||||
h.isAppInstallable = ctx.IsAppInstallable()
|
h.isAppInstallable = ctx.IsAppInstallable()
|
||||||
|
|
||||||
|
h.refreshMessages(ctx)
|
||||||
if app.IsClient {
|
if app.IsClient {
|
||||||
h.connect(ctx)
|
h.connect(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SaltyChat) connect(ctx app.Context) {
|
func (h *SaltyChat) connect(ctx app.Context) {
|
||||||
// TODO: how is client affected with mount / unmount / navigation
|
|
||||||
if h.client != nil {
|
if client != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
identity, err := GetIdentityFromState(ctx)
|
identity, err := GetIdentityFromState(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.showDialog("missing identity, please configure", err.Error())
|
h.dialog.ShowDialog("missing identity, please configure", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := saltyim.NewClient(identity.Addr(), saltyim.WithIdentityBytes(identity.Contents()))
|
newClient, err := saltyim.NewClient(identity.Addr(), saltyim.WithIdentityBytes(identity.Contents()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.showDialog("error setting up client", err.Error())
|
h.dialog.ShowDialog("error setting up client", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.client = client
|
client = newClient
|
||||||
|
|
||||||
ctx.Async(func() {
|
ctx.Async(func() {
|
||||||
for msg := range client.Read(context.Background(), "", "") {
|
for msg := range client.Drain(context.Background(), "", "", "") {
|
||||||
log.Println("incoming message", msg)
|
s, err := lextwt.ParseSalty(msg.Text)
|
||||||
ctx.Dispatch(func(ctx app.Context) {
|
if err != nil {
|
||||||
h.incomingMessage(ctx, msg.Text)
|
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 {
|
func (h *SaltyChat) Render() app.UI {
|
||||||
|
|
||||||
topBar := &bar.TopAppBar{Title: "Salty IM",
|
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,
|
Fixed: true,
|
||||||
ScrollTarget: "main-content",
|
ScrollTarget: "main-content",
|
||||||
Actions: h.topActions(),
|
Actions: h.topActions(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.chatInput == nil {
|
if h.chatBox == nil {
|
||||||
|
h.chatBox = &ChatBox{}
|
||||||
h.chatInput = &textfield.TextField{Id: "chat-input", Placeholder: ">"}
|
h.chatInput = &textfield.TextField{Id: "chat-input", Placeholder: ">"}
|
||||||
h.friend = &textfield.TextField{Id: "friend-input", Placeholder: "send-to"}
|
//h.friend = &textfield.TextField{Id: "friend-input", Placeholder: "send-to"}
|
||||||
h.dialog = &dialog.Dialog{Id: "notification-dialog"}
|
h.dialog = &ModalDialog{}
|
||||||
h.dialog.Title = []app.UI{app.Div().Text("Error")}
|
h.navigation = &Navigation{}
|
||||||
h.dialog.Content = []app.UI{}
|
|
||||||
h.dialog.Buttons = []app.UI{&button.Button{Id: "notification-dialog-dismiss",
|
|
||||||
Dialog: true, DialogAction: "dismiss", Label: "dismiss"}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.chatBox.User = h.Friend
|
||||||
|
|
||||||
return app.Div().Body(
|
return app.Div().Body(
|
||||||
&AppUpdateBanner{},
|
h.navigation,
|
||||||
topBar,
|
app.Div().Class("mdc-drawer-app-content").Body(
|
||||||
h.dialog,
|
&AppUpdateBanner{},
|
||||||
app.Div().Class("main-content").ID("main-content").Body(
|
topBar,
|
||||||
topBar.Main().Body(
|
app.Div().Class("main-content").ID("main-content").Body(
|
||||||
app.Div().ID("wrapper").Body(
|
topBar.Main().Body(
|
||||||
h.buildChatList(),
|
app.Div().ID("wrapper").Body(
|
||||||
app.Form().OnSubmit(h.handleSendMessage).Body(
|
h.chatBox,
|
||||||
h.chatInput,
|
app.Form().OnSubmit(h.handleSendMessage).Body(
|
||||||
icon.MISend.Button().ID("chat-send"),
|
h.chatInput,
|
||||||
h.friend,
|
),
|
||||||
|
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) {
|
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
|
msg := h.chatInput.Value
|
||||||
h.chatInput.Value = ""
|
h.chatInput.Value = ""
|
||||||
h.focusChatInput()
|
h.focusChatInput()
|
||||||
if msg == "" {
|
|
||||||
|
//friendAddress := strings.TrimSpace(h.friend.Value)
|
||||||
|
|
||||||
|
if msg == "" || h.Friend == "" {
|
||||||
// nothing to send
|
// nothing to send
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
storage.ContactsLocalStorage(ctx).Add(h.Friend)
|
||||||
|
|
||||||
// determine current user to send message to and use client to send the message
|
// determine current user to send message to and use client to send the message
|
||||||
if h.client != nil {
|
if client != nil {
|
||||||
friendAddress := strings.TrimSpace(h.friend.Value)
|
//h.friend.Value = friendAddress
|
||||||
h.friend.Value = friendAddress
|
h.chatBox.User = h.Friend
|
||||||
if _, err := saltyim.LookupAddr(friendAddress); err != nil {
|
h.chatBox.Update()
|
||||||
h.showDialog("problem with send-to address", err.Error())
|
if _, err := saltyim.LookupAddr(h.Friend); err != nil {
|
||||||
|
h.dialog.ShowDialog("problem with send-to address", err.Error())
|
||||||
} else {
|
} else {
|
||||||
if err := h.client.Send(friendAddress, msg); err == nil {
|
if err := client.Send(h.Friend, msg); err == nil {
|
||||||
h.incomingMessage(ctx, string(saltyim.PackMessage(h.client.Me(), msg)))
|
storage.ConversationsLocalStorage(ctx, h.Friend).
|
||||||
|
Append(string(saltyim.PackMessage(client.Me(), msg)))
|
||||||
|
h.chatBox.UpdateMessages(ctx)
|
||||||
} else {
|
} 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() {
|
func (h *SaltyChat) focusChatInput() {
|
||||||
chatInputValue := h.JsUtil.JsValueAtPath(h.chatInput.Id + "-input")
|
chatInputValue := h.JsUtil.JsValueAtPath(h.chatInput.Id + "-input")
|
||||||
@ -183,33 +210,14 @@ func (h *SaltyChat) focusChatInput() {
|
|||||||
chatInputValue.Call("focus")
|
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) {
|
func (h *SaltyChat) topActions() (actions []app.HTMLButton) {
|
||||||
if h.isAppInstallable {
|
if h.isAppInstallable {
|
||||||
actions = append(actions, icon.MIDownload.Button().Title("Install PWA").
|
actions = append(actions, icon.MIDownload.Button().Title("Install PWA").
|
||||||
OnClick(func(ctx app.Context, e app.Event) { ctx.ShowAppInstallPrompt() }))
|
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() }))
|
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
|
return actions
|
||||||
}
|
}
|
||||||
|
@ -13,26 +13,7 @@ func init() {
|
|||||||
saltyim.SetResolver(&saltyim.DNSOverHTTPResolver{})
|
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() {
|
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()
|
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()
|
app.RunWhenOnBrowser()
|
||||||
}
|
}
|
||||||
|
@ -9,4 +9,5 @@ import (
|
|||||||
func AddRoutes() {
|
func AddRoutes() {
|
||||||
app.Route("/", &components.SaltyChat{})
|
app.Route("/", &components.SaltyChat{})
|
||||||
app.Route("/config", &components.Configuration{})
|
app.Route("/config", &components.Configuration{})
|
||||||
|
app.Route("/newchat", &components.NewChat{})
|
||||||
}
|
}
|
||||||
|
10
internal/pwa/storage/actions.go
Normal file
10
internal/pwa/storage/actions.go
Normal file
@ -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)
|
||||||
|
}
|
65
internal/pwa/storage/contacts.go
Normal file
65
internal/pwa/storage/contacts.go
Normal file
@ -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{}}
|
||||||
|
}
|
52
internal/pwa/storage/conversations.go
Normal file
52
internal/pwa/storage/conversations.go
Normal file
@ -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}
|
||||||
|
}
|
10
internal/pwa/storage/operations.go
Normal file
10
internal/pwa/storage/operations.go
Normal file
@ -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)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer cancel()
|
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()
|
<-ctx.Done()
|
||||||
log.Infof("Received signal %s", ctx.Err())
|
log.Infof("Received signal %s", ctx.Err())
|
||||||
@ -257,7 +261,7 @@ func (s *Server) setupServiceUser() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = CreateConfig(s.config, addr.Hash(), key.String())
|
err = CreateConfig(s.config, addr.Hash(), key.ID().String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -314,6 +318,7 @@ func (s *Server) initRoutes() {
|
|||||||
}
|
}
|
||||||
s.router.Handler(http.MethodGet, "/", app)
|
s.router.Handler(http.MethodGet, "/", app)
|
||||||
s.router.Handler(http.MethodGet, "/config", 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.js", app)
|
||||||
s.router.Handler(http.MethodGet, "/app.css", app)
|
s.router.Handler(http.MethodGet, "/app.css", app)
|
||||||
s.router.Handler(http.MethodGet, "/web/*static", app)
|
s.router.Handler(http.MethodGet, "/web/*static", app)
|
||||||
|
@ -33,7 +33,6 @@ type ChatTUI struct {
|
|||||||
cli *saltyim.Client
|
cli *saltyim.Client
|
||||||
user string
|
user string
|
||||||
addr *saltyim.Addr
|
addr *saltyim.Addr
|
||||||
config *saltyim.Config
|
|
||||||
|
|
||||||
// Configurations.
|
// Configurations.
|
||||||
palette map[string]string
|
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.
|
// Receives incoming messages on a separate goroutine to be non-blocking.
|
||||||
go func() {
|
go func() {
|
||||||
for msg := range c.cli.Read(ctx, "", "") {
|
for msg := range c.cli.Subscribe(ctx, "", "", "") {
|
||||||
inCh <- msg.Text
|
inCh <- msg.Text
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
3
internal/web/app.wasm
Executable file
3
internal/web/app.wasm
Executable file
@ -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"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"github.com/avast/retry-go"
|
||||||
"github.com/keys-pub/keys"
|
"github.com/keys-pub/keys"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
@ -37,12 +37,19 @@ func NewService(me *Addr, id *Identity) (*Service, error) {
|
|||||||
eventFns: make(map[string]MessageEventHandlerFunc),
|
eventFns: make(map[string]MessageEventHandlerFunc),
|
||||||
}
|
}
|
||||||
svc.TextFunc("ping", func(ctx context.Context, svc *Service, key *keys.EdX25519PublicKey, msg *lextwt.SaltyText) error {
|
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
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *Service) SetClient(cli *Client) {
|
||||||
|
svc.mu.Lock()
|
||||||
|
defer svc.mu.Unlock()
|
||||||
|
|
||||||
|
svc.cli = cli
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *Service) String() string {
|
func (svc *Service) String() string {
|
||||||
svc.mu.RLock()
|
svc.mu.RLock()
|
||||||
defer svc.mu.RUnlock()
|
defer svc.mu.RUnlock()
|
||||||
@ -65,26 +72,36 @@ func (svc *Service) Respond(user, msg string) error {
|
|||||||
return svc.cli.Send(user, msg)
|
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
|
// create the service user's client in a loop until successful
|
||||||
// TODO: Should this timeout? Use a context?
|
// TODO: Should this timeout? Use a context?
|
||||||
for {
|
if err := retry.Do(func() error {
|
||||||
cli, err := NewClient(svc.me, WithIdentity(svc.id))
|
cli, err := NewClient(svc.me, WithIdentity(svc.id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithError(err).Warn("error creating service user client")
|
return err
|
||||||
time.Sleep(time.Second * 3)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
svc.cli = cli
|
if err := cli.me.Refresh(); err != nil {
|
||||||
break
|
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)
|
log.Debugf("listening for service requests as %s", svc.me)
|
||||||
msgch := svc.cli.Read(ctx, "", "")
|
|
||||||
|
msgch := svc.cli.Drain(ctx, "", "", "")
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return nil
|
||||||
case msg := <-msgch:
|
case msg := <-msgch:
|
||||||
if err := svc.handle(ctx, msg); err != nil {
|
if err := svc.handle(ctx, msg); err != nil {
|
||||||
log.WithError(err).Println("failed to handle message")
|
log.WithError(err).Println("failed to handle message")
|
||||||
|
Loading…
Reference in New Issue
Block a user