diff --git a/internal/client/ui/core/core.go b/internal/client/ui/core/core.go index 9e8bd93..d8e4c1f 100644 --- a/internal/client/ui/core/core.go +++ b/internal/client/ui/core/core.go @@ -19,6 +19,7 @@ import ( "github.com/kyren223/eko/internal/client/ui/core/networkcreation" "github.com/kyren223/eko/internal/client/ui/core/networkjoin" "github.com/kyren223/eko/internal/client/ui/core/networklist" + "github.com/kyren223/eko/internal/client/ui/core/networkupdate" "github.com/kyren223/eko/internal/client/ui/core/state" "github.com/kyren223/eko/internal/client/ui/loadscreen" "github.com/kyren223/eko/internal/packet" @@ -51,6 +52,7 @@ type Model struct { connected bool networkCreationPopup *networkcreation.Model + networkUpdatePopup *networkupdate.Model networkJoinPopup *networkjoin.Model frequencyCreationPopup *frequencycreation.Model networkList networklist.Model @@ -68,6 +70,7 @@ func New(privKey ed25519.PrivateKey, name string) Model { timeout: initialTimeout, connected: false, networkCreationPopup: nil, + networkUpdatePopup: nil, networkJoinPopup: nil, frequencyCreationPopup: nil, networkList: networklist.New(), @@ -103,6 +106,8 @@ func (m Model) View() string { var popup string if m.networkCreationPopup != nil { popup = m.networkCreationPopup.View() + } else if m.networkUpdatePopup != nil { + popup = m.networkUpdatePopup.View() } else if m.frequencyCreationPopup != nil { popup = m.frequencyCreationPopup.View() } else if m.networkJoinPopup != nil { @@ -220,7 +225,7 @@ func (m *Model) updateConnected(msg tea.Msg) tea.Cmd { case "n": switch m.focus { case FocusNetworkList: - if m.networkCreationPopup == nil && m.networkJoinPopup == nil { + if !m.HasPopup() { popup := networkcreation.New() m.networkCreationPopup = &popup } else { @@ -230,7 +235,7 @@ func (m *Model) updateConnected(msg tea.Msg) tea.Cmd { } } case FocusFrequencyList: - if m.frequencyCreationPopup == nil { + if !m.HasPopup() { networkId := state.NetworkId(m.networkList.Index()) if networkId == nil { return nil @@ -256,7 +261,7 @@ func (m *Model) updateConnected(msg tea.Msg) tea.Cmd { } case "a": - if m.focus == FocusNetworkList && m.networkJoinPopup == nil && m.networkCreationPopup == nil { + if m.focus == FocusNetworkList && !m.HasPopup() { popup := networkjoin.New() m.networkJoinPopup = &popup } else { @@ -265,7 +270,8 @@ func (m *Model) updateConnected(msg tea.Msg) tea.Cmd { case "i": index := m.networkList.Index() - if m.focus == FocusNetworkList && index != networklist.PeersIndex { + networkFocus := m.focus == FocusNetworkList + if !m.HasPopup() && networkFocus && index != networklist.PeersIndex { networkId := state.NetworkId(index) if networkId != nil { _ = clipboard.WriteAll(networkId.String()) @@ -274,12 +280,27 @@ func (m *Model) updateConnected(msg tea.Msg) tea.Cmd { m.updatePopups(msg) } + case "u": + index := m.networkList.Index() + networkFocus := m.focus == FocusNetworkList + if !m.HasPopup() && networkFocus && index != networklist.PeersIndex { + networkId := state.NetworkId(index) + if networkId != nil { + popup := networkupdate.New(*networkId) + m.networkUpdatePopup = &popup + } + } else { + cmd := m.updatePopups(msg) + if cmd != nil { + return cmd + } + } + case "esc": - if m.networkCreationPopup != nil { + if m.HasPopup() { m.networkCreationPopup = nil - } else if m.frequencyCreationPopup != nil { + m.networkUpdatePopup = nil m.frequencyCreationPopup = nil - } else if m.networkJoinPopup != nil { m.networkJoinPopup = nil } @@ -290,6 +311,12 @@ func (m *Model) updateConnected(msg tea.Msg) tea.Cmd { m.networkCreationPopup = nil } return cmd + } else if m.networkUpdatePopup != nil { + cmd := m.networkUpdatePopup.Select() + if cmd != nil { + m.networkUpdatePopup = nil + } + return cmd } else if m.frequencyCreationPopup != nil { cmd := m.frequencyCreationPopup.Select() if cmd != nil { @@ -324,10 +351,7 @@ func (m *Model) updateConnected(msg tea.Msg) tea.Cmd { } } - isPopup := m.networkCreationPopup != nil || - m.frequencyCreationPopup != nil || - m.networkJoinPopup != nil - if isPopup { + if m.HasPopup() { return nil } @@ -385,6 +409,10 @@ func (m *Model) updatePopups(msg tea.Msg) tea.Cmd { popup, cmd := m.networkCreationPopup.Update(msg) m.networkCreationPopup = &popup return cmd + } else if m.networkUpdatePopup != nil { + popup, cmd := m.networkUpdatePopup.Update(msg) + m.networkUpdatePopup = &popup + return cmd } else if m.frequencyCreationPopup != nil { popup, cmd := m.frequencyCreationPopup.Update(msg) m.frequencyCreationPopup = &popup @@ -396,3 +424,10 @@ func (m *Model) updatePopups(msg tea.Msg) tea.Cmd { } return nil } + +func (m *Model) HasPopup() bool { + return m.networkCreationPopup != nil || + m.networkUpdatePopup != nil || + m.frequencyCreationPopup != nil || + m.networkJoinPopup != nil +} diff --git a/internal/client/ui/core/networkupdate/networkupdate.go b/internal/client/ui/core/networkupdate/networkupdate.go new file mode 100644 index 0000000..9bea5ff --- /dev/null +++ b/internal/client/ui/core/networkupdate/networkupdate.go @@ -0,0 +1,355 @@ +package networkupdate + +import ( + "errors" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/kyren223/eko/internal/client/gateway" + "github.com/kyren223/eko/internal/client/ui/colors" + "github.com/kyren223/eko/internal/client/ui/core/networklist" + "github.com/kyren223/eko/internal/client/ui/core/state" + "github.com/kyren223/eko/internal/client/ui/field" + "github.com/kyren223/eko/internal/client/ui/layouts/flex" + "github.com/kyren223/eko/internal/packet" + "github.com/kyren223/eko/pkg/assert" + "github.com/kyren223/eko/pkg/snowflake" +) + +var ( + width = 48 + + style = lipgloss.NewStyle(). + Border(lipgloss.ThickBorder()). + Padding(1, 4). + Align(lipgloss.Center, lipgloss.Center) + + headerStyle = lipgloss.NewStyle().Foreground(colors.Turquoise) + + fieldBlurredStyle = lipgloss.NewStyle(). + PaddingLeft(1). + Border(lipgloss.RoundedBorder()). + BorderForeground(colors.DarkCyan) + fieldFocusedStyle = fieldBlurredStyle. + BorderForeground(colors.Focus). + Border(lipgloss.ThickBorder()) + + underlineStyle = func(s string, width int, color lipgloss.Color) string { + underline := lipgloss.NewStyle().Foreground(color). + Render(strings.Repeat(lipgloss.ThickBorder().Bottom, width)) + return lipgloss.JoinVertical(lipgloss.Left, s, underline) + } + + iconHeader = headerStyle.Bold(true).Render("Icon: ") + bgColorHeader = headerStyle.Bold(true).Render("BG # ") + fgColorHeader = headerStyle.Bold(true).Render(" FG # ") + + blurredUpdate = lipgloss.NewStyle(). + Background(colors.Gray).Padding(0, 1).Render("Update Network") + focusedUpdate = lipgloss.NewStyle(). + Background(colors.Blue).Padding(0, 1).Render("Update Network") +) + +const ( + MaxIconLength = 2 + MaxHexDigits = 6 +) + +const ( + NameField = iota + FgColorField + BgColorField + IconField + PrivateField + UpdateField + FieldCount +) + +type Model struct { + precomputedStyle lipgloss.Style + + name field.Model + icon textinput.Model + bgColor textinput.Model + fgColor textinput.Model + private bool + update string + + selected int + nameWidth int + + lastFg lipgloss.Color + lastBg lipgloss.Color + + networkId snowflake.ID +} + +func New(networkId snowflake.ID) Model { + network := state.State.Networks[networkId] + + name := field.New(width) + name.Header = "Network Name" + name.HeaderStyle = headerStyle + name.FocusedStyle = fieldFocusedStyle + name.BlurredStyle = fieldBlurredStyle + name.ErrorStyle = lipgloss.NewStyle().Foreground(colors.Error) + name.Input.CharLimit = width + name.Focus() + name.Input.Validate = func(s string) error { + if strings.TrimSpace(s) == "" { + return errors.New("cannot be empty") + } + return nil + } + name.Input.SetValue(network.Name) + + nameWidth := lipgloss.Width(name.View()) + + icon := textinput.New() + icon.Prompt = "" + icon.CharLimit = MaxIconLength + icon.Placeholder = "ic" + icon.Validate = func(s string) error { + if len(s) == 0 { + return errors.New("err") + } + return nil + } + icon.SetValue(network.Icon) + + bgColor := textinput.New() + bgColor.Prompt = "" + bgColor.CharLimit = MaxHexDigits + bgColor.Placeholder = "000000" + bgColor.Validate = func(s string) error { + if len(s) != MaxHexDigits { + return errors.New("err") + } + return nil + } + bgColor.SetValue(network.BgHexColor[1:]) + + fgColor := textinput.New() + fgColor.Prompt = "" + fgColor.CharLimit = MaxHexDigits + fgColor.Placeholder = "000000" + fgColor.Validate = func(s string) error { + if len(s) != MaxHexDigits { + return errors.New("err") + } + return nil + } + fgColor.SetValue(network.FgHexColor[1:]) + + return Model{ + name: name, + icon: icon, + bgColor: bgColor, + fgColor: fgColor, + lastBg: lipgloss.Color("#" + bgColor.Value()), + lastFg: lipgloss.Color("#" + fgColor.Value()), + update: blurredUpdate, + + nameWidth: nameWidth, + precomputedStyle: lipgloss.NewStyle().Width(nameWidth / 3), + + networkId: networkId, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) View() string { + name := m.name.View() + + iconPreview := networklist.IconStyle(m.icon.Value(), m.lastFg, m.lastBg).String() + iconPreview = lipgloss.NewStyle().Width(m.nameWidth).Align(lipgloss.Center).Render(iconPreview) + + color := colors.Gray + if m.icon.Err != nil { + color = colors.Error + } else if m.selected == IconField { + color = colors.Focus + } + iconInput := underlineStyle(m.icon.View(), MaxIconLength, color) + iconText := m.precomputedStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, iconHeader, iconInput)) + + color = colors.Gray + if m.bgColor.Err != nil { + color = colors.Error + } else if m.selected == BgColorField { + color = colors.Focus + } + bgColorInput := underlineStyle(m.bgColor.View(), MaxHexDigits, color) + bgColorInput = lipgloss.NewStyle().Width(MaxHexDigits + 1).Render(bgColorInput) + bgColorIndicator := lipgloss.NewStyle().Foreground(m.lastBg).Render("■") + bgColorText := lipgloss.JoinHorizontal(lipgloss.Top, bgColorHeader, bgColorInput, bgColorIndicator) + bgColorText = m.precomputedStyle.Render(bgColorText) + + color = colors.Gray + if m.fgColor.Err != nil { + color = colors.Error + } else if m.selected == FgColorField { + color = colors.Focus + } + fgColorInput := underlineStyle(m.fgColor.View(), MaxHexDigits, color) + fgColorInput = lipgloss.NewStyle().Width(MaxHexDigits + 1).Render(fgColorInput) + fgColorIndicator := lipgloss.NewStyle().Foreground(m.lastFg).Render("■") + fgColorText := lipgloss.JoinHorizontal(lipgloss.Top, fgColorHeader, fgColorInput, fgColorIndicator) + fgColorText = m.precomputedStyle.Render(fgColorText) + + icon := lipgloss.JoinHorizontal(lipgloss.Top, fgColorText, bgColorText, iconText) + + privateStyle := lipgloss.NewStyle().PaddingLeft(1) + if m.selected == PrivateField { + privateStyle = privateStyle.Foreground(colors.Focus) + } + private := "[ ] Private" + if m.private { + private = "[x] Private" + } + private = privateStyle.Render(private) + + update := lipgloss.NewStyle().Width(m.nameWidth).Align(lipgloss.Center).Render(m.update) + + content := flex.NewVertical(iconPreview, name, icon, private, update).WithGap(1).View() + return style.Render(content) +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + key := msg.Type + switch key { + case tea.KeyTab: + return m, m.cycle(1) + case tea.KeyShiftTab: + return m, m.cycle(-1) + + default: + var cmd tea.Cmd + switch m.selected { + case NameField: + m.name, cmd = m.name.Update(msg) + case IconField: + m.icon, cmd = m.icon.Update(msg) + case BgColorField: + oldValue := m.bgColor.Value() + position := m.bgColor.Position() + m.bgColor, cmd = m.bgColor.Update(msg) + newValue := m.bgColor.Value() + + hex := "0123456789abcdefABCDEF" + invalid := false + for _, c := range newValue { + if !strings.ContainsRune(hex, c) { + invalid = true + break + } + } + + if invalid { + m.bgColor.SetValue(oldValue) + m.bgColor.SetCursor(position) + } else if len(m.bgColor.Value()) == 6 { + m.lastBg = lipgloss.Color("#" + m.bgColor.Value()) + } + case FgColorField: + oldValue := m.fgColor.Value() + position := m.fgColor.Position() + m.fgColor, cmd = m.fgColor.Update(msg) + newValue := m.fgColor.Value() + + hex := "0123456789abcdefABCDEF" + invalid := false + for _, c := range newValue { + if !strings.ContainsRune(hex, c) { + invalid = true + break + } + } + + if invalid { + m.fgColor.SetValue(oldValue) + m.fgColor.SetCursor(position) + } else if len(m.fgColor.Value()) == 6 { + m.lastFg = lipgloss.Color("#" + m.fgColor.Value()) + } + + } + return m, cmd + } + } + + return m, nil +} + +func (m *Model) cycle(step int) tea.Cmd { + m.selected += step + if m.selected < 0 { + m.selected = FieldCount - 1 + } else { + m.selected %= FieldCount + } + return m.updateFocus() +} + +func (m *Model) updateFocus() tea.Cmd { + m.name.Blur() + m.icon.Blur() + m.bgColor.Blur() + m.fgColor.Blur() + m.update = blurredUpdate + switch m.selected { + case NameField: + return m.name.Focus() + case IconField: + return m.icon.Focus() + case BgColorField: + return m.bgColor.Focus() + case FgColorField: + return m.fgColor.Focus() + case PrivateField: + return nil + case UpdateField: + m.update = focusedUpdate + return nil + default: + assert.Never("missing switch statement field in update focus", "selected", m.selected) + return nil + } +} + +func (m *Model) Select() tea.Cmd { + if m.selected == PrivateField { + m.private = !m.private + return nil + } + + if m.selected != UpdateField { + return nil + } + + m.name.Input.Err = m.name.Input.Validate(m.name.Input.Value()) + m.icon.Err = m.icon.Validate(m.icon.Value()) + m.bgColor.Err = m.bgColor.Validate(m.bgColor.Value()) + m.fgColor.Err = m.fgColor.Validate(m.fgColor.Value()) + if m.name.Input.Err != nil || m.icon.Err != nil || m.bgColor.Err != nil || m.fgColor.Err != nil { + return nil + } + + return gateway.Send(&packet.UpdateNetwork{ + CreateNetwork: packet.CreateNetwork{ + Name: m.name.Input.Value(), + Icon: m.icon.Value(), + BgHexColor: "#" + m.bgColor.Value(), + FgHexColor: "#" + m.fgColor.Value(), + IsPublic: !m.private, + }, + Network: m.networkId, + }) +} diff --git a/internal/client/ui/core/state/state.go b/internal/client/ui/core/state/state.go index d8d47b9..b2f80fe 100644 --- a/internal/client/ui/core/state/state.go +++ b/internal/client/ui/core/state/state.go @@ -73,14 +73,19 @@ func UpdateNetworks(info *packet.NetworksInfo) { } networks[network.ID] = network.Network + + if info.Partial { + continue + } + State.Frequencies[network.ID] = network.Frequencies + for _, member := range network.Members { if State.Members[network.ID] == nil { State.Members[network.ID] = map[snowflake.ID]data.Member{} } State.Members[network.ID][member.UserID] = member } - for _, user := range network.Users { State.Users[user.ID] = user } diff --git a/internal/data/networks.sql.go b/internal/data/networks.sql.go index 5091602..af3c273 100644 --- a/internal/data/networks.sql.go +++ b/internal/data/networks.sql.go @@ -110,3 +110,43 @@ func (q *Queries) TransferNetwork(ctx context.Context, arg TransferNetworkParams ) return i, err } + +const updateNetwork = `-- name: UpdateNetwork :one +UPDATE networks SET + name = ?, icon = ?, + bg_hex_color = ?, fg_hex_color = ?, + is_public = ? +WHERE id = ? +RETURNING id, owner_id, name, icon, bg_hex_color, fg_hex_color, is_public +` + +type UpdateNetworkParams struct { + Name string + Icon string + BgHexColor string + FgHexColor string + IsPublic bool + ID snowflake.ID +} + +func (q *Queries) UpdateNetwork(ctx context.Context, arg UpdateNetworkParams) (Network, error) { + row := q.db.QueryRowContext(ctx, updateNetwork, + arg.Name, + arg.Icon, + arg.BgHexColor, + arg.FgHexColor, + arg.IsPublic, + arg.ID, + ) + var i Network + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.Name, + &i.Icon, + &i.BgHexColor, + &i.FgHexColor, + &i.IsPublic, + ) + return i, err +} diff --git a/internal/packet/models.go b/internal/packet/models.go index e1ffdc5..27b6300 100644 --- a/internal/packet/models.go +++ b/internal/packet/models.go @@ -1,6 +1,7 @@ package packet const ( + MaxNetworkNameBytes = 32 MaxIconBytes = 16 DefaultFrequencyName = "main" DefaultFrequencyColor = "#FFFFFF" diff --git a/internal/packet/types.go b/internal/packet/types.go index c73902b..a790cda 100644 --- a/internal/packet/types.go +++ b/internal/packet/types.go @@ -76,6 +76,7 @@ type FullNetwork struct { type NetworksInfo struct { Networks []FullNetwork RemovedNetworks []snowflake.ID + Partial bool } func (m *NetworksInfo) Type() PacketType { diff --git a/internal/server/api/api.go b/internal/server/api/api.go index 9a0ef76..b11bf36 100644 --- a/internal/server/api/api.go +++ b/internal/server/api/api.go @@ -166,6 +166,11 @@ func CreateNetwork(ctx context.Context, sess *session.Session, request *packet.C if name == "" { return &packet.Error{Error: "server name must not be blank"} } + if len(name) > packet.MaxNetworkNameBytes { + return &packet.Error{Error: fmt.Sprintf( + "network name may not exceed %v bytes", packet.MaxNetworkNameBytes, + )} + } if len(request.Icon) > packet.MaxIconBytes { return &packet.Error{Error: fmt.Sprintf( @@ -251,6 +256,7 @@ func CreateNetwork(ctx context.Context, sess *session.Session, request *packet.C return &packet.NetworksInfo{ Networks: []packet.FullNetwork{fullNetwork}, RemovedNetworks: nil, + Partial: false, } } @@ -299,6 +305,7 @@ func GetNetworksInfo(ctx context.Context, sess *session.Session) (packet.Payload return &packet.NetworksInfo{ Networks: fullNetworks, RemovedNetworks: nil, + Partial: false, }, nil } @@ -460,6 +467,7 @@ func DeleteNetwork(ctx context.Context, sess *session.Session, request *packet.D return NetworkPropagate(ctx, sess, network.ID, &packet.NetworksInfo{ Networks: nil, RemovedNetworks: []snowflake.ID{request.Network}, + Partial: false, }) } @@ -527,6 +535,7 @@ func SetMember(ctx context.Context, sess *session.Session, request *packet.SetMe Users: users, }}, RemovedNetworks: nil, + Partial: false, } } @@ -603,6 +612,7 @@ func SetMember(ctx context.Context, sess *session.Session, request *packet.SetMe return &packet.NetworksInfo{ Networks: nil, RemovedNetworks: []snowflake.ID{request.Network}, + Partial: false, } } @@ -642,6 +652,7 @@ func SetMember(ctx context.Context, sess *session.Session, request *packet.SetMe Users: users, }}, RemovedNetworks: nil, + Partial: false, } } @@ -686,3 +697,67 @@ func GetUserData(ctx context.Context, sess *session.Session, request *packet.Get Data: data, } } + +func UpdateNetwork(ctx context.Context, sess *session.Session, request *packet.UpdateNetwork) packet.Payload { + queries := data.New(db) + + network, err := queries.GetNetworkById(ctx, request.Network) + if err == sql.ErrNoRows { + return &packet.Error{Error: "network doesn't exist"} + } + if err != nil { + log.Println("database error 0:", err) + return &ErrInternalError + } + + if network.OwnerID != sess.ID() { + return &ErrPermissionDenied + } + + name := strings.TrimSpace(request.Name) + if name == "" { + return &packet.Error{Error: "server name must not be blank"} + } + if len(name) > packet.MaxNetworkNameBytes { + return &packet.Error{Error: fmt.Sprintf( + "network name may not exceed %v bytes", packet.MaxNetworkNameBytes, + )} + } + + if len(request.Icon) > packet.MaxIconBytes { + return &packet.Error{Error: fmt.Sprintf( + "exceeded allowed icon size in bytes: %v", packet.MaxIconBytes, + )} + } + + if ok, err := isValidHexColor(request.BgHexColor); !ok { + return &packet.Error{Error: err} + } + if ok, err := isValidHexColor(request.FgHexColor); !ok { + return &packet.Error{Error: err} + } + + network, err = queries.UpdateNetwork(ctx, data.UpdateNetworkParams{ + Name: name, + Icon: request.Icon, + BgHexColor: request.BgHexColor, + FgHexColor: request.FgHexColor, + IsPublic: request.IsPublic, + ID: network.ID, + }) + if err != nil { + log.Println("database error 1:", err) + return &ErrInternalError + } + + return NetworkPropagate(ctx, sess, network.ID, &packet.NetworksInfo{ + Networks: []packet.FullNetwork{{ + Network: network, + Frequencies: nil, + Members: nil, + Users: nil, + }}, + RemovedNetworks: nil, + Partial: true, + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index 327eaa8..3bce2de 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -345,6 +345,8 @@ func processRequest(ctx context.Context, sess *session.Session, request packet.P case *packet.CreateNetwork: response = timeout(10*time.Millisecond, api.CreateNetwork, ctx, sess, request) + case *packet.UpdateNetwork: + response = timeout(5*time.Millisecond, api.UpdateNetwork, ctx, sess, request) case *packet.DeleteNetwork: response = timeout(500*time.Millisecond, api.DeleteNetwork, ctx, sess, request) diff --git a/query/networks.sql b/query/networks.sql index b1d01c7..a92bc66 100644 --- a/query/networks.sql +++ b/query/networks.sql @@ -18,5 +18,13 @@ UPDATE networks SET WHERE id = ? RETURNING *; +-- name: UpdateNetwork :one +UPDATE networks SET + name = ?, icon = ?, + bg_hex_color = ?, fg_hex_color = ?, + is_public = ? +WHERE id = ? +RETURNING *; + -- name: DeleteNetwork :exec DELETE FROM networks WHERE id = ?;