diff --git a/README.md b/README.md index ce6c928..5ce6bb8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Dialtone uses symmetric encryption for message bodies and channel names, and pub ## Client ![Dialtone client screen 1](docs/dialtone-client-01.png) +![Dialtone client screen 2](docs/dialtone-client-02.png) ## Who does what? diff --git a/cmd/client/chat.go b/cmd/client/chat.go index 0b542c2..5bdbb6e 100644 --- a/cmd/client/chat.go +++ b/cmd/client/chat.go @@ -90,6 +90,7 @@ type chatModel struct { activeChannel string channelUnread map[string]int sidebarVisible bool + helpVisible bool sidebarIndex int selectActive bool selectOptions []channelInfo @@ -147,6 +148,7 @@ var teaTick = tea.Tick func newChatModel(api *APIClient, auth *AuthResponse, kp *crypto.KeyPair, keystorePassphrase string, width, height int, voiceIPCAddr string) chatModel { input := textinput.New() + input.Prompt = "" input.Placeholder = "type a message..." input.CharLimit = 16384 input.Width = clampMin(width-8, 20) @@ -184,6 +186,7 @@ func newChatModel(api *APIClient, auth *AuthResponse, kp *crypto.KeyPair, keysto directoryKey: dirKey, channelUnread: make(map[string]int), sidebarVisible: true, + helpVisible: true, sidebarIndex: 0, } if auth != nil && auth.IsTrusted { @@ -279,26 +282,32 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) { } if m.sidebarVisible && m.input.Value() == "" { switch msg.String() { - case "up", "k": + case "ctrl+up": m.moveSidebarSelection(-1) return m, nil - case "down", "j": + case "ctrl+down": m.moveSidebarSelection(1) return m, nil - case "enter": - if m.selectSidebarChannel() { - return m, nil - } } } switch msg.String() { + case "ctrl+m": + return m, nil case "enter": + if m.input.Value() == "" && m.sidebarVisible { + selectedID, ok := m.selectedSidebarChannelID() + if ok && selectedID != m.activeChannel { + if m.selectSidebarChannel() { + return m, nil + } + } + } if m.connected { cmd := m.sendCurrentMessage() return m, cmd } return m, nil - case "ctrl+h", "ctrl+u": + case "ctrl+l": m.sidebarVisible = !m.sidebarVisible m.updateLayout() m.refreshViewport() @@ -308,6 +317,10 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) { } return m, nil + case "ctrl+/", "f1": + m.helpVisible = !m.helpVisible + return m, nil + case "pgup", "pgdown": var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) @@ -565,6 +578,20 @@ func (m *chatModel) handleCommand(raw string) tea.Cmd { } return nil } + if cmd == "/join" || cmd == "/j" { + target := strings.TrimSpace(strings.TrimPrefix(raw, parts[0])) + target = normalizeIRCChannelName(target) + if target == "" { + m.appendSystemMessage("usage: /join ") + return nil + } + m.useChannel(target) + return nil + } + if cmd == "/list" || cmd == "/ls" { + m.refreshChannels(true) + return nil + } if cmd != "/channel" { m.appendSystemMessage("unknown command") return nil @@ -624,11 +651,17 @@ func (m *chatModel) handleCommand(raw string) tea.Cmd { return nil } +func normalizeIRCChannelName(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "#") + return strings.TrimSpace(value) +} + func (m *chatModel) helpText() string { if m.auth.IsAdmin { - return "commands: /help | /voice join [channel] | /voice leave | /voice mute | /voice unmute | /channel list | /channel create | /channel rename | /channel delete | /server invite" + return "commands: /help | /join | /j | /list | /ls | /voice join [channel] | /voice leave | /voice mute | /voice unmute | /channel list | /channel create | /channel rename | /channel delete | /server invite" } - return "commands: /help | /voice join [channel] | /voice leave | /voice mute | /voice unmute | /channel list" + return "commands: /help | /join | /j | /list | /ls | /voice join [channel] | /voice leave | /voice mute | /voice unmute | /channel list" } func (m *chatModel) handleVoiceEvent(msg ipc.Message) { @@ -789,9 +822,9 @@ func (m *chatModel) clearPendingVoiceCommand() { func (m *chatModel) channelHelpText() string { if m.auth.IsAdmin { - return "channel commands: /channel list | create | rename | delete " + return "channel commands: /join | /j | /list | /ls | /channel list | create | rename | delete " } - return "channel commands: /channel list" + return "channel commands: /join | /j | /list | /ls | /channel list" } func (m *chatModel) serverHelpText() string { @@ -1532,15 +1565,8 @@ func (m *chatModel) renderSidebar() string { if name == "" { name = shortID(ch.ID) } - markers := "" - if ch.ID == m.activeChannel { - markers += "* " - } - if ch.ID == voiceChannelID { - markers += "♪ " - } - if markers != "" { - name = markers + name + if ch.ID == m.activeChannel && i != m.sidebarIndex { + name = "• " + name } unread := m.channelUnread[ch.ID] if unread > 0 && ch.ID != m.activeChannel { @@ -1587,9 +1613,6 @@ func (m *chatModel) renderSidebar() string { style = sidebarOnlineStyle } name := formatUsername(entry.Name) - if entry.ID == m.auth.UserID { - name = fmt.Sprintf("%s (you)", name) - } if room.ChannelID == voiceChannelID && entry.Speak { name = fmt.Sprintf("+ %s", name) } @@ -1944,6 +1967,17 @@ func (m *chatModel) moveSidebarSelection(delta int) { m.sidebarIndex = idx } +func (m *chatModel) selectedSidebarChannelID() (string, bool) { + channels := m.channelList() + if len(channels) == 0 { + return "", false + } + if m.sidebarIndex < 0 || m.sidebarIndex >= len(channels) { + return "", false + } + return channels[m.sidebarIndex].ID, true +} + func (m *chatModel) selectSidebarChannel() bool { channels := m.channelList() if len(channels) == 0 { @@ -2045,7 +2079,7 @@ func (m chatModel) View() string { header := fmt.Sprintf( " %s %s %s %s %s", - appNameStyle.Render("* dialtone"), + appNameStyle.Render("› dialtone"), headerStyle.Render(formatUsername(m.auth.Username)), labelStyle.Render(shortID(m.auth.UserID)), labelStyle.Render(m.activeChannelLabel()), @@ -2080,8 +2114,16 @@ func (m chatModel) View() string { if m.errMsg != "" { b.WriteString(errorStyle.Render(" x " + m.errMsg)) + } else if m.helpVisible { + helpLines := m.footerHelpLines() + for i, line := range helpLines { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(helpStyle.Render(" " + line)) + } } else { - b.WriteString(helpStyle.Render(" enter: send - /help for commands - up/down+enter (empty): switch channel - ctrl+u: focus users/channels - pgup/pgdn: scroll - ctrl+h: toggle sidebar - ctrl+q: quit")) + b.WriteString(helpStyle.Render(" ctrl+/ or f1: show help - ctrl+q: quit")) } return b.String() @@ -2129,6 +2171,11 @@ func (m *chatModel) voiceStatusLabel() string { return "voice: off" } +func (m *chatModel) footerHelpLines() []string { + help := "enter: switch selected channel or send · /help: commands · ctrl+up/down: select channel · pgup/pgdn: scroll · ctrl+l: toggle sidebar · ctrl+/ or f1: toggle help · ctrl+q: quit" + return wrapText(help, clampMin(m.width-4, 20)) +} + func (m *chatModel) voiceChannelIndicatorID() string { if m.voiceRoom != "" { return m.voiceRoom diff --git a/cmd/client/chat_test.go b/cmd/client/chat_test.go index 1fe4fb6..dfbd140 100644 --- a/cmd/client/chat_test.go +++ b/cmd/client/chat_test.go @@ -329,8 +329,8 @@ func TestChatModelRenderSidebarAndSelection(t *testing.T) { if !strings.Contains(out, "alpha") || !strings.Contains(out, "(2)") { t.Fatalf("unexpected sidebar output") } - if !strings.Contains(out, "♪ beta") { - t.Fatalf("expected voice marker on joined channel") + if strings.Contains(out, "♪ beta") { + t.Fatalf("did not expect voice marker on joined channel") } if !strings.Contains(out, "+ <"+m.auth.Username+">") { t.Fatalf("expected speaking marker in in-voice section") @@ -687,10 +687,10 @@ func TestChatModelSyncDirectoryPushProfile(t *testing.T) { } } -func TestChatModelUpdateCtrlHTogglesSidebar(t *testing.T) { +func TestChatModelUpdateCtrlLTogglesSidebar(t *testing.T) { m := newChatForTest(t, &APIClient{serverURL: "http://server", httpClient: http.DefaultClient}) m.sidebarVisible = true - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) if updated.sidebarVisible { t.Fatalf("expected sidebar hidden") } @@ -867,6 +867,14 @@ func TestChatModelViewIncludesVoiceStatus(t *testing.T) { } } +func TestChatModelViewUsesSingleInputPrompt(t *testing.T) { + m := newChatForTest(t, &APIClient{serverURL: "http://server", httpClient: http.DefaultClient}) + out := m.View() + if strings.Contains(out, " > >") { + t.Fatalf("expected single input prompt") + } +} + func TestChatModelHandleVoiceMembersEvent(t *testing.T) { m := newChatForTest(t, &APIClient{serverURL: "http://server", httpClient: http.DefaultClient}) m.channels["ch-1"] = channelInfo{ID: "ch-1", Name: "general"} @@ -886,8 +894,8 @@ func TestChatModelHandleVoiceMembersEvent(t *testing.T) { } out := m.renderSidebar() - if !strings.Contains(out, "In Voice") || !strings.Contains(out, "(you)") { - t.Fatalf("expected in-voice roster rendering") + if !strings.Contains(out, "In Voice") || strings.Contains(out, "(you)") { + t.Fatalf("expected in-voice roster rendering without self label") } } @@ -1202,25 +1210,12 @@ func TestChatModelUpdateWindowAndPaging(t *testing.T) { _, _ = updated.Update(tea.KeyMsg{Type: tea.KeyPgUp}) } -func TestChatModelUpdateCtrlUTogglesSidebar(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/presence" { - w.WriteHeader(http.StatusNotFound) - return - } - _ = json.NewEncoder(w).Encode(PresenceResponse{Statuses: map[string]bool{"u1": true}}) - })) - defer server.Close() - - api := &APIClient{serverURL: server.URL, httpClient: server.Client()} - m := newChatForTest(t, api) - m.userNames["u1"] = "alice" - updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlU}) - if updated.sidebarVisible { - t.Fatalf("expected sidebar hidden") - } - if cmd != nil { - t.Fatalf("expected no command when hiding sidebar") +func TestChatModelUpdateCtrlUDoesNotToggleSidebar(t *testing.T) { + m := newChatForTest(t, &APIClient{serverURL: "http://server", httpClient: http.DefaultClient}) + m.sidebarVisible = true + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlU}) + if !updated.sidebarVisible { + t.Fatalf("expected sidebar to remain visible") } } @@ -1915,17 +1910,45 @@ func TestChatModelHandleCommandAdditionalBranches(t *testing.T) { m := newChatForTest(t, &APIClient{serverURL: server.URL, httpClient: server.Client()}) m.auth.IsAdmin = true + m.channels["general"] = channelInfo{ID: "general", Name: "general"} + m.channelHistoryLoaded["general"] = true m.handleCommand("/channel help") if !strings.Contains(lastSystemMessage(m), "channel commands") { t.Fatalf("expected channel help") } + m.handleCommand("/list") + if lastSystemMessage(m) != "no channels yet" { + t.Fatalf("unexpected /list message: %q", lastSystemMessage(m)) + } + + m.handleCommand("/ls") + if lastSystemMessage(m) != "no channels yet" { + t.Fatalf("unexpected /ls message: %q", lastSystemMessage(m)) + } + m.handleCommand("/channel list") if lastSystemMessage(m) != "no channels yet" { t.Fatalf("unexpected list message: %q", lastSystemMessage(m)) } + m.handleCommand("/join #general") + if m.activeChannel != "general" { + t.Fatalf("expected /join to switch to channel") + } + + m.activeChannel = "" + m.handleCommand("/j #general") + if m.activeChannel != "general" { + t.Fatalf("expected /j to switch to channel") + } + + m.handleCommand("/join") + if lastSystemMessage(m) != "usage: /join " { + t.Fatalf("unexpected /join usage message: %q", lastSystemMessage(m)) + } + m.handleCommand("/channel unknown") if lastSystemMessage(m) != "unknown channel command" { t.Fatalf("unexpected message: %q", lastSystemMessage(m)) @@ -2316,8 +2339,13 @@ func TestChatModelUpdateAdditionalMessageBranches(t *testing.T) { m.input.SetValue("") updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) - if updated.sidebarIndex == 0 { - t.Fatalf("expected sidebar navigation to move selection") + if updated.sidebarIndex != 0 { + t.Fatalf("expected plain down arrow to not move sidebar selection") + } + + updated, _ = updated.Update(tea.KeyMsg{Type: tea.KeyCtrlDown}) + if updated.sidebarIndex != 1 { + t.Fatalf("expected ctrl+down to move sidebar selection") } updated.connected = false @@ -2366,6 +2394,71 @@ func TestChatModelUpdateAdditionalMessageBranches(t *testing.T) { } } +func TestChatModelUpdateEnterSendsDraftInsteadOfSwitching(t *testing.T) { + m := newChatForTest(t, &APIClient{serverURL: "http://server", httpClient: http.DefaultClient}) + m.channels["ch-1"] = channelInfo{ID: "ch-1", Name: "general"} + m.channels["ch-2"] = channelInfo{ID: "ch-2", Name: "random"} + m.channelHistoryLoaded["ch-1"] = true + m.channelHistoryLoaded["ch-2"] = true + m.sidebarVisible = true + m.activeChannel = "ch-1" + m.sidebarIndex = 1 + m.connected = true + m.ws = &WSClient{closed: true} + m.input.SetValue("hello") + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if updated.activeChannel != "ch-1" { + t.Fatalf("expected enter with draft to stay on current channel") + } + if updated.input.Value() != "hello" { + t.Fatalf("expected input to remain unchanged after failed send") + } +} + +func TestChatModelUpdateEnterSwitchesWhenDraftEmpty(t *testing.T) { + m := newChatForTest(t, &APIClient{serverURL: "http://server", httpClient: http.DefaultClient}) + m.channels["ch-1"] = channelInfo{ID: "ch-1", Name: "general"} + m.channels["ch-2"] = channelInfo{ID: "ch-2", Name: "random"} + m.channelHistoryLoaded["ch-1"] = true + m.channelHistoryLoaded["ch-2"] = true + m.sidebarVisible = true + m.activeChannel = "ch-1" + m.sidebarIndex = 1 + m.connected = true + m.ws = &WSClient{closed: true} + m.input.SetValue("") + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if updated.activeChannel != "ch-2" { + t.Fatalf("expected enter to switch selected sidebar channel when draft is empty") + } +} + +func TestChatModelUpdateCtrlArrowIgnoredWhileDrafting(t *testing.T) { + m := newChatForTest(t, &APIClient{serverURL: "http://server", httpClient: http.DefaultClient}) + m.channels["ch-1"] = channelInfo{ID: "ch-1", Name: "general"} + m.channels["ch-2"] = channelInfo{ID: "ch-2", Name: "random"} + m.sidebarVisible = true + m.sidebarIndex = 0 + m.input.SetValue("hello") + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlDown}) + if updated.sidebarIndex != 0 { + t.Fatalf("expected ctrl+down to be ignored while drafting") + } +} + +func TestChatModelUpdateF1TogglesHelp(t *testing.T) { + m := newChatForTest(t, &APIClient{serverURL: "http://server", httpClient: http.DefaultClient}) + m.helpVisible = true + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyF1}) + if updated.helpVisible { + t.Fatalf("expected f1 to hide help") + } +} + func TestChatModelUpdateVoiceIPCPaths(t *testing.T) { addr := filepath.Join(t.TempDir(), "voice-update.sock") listener, err := ipc.Listen(addr) @@ -2484,7 +2577,7 @@ func TestChatModelSendCurrentMessageAndHelpersEdgeCases(t *testing.T) { t.Fatalf("expected admin channel help text") } m.auth.IsAdmin = false - if got := m.channelHelpText(); got != "channel commands: /channel list" { + if got := m.channelHelpText(); !strings.Contains(got, "/join ") || !strings.Contains(got, "/channel list") { t.Fatalf("unexpected non-admin channel help text: %q", got) } if got := m.serverHelpText(); got != "admin only: server invites" { diff --git a/cmd/client/login.go b/cmd/client/login.go index 10b4ef1..ff1919c 100644 --- a/cmd/client/login.go +++ b/cmd/client/login.go @@ -265,7 +265,7 @@ func (m loginModel) View() string { } b.WriteString(strings.Repeat("\n", topPad)) - b.WriteString(centerText(appNameStyle.Render("* dialtone"), m.width)) + b.WriteString(centerText(appNameStyle.Render("› dialtone"), m.width)) b.WriteString("\n") b.WriteString(centerText(subtitleStyle.Render("encrypted messaging"), m.width)) b.WriteString("\n\n") diff --git a/docs/dialtone-client-01.png b/docs/dialtone-client-01.png index 37dcb1d..82b70a1 100644 Binary files a/docs/dialtone-client-01.png and b/docs/dialtone-client-01.png differ diff --git a/docs/dialtone-client-02.png b/docs/dialtone-client-02.png new file mode 100644 index 0000000..c1fc274 Binary files /dev/null and b/docs/dialtone-client-02.png differ