Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions cmd/slk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,7 @@ func run() error {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

params := make([]slack.UploadFileParameters, 0, len(attachments))
for i, att := range attachments {
p.Send(ui.UploadProgressMsg{Done: i, Total: len(attachments)})

Expand All @@ -1119,14 +1120,15 @@ func run() error {
reader = f
}

currentCaption := ""
if i == len(attachments)-1 {
currentCaption = caption
}
params = append(params, slack.UploadFileParameters{
Filename: att.Filename,
Reader: reader,
FileSize: int(att.Size),
})
}

if _, err := client.UploadFile(ctx, channelID, threadTS, att.Filename, reader, att.Size, currentCaption); err != nil {
return ui.UploadResultMsg{Err: fmt.Errorf("uploading %s (%d/%d): %w", att.Filename, i+1, len(attachments), err)}
}
if _, err := client.UploadFiles(ctx, channelID, threadTS, params, caption); err != nil {
return ui.UploadResultMsg{Err: err}
}
p.Send(ui.UploadProgressMsg{Done: len(attachments), Total: len(attachments)})
return ui.UploadResultMsg{Err: nil}
Expand Down
58 changes: 58 additions & 0 deletions internal/slack/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ type SlackAPI interface {
EndDNDContext(ctx context.Context) error
GetDNDInfoContext(ctx context.Context, user *string, options ...slack.ParamOption) (*slack.DNDStatus, error)
UploadFileContext(ctx context.Context, params slack.UploadFileParameters) (*slack.FileSummary, error)
GetUploadURLExternalContext(ctx context.Context, params slack.GetUploadURLExternalParameters) (*slack.GetUploadURLExternalResponse, error)
UploadToURL(ctx context.Context, params slack.UploadToURLParameters) error
CompleteUploadExternalContext(ctx context.Context, params slack.CompleteUploadExternalParameters) (*slack.CompleteUploadExternalResponse, error)
}

// defaultAPIBaseURL is the canonical Slack Web API root used as a fallback
Expand Down Expand Up @@ -679,6 +682,61 @@ func (c *Client) UploadFile(
return f, nil
}

// UploadFiles uploads multiple files and completes them in a single share so
// Slack renders them under one message. caption, when non-empty, is applied to
// the shared message created by files.completeUploadExternal.
func (c *Client) UploadFiles(
ctx context.Context,
channelID, threadTS string,
files []slack.UploadFileParameters,
caption string,
) ([]slack.FileSummary, error) {
if len(files) == 0 {
return nil, nil
}
uploads := make([]slack.FileSummary, 0, len(files))
for _, file := range files {
if file.Filename == "" {
return nil, fmt.Errorf("uploading file: filename cannot be empty")
}
if file.FileSize == 0 {
return nil, fmt.Errorf("uploading %q: file size cannot be 0", file.Filename)
}
u, err := c.api.GetUploadURLExternalContext(ctx, slack.GetUploadURLExternalParameters{
AltTxt: file.AltTxt,
FileName: file.Filename,
FileSize: file.FileSize,
SnippetType: file.SnippetType,
})
if err != nil {
return nil, fmt.Errorf("get upload URL for %q: %w", file.Filename, err)
}
if err := c.api.UploadToURL(ctx, slack.UploadToURLParameters{
UploadURL: u.UploadURL,
Reader: file.Reader,
File: file.File,
Content: file.Content,
Filename: file.Filename,
}); err != nil {
return nil, fmt.Errorf("uploading %q to external URL: %w", file.Filename, err)
}
uploads = append(uploads, slack.FileSummary{ID: u.FileID, Title: file.Title})
}
resp, err := c.api.CompleteUploadExternalContext(ctx, slack.CompleteUploadExternalParameters{
Files: uploads,
Channel: channelID,
InitialComment: caption,
ThreadTimestamp: threadTS,
})
if err != nil {
return nil, fmt.Errorf("complete external upload: %w", err)
}
if len(resp.Files) != len(files) {
return nil, fmt.Errorf("complete external upload: got %d files, want %d", len(resp.Files), len(files))
}
return resp.Files, nil
}

// UnreadInfo holds the unread state for a single channel.
type UnreadInfo struct {
ChannelID string
Expand Down
73 changes: 73 additions & 0 deletions internal/slack/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ type mockSlackAPI struct {
endDNDContextFn func(ctx context.Context) error
getDNDInfoContextFn func(ctx context.Context, user *string, options ...slack.ParamOption) (*slack.DNDStatus, error)
uploadFileContextFn func(ctx context.Context, params slack.UploadFileParameters) (*slack.FileSummary, error)
getUploadURLExternalContextFn func(ctx context.Context, params slack.GetUploadURLExternalParameters) (*slack.GetUploadURLExternalResponse, error)
uploadToURLFn func(ctx context.Context, params slack.UploadToURLParameters) error
completeUploadExternalContextFn func(ctx context.Context, params slack.CompleteUploadExternalParameters) (*slack.CompleteUploadExternalResponse, error)
getUsersInConversationContextFn func(ctx context.Context, params *slack.GetUsersInConversationParameters) ([]string, string, error)
}

Expand Down Expand Up @@ -275,6 +278,27 @@ func (m *mockSlackAPI) UploadFileContext(ctx context.Context, params slack.Uploa
return &slack.FileSummary{}, nil
}

func (m *mockSlackAPI) GetUploadURLExternalContext(ctx context.Context, params slack.GetUploadURLExternalParameters) (*slack.GetUploadURLExternalResponse, error) {
if m.getUploadURLExternalContextFn != nil {
return m.getUploadURLExternalContextFn(ctx, params)
}
return &slack.GetUploadURLExternalResponse{}, nil
}

func (m *mockSlackAPI) UploadToURL(ctx context.Context, params slack.UploadToURLParameters) error {
if m.uploadToURLFn != nil {
return m.uploadToURLFn(ctx, params)
}
return nil
}

func (m *mockSlackAPI) CompleteUploadExternalContext(ctx context.Context, params slack.CompleteUploadExternalParameters) (*slack.CompleteUploadExternalResponse, error) {
if m.completeUploadExternalContextFn != nil {
return m.completeUploadExternalContextFn(ctx, params)
}
return &slack.CompleteUploadExternalResponse{}, nil
}

func (m *mockSlackAPI) GetUsersInConversationContext(ctx context.Context, params *slack.GetUsersInConversationParameters) ([]string, string, error) {
if m.getUsersInConversationContextFn != nil {
return m.getUsersInConversationContextFn(ctx, params)
Expand Down Expand Up @@ -361,6 +385,55 @@ func TestUploadFile_ErrorWraps(t *testing.T) {
}
}

func TestUploadFiles_SharesBatchWithSingleComment(t *testing.T) {
var complete slack.CompleteUploadExternalParameters
var uploaded []string
mock := &mockSlackAPI{
getUploadURLExternalContextFn: func(ctx context.Context, params slack.GetUploadURLExternalParameters) (*slack.GetUploadURLExternalResponse, error) {
return &slack.GetUploadURLExternalResponse{UploadURL: "https://upload.example/" + params.FileName, FileID: "F-" + params.FileName}, nil
},
uploadToURLFn: func(ctx context.Context, params slack.UploadToURLParameters) error {
uploaded = append(uploaded, params.Filename)
return nil
},
completeUploadExternalContextFn: func(ctx context.Context, params slack.CompleteUploadExternalParameters) (*slack.CompleteUploadExternalResponse, error) {
complete = params
return &slack.CompleteUploadExternalResponse{Files: params.Files}, nil
},
}
c := &Client{api: mock}
files := []slack.UploadFileParameters{
{Filename: "a.png", Reader: strings.NewReader("aaa"), FileSize: 3, Title: "A"},
{Filename: "b.png", Reader: strings.NewReader("bbbb"), FileSize: 4, Title: "B"},
}

got, err := c.UploadFiles(context.Background(), "C1", "123.456", files, "caption")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 uploaded files, got %d", len(got))
}
if len(uploaded) != 2 || uploaded[0] != "a.png" || uploaded[1] != "b.png" {
t.Fatalf("unexpected uploaded sequence: %#v", uploaded)
}
if complete.Channel != "C1" {
t.Fatalf("expected channel C1, got %q", complete.Channel)
}
if complete.ThreadTimestamp != "123.456" {
t.Fatalf("expected thread ts preserved, got %q", complete.ThreadTimestamp)
}
if complete.InitialComment != "caption" {
t.Fatalf("expected shared comment caption, got %q", complete.InitialComment)
}
if len(complete.Files) != 2 {
t.Fatalf("expected 2 file summaries, got %d", len(complete.Files))
}
if complete.Files[0].ID != "F-a.png" || complete.Files[1].ID != "F-b.png" {
t.Fatalf("unexpected file IDs: %#v", complete.Files)
}
}

func TestClient_SetUserPresence(t *testing.T) {
var calls int
var gotPresence string
Expand Down
95 changes: 95 additions & 0 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/gammons/slk/internal/ui/channelpicker"
"github.com/gammons/slk/internal/ui/compose"
"github.com/gammons/slk/internal/ui/confirmprompt"
"github.com/gammons/slk/internal/ui/filepicker"
"github.com/gammons/slk/internal/ui/help"
"github.com/gammons/slk/internal/ui/imgrender"
"github.com/gammons/slk/internal/ui/mentionpicker"
Expand Down Expand Up @@ -707,6 +708,7 @@ type App struct {
statusbar statusbar.Model
channelFinder channelfinder.Model
workspaceFinder workspacefinder.Model
filePicker filepicker.Model
themeSwitcher themeswitcher.Model
presenceMenu presencemenu.Model
help help.Model
Expand Down Expand Up @@ -1020,6 +1022,7 @@ func NewApp() *App {
statusbar: statusbar.New(),
channelFinder: channelfinder.New(),
workspaceFinder: workspacefinder.New(),
filePicker: filepicker.New(),
themeSwitcher: themeswitcher.New(),
presenceMenu: presencemenu.New(),
help: help.New(),
Expand Down Expand Up @@ -2795,6 +2798,8 @@ func (a *App) handleKey(msg tea.KeyMsg) tea.Cmd {
return a.handleConfirmMode(msg)
case ModeWorkspaceFinder:
return a.handleWorkspaceFinderMode(msg)
case ModeFilePicker:
return a.handleFilePickerMode(msg)
case ModeThemeSwitcher:
return a.handleThemeSwitcherMode(msg)
case ModePresenceMenu:
Expand Down Expand Up @@ -3283,6 +3288,9 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd {
if isPaste {
return a.smartPaste()
}
if key.Matches(msg, a.keys.AttachFile) {
return a.openFilePicker()
}

// Insert-mode shortcuts that operate on the active compose:
// Ctrl+U → clear compose (text + attachments + uploading flag)
Expand All @@ -3296,6 +3304,20 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd {
target.Reset()
return nil
}
if len(target.Attachments()) > 0 {
switch {
case code == tea.KeyLeft && mod.Contains(tea.ModAlt), code == 'h' && mod == tea.ModCtrl:
target.SelectPrevAttachment()
return nil
case code == tea.KeyRight && mod.Contains(tea.ModAlt), code == 'l' && mod == tea.ModCtrl:
target.SelectNextAttachment()
return nil
case code == tea.KeyDelete || (code == tea.KeyBackspace && target.SelectedAttachmentIndex() >= 0 && target.Value() == ""):
if _, ok := target.RemoveSelectedAttachment(); ok {
return nil
}
}
}
// If a compose-overlay picker (emoji / @mention / #channel) is active,
// let it own Up/Down so users can navigate the suggestion list. Without
// this guard, the jump-to-start/end shortcuts below swallow the arrow
Expand Down Expand Up @@ -3503,6 +3525,37 @@ func (a *App) handleWorkspaceFinderMode(msg tea.KeyMsg) tea.Cmd {
return nil
}

func (a *App) handleFilePickerMode(msg tea.KeyMsg) tea.Cmd {
keyStr := msg.String()
switch msg.Key().Code {
case tea.KeyEnter:
keyStr = "enter"
case tea.KeyEscape:
keyStr = "esc"
case tea.KeyUp:
keyStr = "up"
case tea.KeyDown:
keyStr = "down"
case tea.KeyBackspace:
keyStr = "backspace"
case tea.KeyLeft:
keyStr = "h"
}
result := a.filePicker.HandleKey(keyStr)
if result != nil {
a.SetMode(ModeInsert)
return a.attachFileToActiveCompose(result.Path)
}
if !a.filePicker.IsVisible() {
a.SetMode(ModeInsert)
if a.focusedPanel == PanelThread && a.threadVisible {
return a.threadCompose.Focus()
}
return a.compose.Focus()
}
return nil
}

func (a *App) handleThemeSwitcherMode(msg tea.KeyMsg) tea.Cmd {
keyStr := msg.String()
switch msg.Key().Code {
Expand Down Expand Up @@ -5903,6 +5956,10 @@ func (a *App) View() tea.View {
screen = a.workspaceFinder.ViewOverlay(a.width, a.height, screen)
}

if a.filePicker.IsVisible() {
screen = a.filePicker.ViewOverlay(a.width, a.height, screen)
}

if a.themeSwitcher.IsVisible() {
screen = a.themeSwitcher.ViewOverlay(a.width, a.height, screen)
}
Expand Down Expand Up @@ -5936,6 +5993,7 @@ func (a *App) View() tea.View {
a.reactionPicker.IsVisible() ||
a.confirmPrompt.IsVisible() ||
a.workspaceFinder.IsVisible() ||
a.filePicker.IsVisible() ||
a.themeSwitcher.IsVisible() ||
a.presenceMenu.IsVisible() ||
a.mode == ModePresenceCustomSnooze ||
Expand Down Expand Up @@ -6074,6 +6132,43 @@ func (a *App) smartPaste() tea.Cmd {
return nil
}

func (a *App) openFilePicker() tea.Cmd {
if a.compose.Uploading() || a.threadCompose.Uploading() {
return a.uploadToastCmd("Upload in progress", 2*time.Second)
}
a.filePicker.Open()
a.SetMode(ModeFilePicker)
return nil
}

func (a *App) attachFileToActiveCompose(path string) tea.Cmd {
target := &a.compose
if a.focusedPanel == PanelThread && a.threadVisible {
target = &a.threadCompose
}
info, err := os.Stat(path)
if err != nil {
return a.uploadToastCmd("Cannot attach: "+truncateReason(err.Error(), 40), 3*time.Second)
}
if !info.Mode().IsRegular() {
return a.uploadToastCmd("Cannot attach: not a regular file", 3*time.Second)
}
if info.Size() > maxAttachmentSize {
return a.uploadToastCmd("File too large (>10 MB limit)", 3*time.Second)
}
if info.Size() == 0 {
return a.uploadToastCmd("Empty file", 2*time.Second)
}
filename := filepath.Base(path)
target.AddAttachment(compose.PendingAttachment{
Filename: filename,
Path: path,
Mime: mime.TypeByExtension(filepath.Ext(path)),
Size: info.Size(),
})
return a.uploadToastCmd(fmt.Sprintf("Attached: %s (%s)", filename, humanSize(info.Size())), 2*time.Second)
}

// tryAttachFromClipboard inspects the OS clipboard for an image and the
// supplied text for a file-path reference, attaching the first match
// to the given compose. Returns consumed=true if an attachment (or an
Expand Down
Loading