package main import ( "fmt" "flag" "os/exec" "runtime" "context" "path/filepath" "os" "slices" "strings" "time" "github.com/jackc/pgx/v5" "github.com/joho/godotenv" "github.com/gdamore/tcell/v2" "github.com/mattn/go-runewidth" ) var SHORT_TWEET_SIZE int = 180 func newApp() (*App, error) { screen, err := tcell.NewScreen() if err != nil { return nil, fmt.Errorf("failed to create screen: %v", err) } if err := screen.Init(); err != nil { return nil, fmt.Errorf("failed to initialize screen: %v", err) } return &App{ screen: screen, selectedIdx: 0, currentPage: 0, }, nil } func (a *App) loadTweets(accountsList string) ([]Tweet, error) { if err := godotenv.Load(".env"); err != nil { return []Tweet{}, fmt.Errorf("error loading .env file: %v", err) } url := os.Getenv("DATABASE_POOL_URL") if url == "" { return []Tweet{}, fmt.Errorf("DATABASE_POOL_URL environment variable not set") } ctx := context.Background() conn, err := pgx.Connect(ctx, url) if err != nil { return []Tweet{}, fmt.Errorf("in client, failed to connect to database: %v", err) } defer conn.Close(ctx) rows, err := conn.Query(ctx, "SELECT tweet_id, tweet_text, username, created_at FROM tweets0x001 WHERE created_at >= NOW() - INTERVAL '37 day' ORDER BY created_at DESC") // 37 day if err != nil { return []Tweet{}, fmt.Errorf("failed to query tweets: %v", err) } defer rows.Close() validAccounts, err := getAccounts(accountsList) if err != nil { return []Tweet{}, fmt.Errorf("didn't get accounts: %v", err) } var sources []Tweet for rows.Next() { var s Tweet var date time.Time err := rows.Scan(&s.ID, &s.Text, &s.Username, &date) s.CreatedAt = date.Format("2006-01-02 15:04:05") if err != nil { return []Tweet{}, fmt.Errorf("failed to scan row: %v", err) } if slices.Contains(validAccounts, s.Username) { sources = append(sources, s) } } return sources, nil } func getAccounts(accountsList string) ([]string, error){ accountsDir := "./lists" accountsPath := filepath.Join(accountsDir, accountsList+".txt") accountsData, err := os.ReadFile(accountsPath) if err != nil { return []string{}, fmt.Errorf("error reading accounts file: %v", err) } accounts := strings.Split(strings.TrimSpace(string(accountsData)), "\n") // Filter out empty and commented accounts var validAccounts []string for _, account := range accounts { if account = strings.TrimSpace(account); account != "" && !strings.HasPrefix(account, "#") { validAccounts = append(validAccounts, account) } } return validAccounts, nil } func (a *App) Run() error { // Try to load from cache allTweets, err := a.loadTweets(a.accountsList) if err != nil { return err } // Filter tweets based on length preferences and username a.tweets = make([]Tweet, 0, len(allTweets)) for _, tweet := range allTweets { // Skip if username filter is set and doesn't match if a.filterUsername != "" && tweet.Username != a.filterUsername { continue } tweetLength := len(tweet.Text) if (a.shortTweetsOnly && tweetLength < SHORT_TWEET_SIZE) || (a.longTweetsOnly && tweetLength >= SHORT_TWEET_SIZE) || (!a.shortTweetsOnly && !a.longTweetsOnly) { a.tweets = append(a.tweets, tweet) } } if len(a.tweets) == 0 { if a.filterUsername != "" { return fmt.Errorf("no tweets found from user @%s", a.filterUsername) } return fmt.Errorf("no tweets match the current length filter") } for { a.draw() switch ev := a.screen.PollEvent().(type) { case *tcell.EventKey: startIdx, endIdx := a.getPageBoundaries(a.currentPage) switch ev.Key() { case tcell.KeyEscape, tcell.KeyCtrlC: return nil case tcell.KeyUp: if a.selectedIdx > 0 { a.selectedIdx-- if a.selectedIdx < startIdx { a.currentPage-- } } case tcell.KeyDown: if a.selectedIdx < len(a.tweets)-1 { a.selectedIdx++ if a.selectedIdx >= endIdx { a.currentPage++ } } case tcell.KeyRight: nextStart, _ := a.getPageBoundaries(a.currentPage + 1) if nextStart < len(a.tweets) { a.currentPage++ a.selectedIdx = nextStart } case tcell.KeyLeft: if a.currentPage > 0 { a.currentPage-- prevStart, _ := a.getPageBoundaries(a.currentPage) a.selectedIdx = prevStart } case tcell.KeyRune: switch ev.Rune() { case 'h': if a.currentPage > 0 { a.currentPage-- prevStart, _ := a.getPageBoundaries(a.currentPage) a.selectedIdx = prevStart } case 'j': if a.selectedIdx < len(a.tweets)-1 { a.selectedIdx++ if a.selectedIdx >= endIdx { a.currentPage++ } } case 'k': if a.selectedIdx > 0 { a.selectedIdx-- if a.selectedIdx < startIdx { a.currentPage-- } } case 'l': nextStart, _ := a.getPageBoundaries(a.currentPage + 1) if nextStart < len(a.tweets) { a.currentPage++ a.selectedIdx = nextStart } case 'q', 'Q': return nil case 'o', 'O', 'r', 'R': if err := a.handleCommand(ev.Rune()); err != nil { return err } } } case *tcell.EventResize: a.screen.Sync() } } } func (a *App) getPageBoundaries(page int) (startIdx, endIdx int) { width, height := a.screen.Size() availableHeight := height - 0 // Find start index by walking through previous pages startIdx = 0 currentY := 0 for p := 0; p < page; p++ { // Find where this page ends for startIdx < len(a.tweets) { tweetHeight := a.calculateTweetHeight(a.tweets[startIdx], width) if currentY+tweetHeight > availableHeight { currentY = 0 break } currentY += tweetHeight startIdx++ } } // Find end index by calculating how many tweets fit from the start endIdx = startIdx currentY = 0 for endIdx < len(a.tweets) { tweetHeight := a.calculateTweetHeight(a.tweets[endIdx], width) if currentY+tweetHeight > availableHeight { break } currentY += tweetHeight endIdx++ } return startIdx, endIdx } func convertToNormal(text string) string { replacements := map[rune]rune{ // Lowercase mappings '𝗮': 'a', '𝗯': 'b', '𝗰': 'c', '𝗱': 'd', '𝗲': 'e', '𝗳': 'f', '𝗴': 'g', '𝗵': 'h', '𝗶': 'i', '𝗷': 'j', '𝗸': 'k', '𝗹': 'l', '𝗺': 'm', '𝗻': 'n', '𝗼': 'o', '𝗽': 'p', '𝗾': 'q', '𝗿': 'r', '𝘀': 's', '𝘁': 't', '𝘂': 'u', '𝘃': 'v', '𝘄': 'w', '𝘅': 'x', '𝘆': 'y', '𝘇': 'z', // Uppercase mappings '𝗔': 'A', '𝗕': 'B', '𝗖': 'C', '𝗗': 'D', '𝗘': 'E', '𝗙': 'F', '𝗚': 'G', '𝗛': 'H', '𝗜': 'I', '𝗝': 'J', '𝗞': 'K', '𝗟': 'L', '𝗠': 'M', '𝗡': 'N', '𝗢': 'O', '𝗣': 'P', '𝗤': 'Q', '𝗥': 'R', '𝗦': 'S', '𝗧': 'T', '𝗨': 'U', '𝗩': 'V', '𝗪': 'W', '𝗫': 'X', '𝗬': 'Y', '𝗭': 'Z', } var result strings.Builder for _, char := range text { if normalChar, found := replacements[char]; found { result.WriteRune(normalChar) } else { result.WriteRune(char) // if character is not in map, keep it as is } } return result.String() } func (a *App) draw() { a.screen.Clear() width, height := a.screen.Size() style := tcell.StyleDefault.Background(tcell.ColorReset).Foreground(tcell.ColorWhite) selectedStyle := tcell.StyleDefault.Background(tcell.Color24).Foreground(tcell.ColorWhite) // Get the tweets that fit on this page startIdx, endIdx := a.getPageBoundaries(a.currentPage) // Draw the tweets currentY := 0 for idx := startIdx; idx < endIdx; idx++ { tweet := a.tweets[idx] currentStyle := style if idx == a.selectedIdx { currentStyle = selectedStyle } // Cap tweet text length at 2500 characters text := tweet.Text if len(text) > 2500 { text = text[:2500] + "..." } text = convertToNormal(text) tweetLine := fmt.Sprintf("[%d] @%s: %s | %s", idx, tweet.Username, text, tweet.CreatedAt) lastY := drawText(a.screen, 0, currentY, width, currentStyle, tweetLine) currentY = lastY + 1 } // Draw help text at the bottom helpText := "^/v: Navigate | <>: Change Page | o: Open | r: Reply | q: Quit" if height > 0 { drawText(a.screen, 0, height-1, width, style, helpText) } a.screen.Show() } func (a *App) calculateTweetHeight(tweet Tweet, width int) int { // Cap tweet text length at 2500 characters text := tweet.Text if len(text) > 2500 { text = text[:2500] + "..." } if tweet.Username == "VOCPEnglish" { text = convertToNormal(text) } // Format the tweet line as it will be displayed tweetLine := fmt.Sprintf("[%d] @%s: %s | %s", tweet.ID, tweet.Username, text, tweet.CreatedAt) // Count how many lines this tweet will take using proper Unicode width lines := 1 currentLineWidth := 0 for _, r := range tweetLine { runeWidth := runewidth.RuneWidth(r) if currentLineWidth+runeWidth > width { lines++ currentLineWidth = runeWidth } else { currentLineWidth += runeWidth } } return lines } func drawText(screen tcell.Screen, x, y, maxWidth int, style tcell.Style, text string) int { currentX := x currentY := y for _, r := range text { runeWidth := runewidth.RuneWidth(r) if currentX+runeWidth > x+maxWidth { // Move to next line currentY++ currentX = x } screen.SetContent(currentX, currentY, r, nil, style) currentX += runeWidth } return currentY } func (a *App) handleCommand(cmd rune) error { if len(a.tweets) == 0 { return nil } currentTweet := a.tweets[a.selectedIdx] switch cmd { case 'o', 'O': // Open tweet in browser tweetURL := fmt.Sprintf("https://twitter.com/user/status/%s", currentTweet.ID) return openBrowser(tweetURL) case 'r': // Get reply text from user reply := a.getInput("Reply: ") if reply == "" { return nil // User cancelled } // Save current screen state and clean up terminal a.screen.Fini() fmt.Print("\033[H\033[2J") // Clear screen // Cap tweet text length for display text := currentTweet.Text if len(text) > 2500 { text = text[:2500] + "..." } // Show feedback fmt.Printf("Sending reply to tweet: %s\n", text) fmt.Printf("Your reply: %s\n", reply) // Construct the command that will be executed cmdStr := fmt.Sprintf("tweet -d %s %q", currentTweet.ID, reply) // Show the command that will be executed fmt.Printf("\nExecuting command:\n$ %s\n\n", cmdStr) // Use split to separate command and its arguments cmdArgs := []string{"-i", "-c", cmdStr} bashCmd := exec.Command("bash", cmdArgs...) // Use exec.Command directly, no need for quotes err := bashCmd.Run() // Show result and wait for user input if err != nil { fmt.Printf("\nError sending reply: %v\n", err) } else { fmt.Println("\nReply sent successfully!") } fmt.Print("\nPress Enter to continue...") fmt.Scanln() // Wait for Enter key // Reinitialize screen screen, err := tcell.NewScreen() if err != nil { return fmt.Errorf("failed to create new screen: %v", err) } if err := screen.Init(); err != nil { return fmt.Errorf("failed to initialize new screen: %v", err) } a.screen = screen /* case 'R': allTweets, ok := loadFromCache(a.accountsList) if !ok { return fmt.Errorf("no cached tweets available - please run fetcher first") } // Filter tweets based on length preferences and username a.tweets = make([]Tweet, 0, len(allTweets)) for _, tweet := range allTweets { // Skip if username filter is set and doesn't match if a.filterUsername != "" && tweet.Username != a.filterUsername { continue } tweetLength := len(tweet.Text) if (a.shortTweetsOnly && tweetLength < SHORT_TWEET_SIZE) || (a.longTweetsOnly && tweetLength >= SHORT_TWEET_SIZE) || (!a.shortTweetsOnly && !a.longTweetsOnly) { a.tweets = append(a.tweets, tweet) } } // Reset selection if it's now out of bounds if a.selectedIdx >= len(a.tweets) { a.selectedIdx = len(a.tweets) - 1 } if a.selectedIdx < 0 { a.selectedIdx = 0 } */ } return nil } func (a *App) getInput(prompt string) string { // Clear bottom of screen width, height := a.screen.Size() style := tcell.StyleDefault.Background(tcell.ColorReset).Foreground(tcell.ColorWhite) // Create a buffer for multiline input var lines []string currentLine := "" cursorX := len(prompt) cursorY := height - 3 // Show initial prompt and instructions drawText(a.screen, 0, cursorY-1, width, style, "Enter your reply (Ctrl+J to submit, Enter for new line, Esc to cancel):") drawText(a.screen, 0, cursorY, width, style, prompt) a.screen.Show() for { ev := a.screen.PollEvent() switch ev := ev.(type) { case *tcell.EventKey: switch ev.Key() { case tcell.KeyCtrlJ: if currentLine != "" { lines = append(lines, currentLine) } return strings.Join(lines, "\n") case tcell.KeyEscape: return "" case tcell.KeyEnter: lines = append(lines, currentLine) currentLine = "" cursorX = len(prompt) cursorY++ if cursorY >= height-1 { cursorY-- a.screen.Clear() drawText(a.screen, 0, cursorY-len(lines)-1, width, style, "Enter your reply (Ctrl+J to submit, Enter for new line, Esc to cancel):") for i, line := range lines { drawText(a.screen, 0, cursorY-len(lines)+i, width, style, prompt+line) } } drawText(a.screen, 0, cursorY, width, style, prompt) case tcell.KeyBackspace, tcell.KeyBackspace2: if len(currentLine) > 0 { currentLine = currentLine[:len(currentLine)-1] cursorX-- a.screen.SetContent(cursorX, cursorY, ' ', nil, style) } else if len(lines) > 0 { currentLine = lines[len(lines)-1] lines = lines[:len(lines)-1] cursorY-- cursorX = len(prompt) + len(currentLine) } case tcell.KeyRune: currentLine += string(ev.Rune()) a.screen.SetContent(cursorX, cursorY, ev.Rune(), nil, style) cursorX++ } // Redraw current line for x := 0; x < width; x++ { a.screen.SetContent(x, cursorY, ' ', nil, style) } drawText(a.screen, 0, cursorY, width, style, prompt+currentLine) a.screen.Show() } } } func openBrowser(url string) error { var cmd string var args []string switch runtime.GOOS { case "windows": cmd = "cmd" args = []string{"/c", "start"} case "darwin": cmd = "open" default: // "linux", "freebsd", "openbsd", "netbsd" cmd = "xdg-open" } args = append(args, url) return exec.Command(cmd, args...).Start() } func main() { // Define command line flags list := flag.String("list", "all", "Read a specific accounts list from data. By default all (all.txt, all.json)") shortTweets := flag.Bool("short", false, "Show only short tweets (<180 characters)") longTweets := flag.Bool("long", false, "Show only long tweets (>=180 characters)") username := flag.String("u", "", "Show tweets only from this username") flag.Parse() app, err := newApp() if err != nil { fmt.Printf("Could not create client: %v\n", err) os.Exit(1) } // Configure tweet filtering if *shortTweets && *longTweets { fmt.Println("Warning: Both --short and --long flags set. Showing all tweets.") } else { app.shortTweetsOnly = *shortTweets app.longTweetsOnly = *longTweets } // Configure which accounts list to use app.accountsList = *list app.filterUsername = *username if err := app.Run(); err != nil { app.screen.Fini() fmt.Printf("Error running client: %v\n", err) os.Exit(1) } app.screen.Fini() }