navigation drawer is now fixed for > 900px windows (#157)

Co-authored-by: mlctrez <mlctrez@gmail.com>
Reviewed-on: #157
Reviewed-by: James Mills <james@mills.io>
Co-authored-by: mlctrez <mlctrez@noreply@mills.io>
Co-committed-by: mlctrez <mlctrez@noreply@mills.io>
pull/158/head
mlctrez 6 months ago committed by James Mills
parent 81935022c1
commit fcc4f53f20
  1. 18
      client.go
  2. 62
      internal/pwa/components/configuration.go
  3. 14
      internal/pwa/components/navigation.go
  4. 38
      internal/pwa/components/newchat.go
  5. 93
      internal/pwa/components/page.go
  6. 88
      internal/pwa/components/saltychat.go
  7. 20
      internal/pwa/components/state.go
  8. 12
      internal/pwa/storage/conversations.go
  9. BIN
      internal/web/app.wasm
  10. 2
      internal/web/css/style.css
  11. 8
      options.go

@ -18,6 +18,7 @@ import (
msgbus_client "git.mills.io/prologic/msgbus/client"
"github.com/keys-pub/keys"
log "github.com/sirupsen/logrus"
"go.yarn.social/lextwt"
"go.mills.io/salty"
"go.mills.io/saltyim/internal/exec"
@ -57,10 +58,10 @@ func parseExtraEnvs(extraenvs string) map[string]string {
return env
}
// PackMessage formts an outoing message in the Message Format
// PackMessage formats an outgoing message in the Message Format
// <timestamp>\t(<sender>) <message>
func PackMessage(me *Addr, msg string) []byte {
log.Debug("pack: ", me.Formatted(), msg)
//log.Debug("pack: ", me.Formatted(), msg)
return []byte(
fmt.Sprint(
time.Now().UTC().Format(time.RFC3339), "\t",
@ -70,6 +71,19 @@ func PackMessage(me *Addr, msg string) []byte {
)
}
// PackMessageTime formats an incoming message in the Message Format using the existing timestamp
// <timestamp>\t(<sender>) <message>
func PackMessageTime(me *Addr, msg string, t *lextwt.DateTime) []byte {
//log.Debug("pack: ", me.Formatted(), msg)
return []byte(
fmt.Sprint(
t.Literal(), "\t",
me.Formatted(), "\t",
strings.TrimSpace(msg), "\n",
),
)
}
// Client is a Salty IM client that handles talking to a Salty IM Broker
// and Sedngina and Receiving messages to/from Salty IM Users.
type Client struct {

@ -6,7 +6,6 @@ import (
"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"
@ -23,8 +22,7 @@ type Configuration struct {
// to support removal
Contacts []string
dialog *ModalDialog
navigation *Navigation
dialog *ModalDialog
// components
user *textfield.TextField
@ -51,15 +49,6 @@ func (c *Configuration) OnMount(ctx app.Context) {
}
func (c *Configuration) Render() app.UI {
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)
@ -67,39 +56,28 @@ func (c *Configuration) Render() app.UI {
in.OnChange(c.identity.ValueTo(&c.identity.Value))
})
c.dialog = &ModalDialog{}
c.navigation = &Navigation{}
}
return app.Div().Body(
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.Br(),
app.H4().Text("register with salty@domain for above identity"),
&button.Button{Icon: string(icon.MICreate), Label: "register",
Outlined: true, Raised: true, Callback: c.registerIdentity()},
app.Hr(),
app.H4().Text("remove contacts and storage"),
c.buildDeleteContacts(),
),
),
),
return PageBody(
app.Div().ID("wrapper").Body(
app.H4().Text("configuration"),
c.dialog,
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.Br(),
app.H4().Text("register with salty@domain for above identity"),
&button.Button{Icon: string(icon.MICreate), Label: "register",
Outlined: true, Raised: true, Callback: c.registerIdentity()},
app.Hr(),
app.H4().Text("remove contacts and storage"),
c.buildDeleteContacts(),
),
c.dialog,
)
}
func (c *Configuration) buildDeleteContacts() app.UI {
@ -115,7 +93,7 @@ func (c *Configuration) buildDeleteContacts() app.UI {
storage.ConversationsLocalStorage(ctx, c.Contacts[i]).Delete()
storage.ContactsLocalStorage(ctx).Remove(c.Contacts[i])
c.Contacts = storage.ContactsLocalStorage(ctx).List()
c.navigation.UpdateAction(ctx)
NavigationUpdate(ctx)
ctx.Defer(func(context app.Context) {
c.Update()
})

@ -12,8 +12,11 @@ const (
navigationUpdateAction = "navigation.update.action"
)
var DrawerOpen bool
type Navigation struct {
app.Compo
Type drawer.Type
drawer *drawer.Drawer
items list.Items
Contacts []string
@ -31,7 +34,7 @@ func (n *Navigation) HasContact(addr string) bool {
func (n *Navigation) Render() app.UI {
if n.drawer == nil {
n.drawer = &drawer.Drawer{
Type: drawer.Dismissible,
Type: n.Type,
Id: "navigationDrawer",
List: &list.List{Type: list.Navigation, Id: "navigationList"},
}
@ -56,13 +59,16 @@ func (n *Navigation) LoadFromStorage(ctx app.Context) {
func (n *Navigation) OnMount(ctx app.Context) {
n.items.SelectHref(ctx.Page().URL().Path)
n.LoadFromStorage(ctx)
ctx.Handle(string(list.Select), func(context app.Context, action app.Action) {
if action.Value == n.drawer.List {
n.drawer.ActionClose(context)
if !DesktopMode {
n.drawer.ActionClose(context)
DrawerOpen = false
}
}
})
ctx.Handle(navigationUpdateAction, n.navigationUpdate)
ctx.Defer(func(context app.Context) { NavigationUpdate(context) })
}
func (n *Navigation) navigationUpdate(ctx app.Context, action app.Action) {
@ -70,6 +76,6 @@ func (n *Navigation) navigationUpdate(ctx app.Context, action app.Action) {
n.drawer.List.Update()
}
func (n *Navigation) UpdateAction(ctx app.Context) {
func NavigationUpdate(ctx app.Context) {
ctx.NewAction(navigationUpdateAction)
}

@ -2,7 +2,6 @@ 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"
@ -15,46 +14,25 @@ type NewChat struct {
app.Compo
base.JsUtil
navigation *Navigation
dialog *ModalDialog
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,
),
),
),
),
return PageBody(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,
),
)
}

@ -0,0 +1,93 @@
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/drawer"
"github.com/mlctrez/goapp-mdc/pkg/icon"
log "github.com/sirupsen/logrus"
)
var widthChecked bool
var DesktopMode bool
// PageBody applies the navigation, update banner, and demo page layout to the provided pageContent.
func PageBody(pageContent ...app.UI) app.UI {
initDesktopMode()
nav := &Navigation{Type: drawer.Dismissible}
if DesktopMode {
nav.Type = drawer.Standard
}
topBar := &bar.TopAppBar{Title: "Salty IM", Fixed: true, ScrollTarget: "main-content"}
if !DesktopMode {
menuButton := icon.MIMenu.Button().OnClick(func(ctx app.Context, e app.Event) {
toggleDrawer(ctx, nav)
})
topBar.Navigation = []app.HTMLButton{menuButton}
} else {
// TODO: put current chat user in menu
topBar.Navigation = []app.HTMLButton{}
}
reloadButton := icon.MIRefresh.Button().Title("reload the page")
reloadButton.OnClick(func(ctx app.Context, e app.Event) { ctx.Reload() })
topBar.Actions = []app.HTMLButton{reloadButton}
if DesktopMode {
var navFirstContent []app.UI
navFirstContent = append(navFirstContent, nav)
navFirstContent = append(navFirstContent, pageContent...)
return app.Div().Body(
&AppUpdateBanner{},
topBar,
app.Div().Class("main-content").ID("main-content").Body(
topBar.Main().Body(
app.Div().Style("display", "flex").Body(navFirstContent...),
),
),
)
} else {
return app.Div().Body(
nav,
app.Div().Class("mdc-drawer-app-content").Body(
&AppUpdateBanner{},
topBar,
app.Div().Class("main-content").ID("main-content").Body(
topBar.Main().Body(
app.Div().Style("display", "flex").Body(pageContent...),
),
),
),
)
}
}
func toggleDrawer(ctx app.Context, nav *Navigation) {
if DrawerOpen {
nav.drawer.ActionClose(ctx)
} else {
nav.drawer.ActionOpen(ctx)
}
DrawerOpen = !DrawerOpen
}
func initDesktopMode() {
if !widthChecked {
widthChecked = true
if app.IsClient {
innerWidth := app.Window().Get("innerWidth")
if innerWidth.Truthy() {
DesktopMode = innerWidth.Int() >= 900
}
log.Debugf("desktop mode is %t", DesktopMode)
}
}
}

@ -1,13 +1,13 @@
package components
import (
"log"
"time"
"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/icon"
"github.com/mlctrez/goapp-mdc/pkg/textfield"
log "github.com/sirupsen/logrus"
"go.mills.io/saltyim"
"go.mills.io/saltyim/internal/pwa/storage"
"go.yarn.social/lextwt"
@ -27,9 +27,8 @@ type SaltyChat struct {
base.JsUtil
isAppInstallable bool
navigation *Navigation
dialog *ModalDialog
chatBox *ChatBox
dialog *ModalDialog
chatBox *ChatBox
friend string
chatInput *textfield.TextField
@ -51,7 +50,6 @@ func (h *SaltyChat) OnPreRender(ctx app.Context) {
func (h *SaltyChat) OnResize(ctx app.Context) {
h.ResizeContent()
h.navigation.drawer.ActionClose(ctx)
}
func (h *SaltyChat) OnAppInstallChange(ctx app.Context) {
@ -103,7 +101,14 @@ func (h *SaltyChat) connect(ctx app.Context) {
return
}
newClient, err := saltyim.NewClient(identity.Addr(), saltyim.WithClientIdentity(saltyim.WithIdentityBytes(identity.Contents())))
state, err := GetStateFromState(ctx)
if err != nil {
log.Errorf("error loading state: %s", err)
state = saltyim.NewState()
}
clientIdentity := saltyim.WithClientIdentity(saltyim.WithIdentityBytes(identity.Contents()))
newClient, err := saltyim.NewClient(identity.Addr(), clientIdentity, saltyim.WithState(state))
if err != nil {
h.dialog.ShowError("error setting up client", err.Error())
return
@ -114,20 +119,25 @@ func (h *SaltyChat) connect(ctx app.Context) {
client = newClient
ctx.Async(func() {
inboxCh := client.Subscribe(ctx, "", "", "")
outboxCh := client.OutboxClient(nil).Subscribe(ctx, "", "", "")
stateCh := time.NewTicker(time.Second * 20)
inboxCh := client.Subscribe(ctx.Dispatcher().Context(), "", "", "")
outboxCh := client.OutboxClient(nil).Subscribe(ctx.Dispatcher().Context(), "", "", "")
for {
select {
case <-ctx.Done():
stateCh.Stop()
return
case msg := <-inboxCh:
// passing both the message and the text in case we need the message key at some point
ctx.NewActionWithValue(saltyChatRecvMessageAction, msg, app.T("text", msg.Text))
case msg := <-outboxCh:
// passing both the message and the text in case we need the message key at some point
ctx.NewActionWithValue(saltyChatSentMessageAction, msg, app.T("text", msg.Text))
case <-stateCh.C:
if err := SetStateToState(ctx, client.State()); err != nil {
log.WithError(err).Warn("error saving state")
}
}
}
})
@ -135,6 +145,7 @@ func (h *SaltyChat) connect(ctx app.Context) {
func (h *SaltyChat) incomingMessage(ctx app.Context, action app.Action) {
messageText := action.Tags.Get("text")
s, err := lextwt.ParseSalty(messageText)
if err != nil {
h.dialog.ShowError("incoming message error", err.Error())
@ -146,11 +157,6 @@ func (h *SaltyChat) incomingMessage(ctx app.Context, action app.Action) {
user := s.User.String()
storage.ContactsLocalStorage(ctx).Add(user)
storage.ConversationsLocalStorage(ctx, user).Append(messageText)
if !h.navigation.HasContact(user) {
h.navigation.UpdateAction(ctx)
}
// only update when incoming user's message is the active chat
if h.friend == user {
h.chatBox.UpdateMessages(ctx)
@ -176,7 +182,7 @@ func (h *SaltyChat) outgoingMessage(ctx app.Context, action app.Action) {
friend = s.User.String()
storage.ContactsLocalStorage(ctx).Add(friend)
storage.ConversationsLocalStorage(ctx, friend).
Append(string(saltyim.PackMessage(client.Me(), s.LiteralText())))
Append(string(saltyim.PackMessageTime(client.Me(), s.LiteralText(), s.Timestamp)))
}
// only update when incoming user's message is the active chat
@ -190,41 +196,23 @@ func (h *SaltyChat) outgoingMessage(ctx app.Context, action app.Action) {
}
func (h *SaltyChat) Render() app.UI {
topBar := &bar.TopAppBar{Title: "Salty IM",
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.chatBox == nil {
h.chatBox = &ChatBox{}
h.chatInput = &textfield.TextField{Id: "chat-input", Placeholder: "New Message"}
h.dialog = &ModalDialog{}
h.navigation = &Navigation{}
}
h.chatBox.User = h.friend
return app.Div().Body(
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,
icon.MISend.Button().ID("chat-send"),
),
h.dialog,
),
),
return PageBody(
app.Div().ID("wrapper").Body(
h.chatBox,
app.Form().OnSubmit(h.handleSendMessage).Body(
h.chatInput,
icon.MISend.Button().ID("chat-send"),
),
h.dialog,
),
)
@ -246,11 +234,7 @@ func (h *SaltyChat) handleSendMessage(ctx app.Context, e app.Event) {
if client != nil {
h.chatBox.User = h.friend
ctx.Async(func() {
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 {
if err := client.Send(h.friend, msg); err != nil {
h.dialog.ShowError("error sending message", err.Error())
}
})
@ -263,15 +247,3 @@ func (h *SaltyChat) focusChatInput() {
chatInputValue.Set("value", "")
chatInputValue.Call("focus")
}
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").
OnClick(func(ctx app.Context, e app.Event) { ctx.Reload() }))
return actions
}

@ -1,6 +1,7 @@
package components
import (
"bytes"
"fmt"
"github.com/maxence-charriere/go-app/v9/pkg/app"
@ -9,6 +10,7 @@ import (
const (
saltyIdentityKey = "salty-identity"
saltyStateKey = "salty-state"
)
func GetIdentityFromState(ctx app.Context) (identity *saltyim.Identity, err error) {
@ -27,3 +29,21 @@ func SetIdentityToState(ctx app.Context, identity *saltyim.Identity) (err error)
ctx.SetState(saltyIdentityKey, string(identity.Contents()), app.Persist, app.Encrypt)
return
}
func GetStateFromState(ctx app.Context) (state *saltyim.State, err error) {
var stateJson string
ctx.GetState(saltyStateKey, &stateJson)
if stateJson == "" {
return nil, fmt.Errorf("no state found")
}
return saltyim.LoadState(bytes.NewBufferString(stateJson))
}
func SetStateToState(ctx app.Context, state *saltyim.State) (err error) {
jsonBytes, err := state.Bytes()
if err != nil {
return fmt.Errorf("error serializing state: %w", err)
}
ctx.SetState(saltyStateKey, string(jsonBytes), app.Persist, app.Encrypt)
return
}

@ -3,6 +3,7 @@ package storage
import (
"crypto/sha256"
"fmt"
"sort"
"sync"
"github.com/maxence-charriere/go-app/v9/pkg/app"
@ -34,7 +35,16 @@ func (c *conversations) Update(lines []string) {
}
func (c *conversations) Append(line string) {
c.Update(append(readConversations(c.state, c.addr), line))
existingLines := readConversations(c.state, c.addr)
// TODO: map would be more efficient for finding existing messages
for _, existingLine := range existingLines {
if line == existingLine {
return
}
}
conversationLines := append(existingLines, line)
sort.Strings(conversationLines)
c.Update(conversationLines)
}
func (c *conversations) Delete() {

BIN
internal/web/app.wasm (Stored with Git LFS)

Binary file not shown.

@ -17,7 +17,7 @@ form {
#wrapper {
margin: 0;
background: #eee;
max-width: 100%;
flex: 1;
padding-bottom: 1.1rem;
}

@ -54,3 +54,11 @@ func WithStateFromBytes(data []byte) ClientOption {
return nil
}
}
// WithState sets the client's state from a state object
func WithState(state *State) ClientOption {
return func(cli *Client) error {
cli.state = state
return nil
}
}

Loading…
Cancel
Save