twitter-tools-public/frontend/tui/display-tweets-tui/main.go

578 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
}