commit 08e0bab

Hugo Osvaldo Barrera  ·  2026-07-23 17:39:30 +0000 UTC
parent 42b47ec
Delete built-image image viewer

Senpai includes a built-in image viewer. This image viewer lacks basic
features such as zooming, panning, and displays a blurry thumbnail of
the linked images.

Rather than aspire to implement all these features into an IRC client,
use the system image viewer instead. Most modern computer systems from
the last few decades include an image viewer already.

This also reduces delay when clicking non-image links, since senpai no
longer has to perform one request to determine if it is an image.
8 files changed,  +2, -419
M app.go
M go.mod
M go.sum
M app.go
+1, -161
  1@@ -5,19 +5,12 @@ import (
  2 	"crypto/tls"
  3 	"errors"
  4 	"fmt"
  5-	"html"
  6-	"image"
  7-	_ "image/gif"
  8-	_ "image/jpeg"
  9-	_ "image/png"
 10 	"io"
 11 	"mime"
 12 	"net"
 13 	"net/http"
 14-	"net/url"
 15 	"os"
 16 	"os/exec"
 17-	"regexp"
 18 	"runtime/debug"
 19 	"strconv"
 20 	"strings"
 21@@ -144,9 +137,6 @@ type App struct {
 22 
 23 	lastConfirm string
 24 
 25-	imageLoading bool
 26-	imageOverlay bool
 27-
 28 	uploadingProgress *float64
 29 
 30 	shownBouncerNotice bool
 31@@ -619,11 +609,6 @@ func (app *App) handleUIEvent(ev interface{}) bool {
 32 		app.handleLinkEvent(ev)
 33 	case *events.EventClickChannel:
 34 		app.handleChannelEvent(ev)
 35-	case *events.EventImageLoaded:
 36-		app.win.ShowImage(ev.Image)
 37-		if ev.Image == nil {
 38-			app.imageLoading = false
 39-		}
 40 	case *events.EventFileUpload:
 41 		if ev.Location != "" {
 42 			app.uploadingProgress = nil
 43@@ -659,14 +644,6 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
 44 	x, y := ev.Col, ev.Row
 45 	w, h := app.win.Size()
 46 
 47-	if app.imageOverlay && ev.Button == vaxis.MouseLeftButton {
 48-		if ev.EventType == vaxis.EventPress {
 49-			app.win.ShowImage(nil)
 50-			app.imageOverlay = false
 51-		}
 52-		return
 53-	}
 54-
 55 	if ev.Button == vaxis.MouseLeftButton && (ev.EventType == vaxis.EventRelease || ev.EventType == vaxis.EventMotion) {
 56 		if app.win.ChannelColClicked() {
 57 			app.win.ResizeChannelCol(x + 1)
 58@@ -1114,107 +1091,8 @@ func (app *App) handleChannelEvent(ev *events.EventClickChannel) {
 59 	}
 60 }
 61 
 62-var patternOpenGraphImage = regexp.MustCompile(`<meta property="og:image" content="(.*?)"/?>`)
 63-var patternOpenGraphVideo = regexp.MustCompile(`<meta property="og:video"`)
 64-
 65-func (app *App) fetchImage(link string) (image.Image, error) {
 66-	userAgent := "senpai"
 67-	if v, ok := BuildVersion(); ok {
 68-		userAgent = "senpai/" + v
 69-	}
 70-
 71-	if u, err := url.Parse(link); err == nil {
 72-		changed := true
 73-		switch u.Host {
 74-		case "twitter.com", "x.com":
 75-			u.Host = "fixupx.com"
 76-		default:
 77-			changed = false
 78-		}
 79-		if changed {
 80-			link = u.String()
 81-		}
 82-	}
 83-
 84-	cHead := http.Client{
 85-		Timeout: 1500 * time.Millisecond,
 86-	}
 87-	req, err := http.NewRequest("HEAD", link, nil)
 88-	if err != nil {
 89-		return nil, err
 90-	}
 91-	req.Header.Set("User-Agent", userAgent)
 92-	res, err := cHead.Do(req)
 93-	if err != nil {
 94-		return nil, err
 95-	}
 96-	res.Body.Close()
 97-	if res.StatusCode != http.StatusOK {
 98-		return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode)
 99-	}
100-	contentType, _, err := mime.ParseMediaType(res.Header.Get("Content-Type"))
101-	if err != nil {
102-		return nil, fmt.Errorf("unexpected content type: %v", res.Header.Get("Content-Type"))
103-	}
104-	var isHTML bool
105-	switch contentType {
106-	case "image/gif", "image/jpeg", "image/png": // Actual image, fetch
107-	case "text/html": // Might have an opengraph image, try fetching
108-		isHTML = true
109-	default:
110-		return nil, fmt.Errorf("unexpected content type: %v", contentType)
111-	}
112-	if isHTML {
113-		req, err := http.NewRequest("GET", link, nil)
114-		if err != nil {
115-			return nil, err
116-		}
117-		req.Header.Set("User-Agent", userAgent)
118-		var previewSize int64 = 10 * 1024
119-		if res.Header.Get("Accept-Ranges") == "bytes" {
120-			req.Header.Set("Range", fmt.Sprintf("bytes=0-%v", previewSize))
121-		}
122-		res, err = cHead.Do(req)
123-		if err != nil {
124-			return nil, err
125-		}
126-		b, err := io.ReadAll(io.LimitReader(res.Body, previewSize))
127-		res.Body.Close()
128-		if err != nil {
129-			return nil, fmt.Errorf("unexpected read error: %v", err)
130-		}
131-		if patternOpenGraphVideo.Match(b) {
132-			// Do not display image (previews) of video objects
133-			return nil, fmt.Errorf("video embed found")
134-		}
135-		m := patternOpenGraphImage.FindSubmatch(b)
136-		if len(m) < 2 {
137-			return nil, fmt.Errorf("image embed not found")
138-		}
139-		link = html.UnescapeString(string(m[1]))
140-	}
141-	cGet := http.Client{
142-		Timeout: 5 * time.Second,
143-	}
144-	req, err = http.NewRequest("GET", link, nil)
145-	if err != nil {
146-		return nil, err
147-	}
148-	req.Header.Set("User-Agent", userAgent)
149-	res, err = cGet.Do(req)
150-	if err != nil {
151-		return nil, err
152-	}
153-	img, _, err := app.win.DecodeImage(res.Body)
154-	res.Body.Close()
155-	if err != nil {
156-		return nil, err
157-	}
158-	return img, nil
159-}
160-
161 func (app *App) handleLinkEvent(ev *events.EventClickLink) {
162-	open := func() {
163+	go func() {
164 		if strings.HasPrefix(ev.Link, "-") {
165 			// Avoid injection of parameters.
166 			// Sadly xdg-open does not support "--"...
167@@ -1222,44 +1100,6 @@ func (app *App) handleLinkEvent(ev *events.EventClickLink) {
168 		}
169 		cmd := exec.Command("xdg-open", ev.Link)
170 		cmd.Run()
171-	}
172-
173-	if ev.Event.Modifiers == vaxis.ModCtrl {
174-		if ev.Mouse {
175-			// Only react to Ctrl+Click when mouse links are enabled.
176-
177-			// Explicit external link open requested with Ctrl+Click:
178-			// just run xdg-open.
179-			go open()
180-		}
181-		return
182-	}
183-
184-	// Regular link open requested:
185-	// Try fetching as an image and displaying a preview;
186-	// fall back to xdg-open if mouse links are enabled.
187-
188-	app.imageLoading = true
189-	go func() {
190-		img, err := app.fetchImage(ev.Link)
191-		if err != nil {
192-			app.postEvent(event{
193-				src: "*",
194-				content: &events.EventImageLoaded{
195-					Image: nil,
196-				},
197-			})
198-			if ev.Mouse {
199-				open()
200-			}
201-		} else {
202-			app.postEvent(event{
203-				src: "*",
204-				content: &events.EventImageLoaded{
205-					Image: img,
206-				},
207-			})
208-		}
209 	}()
210 }
211 
+1, -3
 1@@ -85,9 +85,7 @@ In order to open links, refer to your terminal manual. On most terminals,
 2 opening links is done by holding CTRL, or SHIFT, while clicking the link.
 3 On the *foot* terminal, links can be opened by pressing CTRL+SHIFT+O.
 4 
 5-A simple left click with no modifiers will make senpai try to preview the link,
 6-or open it. In order to skip the preview, open the link with a modifier, as
 7-specified above, instead of an unmodified left click.
 8+By simply clicking an link it will be opened.
 9 
10 # KEYBOARD SHORTCUTS
11 
+0, -6
 1@@ -1,8 +1,6 @@
 2 package events
 3 
 4 import (
 5-	"image"
 6-
 7 	"git.sr.ht/~rockorager/vaxis"
 8 )
 9 
10@@ -36,10 +34,6 @@ type EventClickChannel struct {
11 	Channel string
12 }
13 
14-type EventImageLoaded struct {
15-	Image image.Image // nil if error
16-}
17-
18 type EventFileUpload struct {
19 	Progress float64
20 	Location string
M go.mod
+0, -1
1@@ -8,7 +8,6 @@ require (
2 	github.com/containerd/console v1.0.4
3 	github.com/delthas/go-libnp v0.2.0
4 	github.com/delthas/go-localeinfo v0.2.0
5-	github.com/disintegration/imaging v1.6.2
6 	github.com/godbus/dbus/v5 v5.1.0
7 	github.com/rivo/uniseg v0.4.7
8 	golang.org/x/net v0.35.0
M go.sum
+0, -4
 1@@ -10,8 +10,6 @@ github.com/delthas/go-libnp v0.2.0 h1:OVll7Z9CER0rYpgiglXN1QvuNtkyY5X9uzLqm+xDst
 2 github.com/delthas/go-libnp v0.2.0/go.mod h1:LHUapIgbv1vBcZJU4cJRPkyslKhKe7GNRF38yUbl9QA=
 3 github.com/delthas/go-localeinfo v0.2.0 h1:0K5F5GQ5p4ealZjuBs4eubHLOIL21/YWSxs557hHZ+M=
 4 github.com/delthas/go-localeinfo v0.2.0/go.mod h1:sG54BxlyQgIskYURLrg7mvhoGBe0Qq12DNtYRALwNa4=
 5-github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
 6-github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
 7 github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
 8 github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
 9 github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
10@@ -29,7 +27,6 @@ github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y
11 github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds=
12 github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
13 github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
14-golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
15 golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
16 golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
17 golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
18@@ -37,7 +34,6 @@ golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
19 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
20 golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
21 golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
22-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
23 golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
24 golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
25 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+0, -127
  1@@ -1,127 +0,0 @@
  2-package ui
  3-
  4-import (
  5-	"encoding/binary"
  6-	"io"
  7-)
  8-
  9-func exifOrientation(r io.Reader) int {
 10-	// Inspired by: https://github.com/disintegration/imageorient
 11-
 12-	const (
 13-		markerSOI      = 0xffd8
 14-		markerAPP1     = 0xffe1
 15-		exifHeader     = 0x45786966
 16-		byteOrderBE    = 0x4d4d
 17-		byteOrderLE    = 0x4949
 18-		orientationTag = 0x0112
 19-	)
 20-
 21-	// Check if JPEG SOI marker is present.
 22-	var soi uint16
 23-	if err := binary.Read(r, binary.BigEndian, &soi); err != nil {
 24-		return 0
 25-	}
 26-	if soi != markerSOI {
 27-		return 0 // Missing JPEG SOI marker.
 28-	}
 29-
 30-	// Find JPEG APP1 marker.
 31-	for {
 32-		var marker, size uint16
 33-		if err := binary.Read(r, binary.BigEndian, &marker); err != nil {
 34-			return 0
 35-		}
 36-		if err := binary.Read(r, binary.BigEndian, &size); err != nil {
 37-			return 0
 38-		}
 39-		if marker>>8 != 0xff {
 40-			return 0 // Invalid JPEG marker.
 41-		}
 42-		if marker == markerAPP1 {
 43-			break
 44-		}
 45-		if size < 2 {
 46-			return 0 // Invalid block size.
 47-		}
 48-		if _, err := io.CopyN(io.Discard, r, int64(size-2)); err != nil {
 49-			return 0
 50-		}
 51-	}
 52-
 53-	// Check if EXIF header is present.
 54-	var header uint32
 55-	if err := binary.Read(r, binary.BigEndian, &header); err != nil {
 56-		return 0
 57-	}
 58-	if header != exifHeader {
 59-		return 0
 60-	}
 61-	if _, err := io.CopyN(io.Discard, r, 2); err != nil {
 62-		return 0
 63-	}
 64-
 65-	// Read byte order information.
 66-	var (
 67-		byteOrderTag uint16
 68-		byteOrder    binary.ByteOrder
 69-	)
 70-	if err := binary.Read(r, binary.BigEndian, &byteOrderTag); err != nil {
 71-		return 0
 72-	}
 73-	switch byteOrderTag {
 74-	case byteOrderBE:
 75-		byteOrder = binary.BigEndian
 76-	case byteOrderLE:
 77-		byteOrder = binary.LittleEndian
 78-	default:
 79-		return 0 // Invalid byte order flag.
 80-	}
 81-	if _, err := io.CopyN(io.Discard, r, 2); err != nil {
 82-		return 0
 83-	}
 84-
 85-	// Skip the EXIF offset.
 86-	var offset uint32
 87-	if err := binary.Read(r, byteOrder, &offset); err != nil {
 88-		return 0
 89-	}
 90-	if offset < 8 {
 91-		return 0 // Invalid offset value.
 92-	}
 93-	if _, err := io.CopyN(io.Discard, r, int64(offset-8)); err != nil {
 94-		return 0
 95-	}
 96-
 97-	// Read the number of tags.
 98-	var numTags uint16
 99-	if err := binary.Read(r, byteOrder, &numTags); err != nil {
100-		return 0
101-	}
102-
103-	// Find the orientation tag.
104-	for i := 0; i < int(numTags); i++ {
105-		var tag uint16
106-		if err := binary.Read(r, byteOrder, &tag); err != nil {
107-			return 0
108-		}
109-		if tag != orientationTag {
110-			if _, err := io.CopyN(io.Discard, r, 10); err != nil {
111-				return 0
112-			}
113-			continue
114-		}
115-		if _, err := io.CopyN(io.Discard, r, 6); err != nil {
116-			return 0
117-		}
118-		var val uint16
119-		if err := binary.Read(r, byteOrder, &val); err != nil {
120-			return 0
121-		}
122-		if val < 1 || val > 8 {
123-			return 0 // Invalid tag value.
124-		}
125-		return int(val)
126-	}
127-	return 0 // Missing orientation tag.
128-}
+0, -109
  1@@ -1,10 +1,7 @@
  2 package ui
  3 
  4 import (
  5-	"bytes"
  6 	"fmt"
  7-	"image"
  8-	"io"
  9 	"os"
 10 	"reflect"
 11 	"runtime"
 12@@ -13,9 +10,7 @@ import (
 13 	"time"
 14 
 15 	"git.sr.ht/~rockorager/vaxis"
 16-	"git.sr.ht/~rockorager/vaxis/widgets/align"
 17 	"github.com/containerd/console"
 18-	"github.com/disintegration/imaging"
 19 
 20 	"git.sr.ht/~delthas/senpai/events"
 21 	"git.sr.ht/~delthas/senpai/irc"
 22@@ -93,8 +88,6 @@ type UI struct {
 23 
 24 	clickEvents []clickEvent
 25 
 26-	image vaxis.Image
 27-
 28 	mouseLinks bool
 29 
 30 	colorThemeMode vaxis.ColorThemeMode
 31@@ -731,9 +724,6 @@ func (ui *UI) Resize() {
 32 		ui.bs.ResizeTimeline(innerWidth, h-2, textWidth)
 33 	}
 34 	ui.ScrollToBuffer()
 35-	if ui.image != nil {
 36-		ui.image.Resize(w, h)
 37-	}
 38 	ui.vx.Refresh()
 39 }
 40 
 41@@ -753,35 +743,6 @@ func (ui *UI) Highlights() int {
 42 	return ui.bs.Highlights()
 43 }
 44 
 45-func (ui *UI) ImageReady() bool {
 46-	if ui.image == nil {
 47-		return false
 48-	}
 49-	iw, ih := ui.image.CellSize()
 50-	return iw != 0 || ih != 0
 51-}
 52-
 53-func (ui *UI) ShowImage(img image.Image) bool {
 54-	if img == nil {
 55-		if ui.image != nil {
 56-			ui.image.Destroy()
 57-			ui.image = nil
 58-		}
 59-		return true
 60-	}
 61-
 62-	vi, err := ui.vx.NewImage(img)
 63-	if err != nil {
 64-		return false
 65-	}
 66-	w, h := ui.vx.window.Size()
 67-	w = w * 9 / 10
 68-	h = h * 9 / 10
 69-	vi.Resize(w, h)
 70-	ui.image = vi
 71-	return true
 72-}
 73-
 74 func (ui *UI) AsyncCompletions(id int, cs []Completion) {
 75 	ui.e.AsyncCompletions(id, cs)
 76 }
 77@@ -834,11 +795,6 @@ func (ui *UI) Draw(members []irc.Member) {
 78 		ui.e.Draw(ui.vx, 9+ui.channelWidth+ui.config.NickColWidth, h-1, hint)
 79 	}
 80 
 81-	if ui.image != nil {
 82-		iw, ih := ui.image.CellSize()
 83-		ui.image.Draw(align.Center(ui.vx.window, iw, ih))
 84-	}
 85-
 86 	ui.vx.Render()
 87 }
 88 
 89@@ -1033,68 +989,3 @@ func (ui *UI) drawVerticalMemberList(vx *Vaxis, x0, y0, width, height int, b *bu
 90 		printString(vx, &x, y, name)
 91 	}
 92 }
 93-
 94-func (ui *UI) DecodeImage(r io.Reader) (image.Image, string, error) {
 95-	var b bytes.Buffer
 96-	tr := io.TeeReader(io.LimitReader(r, 1<<20), &b)
 97-	o := exifOrientation(tr)
 98-	r = io.MultiReader(&b, r)
 99-
100-	img, format, err := image.Decode(r)
101-	if err != nil {
102-		return img, format, err
103-	}
104-
105-	img = ui.maybeResizeImage(img, o)
106-	switch o {
107-	case 3:
108-		img = imaging.Rotate180(img)
109-	case 6:
110-		img = imaging.Rotate270(img)
111-	case 8:
112-		img = imaging.Rotate90(img)
113-	}
114-	return img, format, nil
115-}
116-
117-func (ui *UI) maybeResizeImage(img image.Image, exifOrientation int) image.Image {
118-	if ui.vx.xPixel <= 0 || ui.vx.yPixel <= 0 {
119-		return img
120-	}
121-	w, h := ui.vx.window.Size()
122-	w = w * 9 / 10
123-	h = h * 9 / 10
124-	if w <= 0 || h <= 0 {
125-		return img
126-	}
127-	wp := img.Bounds().Dx()
128-	hp := img.Bounds().Dy()
129-	switch exifOrientation {
130-	case 6, 8:
131-		wp, hp = hp, wp
132-	}
133-	cellPixW := ui.vx.xPixel / w
134-	cellPixH := ui.vx.yPixel / h
135-	columns := (wp + cellPixW - 1) / cellPixW
136-	lines := (hp + cellPixH - 1) / cellPixH
137-	if columns <= w && lines <= h {
138-		return img
139-	}
140-	sfX := float64(w) / float64(columns)
141-	sfY := float64(h) / float64(lines)
142-	nwp := wp
143-	nhp := hp
144-	switch {
145-	case sfX < sfY:
146-		nwp = int(sfX * float64(wp))
147-		nhp = int(sfX * float64(hp))
148-	case sfX > sfY:
149-		nwp = int(sfY * float64(wp))
150-		nhp = int(sfY * float64(hp))
151-	}
152-	switch exifOrientation {
153-	case 6, 8:
154-		nwp, nhp = nhp, nwp
155-	}
156-	return imaging.Resize(img, nwp, nhp, imaging.NearestNeighbor)
157-}
+0, -8
 1@@ -49,18 +49,10 @@ func (app *App) addStatusLine(netID string, line ui.Line) {
 2 }
 3 
 4 func (app *App) setStatus() {
 5-	if app.imageLoading && app.win.ImageReady() {
 6-		app.imageLoading = false
 7-		app.imageOverlay = true
 8-	}
 9 	if app.uploadingProgress != nil {
10 		app.win.SetStatus(fmt.Sprintf("Uploading file (%02.1f%%)...", *app.uploadingProgress*100))
11 		return
12 	}
13-	if app.imageLoading {
14-		app.win.SetStatus("Loading image...")
15-		return
16-	}
17 
18 	netID, buffer := app.win.CurrentBuffer()
19 	s := app.sessions[netID]