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.
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-}
M
ui/ui.go
+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]