commit 488d264

delthas  ·  2023-10-25 08:27:48 +0000 UTC
parent 206be65
Add icon, custom notifications through local integrations

This adds custom notifications sent directly (bypassing the
terminal), that enables us to automatically dismiss them, add a
nice icon, and to automatically switch to the appropriate buffer
when clicked.

This can be disabled with `local-integrations false` in the config
if any issues are encountered. If you do, please open a ticket so
this can be investigated. (I'm aware this might be a bit
experimental.)

This also adds support for OSC 176 ([1]), which enables users to
use senpai as an "application", meaning it can be started from the
application launcher, pinned to a task bar, have a proper icon and
standalone window grouping, etc. This is at the time of writing
only supported on foot.

[1]: https://gist.github.com/delthas/d451e2cc1573bb2364839849c7117239
18 files changed,  +316, -45
M app.go
M go.mod
M go.sum
+12, -1
 1@@ -10,6 +10,7 @@ PREFIX ?= /usr/local
 2 BINDIR ?= bin
 3 MANDIR ?= share/man
 4 APPDIR ?= share/applications
 5+ICONDIR ?= share/icons
 6 
 7 ifeq (0, $(shell $(GIT) status >/dev/null 2>&1; echo $$?))
 8 export SOURCE_DATE_EPOCH ?= $(shell $(GIT) log -1 --pretty=%ct)
 9@@ -31,6 +32,12 @@ doc/senpai.5: doc/senpai.5.scd
10 	$(SCDOC) < doc/senpai.5.scd > doc/senpai.5
11 endif
12 
13+res: res/icon.128.png res/icon.48.png
14+res/icon.128.png: res/icon.svg
15+	rsvg-convert -o res/icon.128.png res/icon.svg
16+res/icon.48.png: res/icon.svg
17+	rsvg-convert -w 48 -h 48 -o res/icon.48.png res/icon.svg
18+
19 clean:
20 	$(RM) -rf senpai doc/senpai.1 doc/senpai.5
21 install:
22@@ -38,10 +45,14 @@ install:
23 	mkdir -p $(DESTDIR)$(PREFIX)/$(MANDIR)/man1
24 	mkdir -p $(DESTDIR)$(PREFIX)/$(MANDIR)/man5
25 	mkdir -p $(DESTDIR)$(PREFIX)/$(APPDIR)
26+	mkdir -p $(DESTDIR)$(PREFIX)/$(ICONDIR)/hicolor/48x48/apps
27+	mkdir -p $(DESTDIR)$(PREFIX)/$(ICONDIR)/hicolor/scalable/apps
28 	cp -f senpai $(DESTDIR)$(PREFIX)/$(BINDIR)
29 	cp -f doc/senpai.1 $(DESTDIR)$(PREFIX)/$(MANDIR)/man1
30 	cp -f doc/senpai.5 $(DESTDIR)$(PREFIX)/$(MANDIR)/man5
31 	cp -f contrib/senpai.desktop $(DESTDIR)$(PREFIX)/$(APPDIR)/senpai.desktop
32+	cp -f res/icon.48.png $(DESTDIR)$(PREFIX)/$(ICONDIR)/hicolor/48x48/apps/senpai.png
33+	cp -f res/icon.svg $(DESTDIR)$(PREFIX)/$(ICONDIR)/hicolor/scalable/apps/senpai.svg
34 uninstall:
35 	$(RM) $(DESTDIR)$(PREFIX)/$(BINDIR)/senpai
36 	$(RM) $(DESTDIR)$(PREFIX)/$(MANDIR)/man1/senpai.1
37@@ -51,4 +62,4 @@ uninstall:
38 emoji:
39 	curl -sSfL -o emoji.json "https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json"
40 
41-.PHONY: all senpai doc clean install uninstall emoji
42+.PHONY: all senpai doc res clean install uninstall emoji
+1, -1
1@@ -1,4 +1,4 @@
2-# NOTICE me :senpai!
3+![](res/icon.128.png) ![](res/logo.png)
4 
5 *Welcome home, desune~*
6 
M app.go
+10, -6
 1@@ -159,11 +159,18 @@ func NewApp(cfg Config) (app *App, err error) {
 2 		MergeLine: func(former *ui.Line, addition ui.Line) {
 3 			app.mergeLine(former, addition)
 4 		},
 5-		Colors: cfg.Colors,
 6+		Colors:            cfg.Colors,
 7+		LocalIntegrations: cfg.LocalIntegrations,
 8 	})
 9 	if err != nil {
10 		return
11 	}
12+	ui.NotifyStart(func(ev *ui.NotifyEvent) {
13+		app.events <- event{
14+			src:     "*",
15+			content: ev,
16+		}
17+	})
18 	app.win.SetPrompt(ui.Styled(">",
19 		tcell.
20 			StyleDefault.
21@@ -470,6 +477,8 @@ func (app *App) handleUIEvent(ev interface{}) bool {
22 	case *tcell.EventError:
23 		// happens when the terminal is closing: in which case, exit
24 		return false
25+	case *ui.NotifyEvent:
26+		app.win.JumpBufferNetwork(ev.NetID, ev.Buffer)
27 	case statusLine:
28 		app.addStatusLine(ev.netID, ev.line)
29 	default:
30@@ -1191,11 +1200,6 @@ func (app *App) notifyHighlight(buffer, nick, content string, current bool) {
31 	if app.cfg.OnHighlightBeep {
32 		app.win.Beep()
33 	}
34-	if buffer != nick {
35-		app.win.Notify(fmt.Sprintf("%s — %s", buffer, nick), content)
36-	} else {
37-		app.win.Notify(nick, content)
38-	}
39 
40 	if app.cfg.Transient {
41 		return
+3, -2
 1@@ -258,8 +258,9 @@ func init() {
 2 			Handle:    commandDoBack,
 3 		},
 4 		"SHRUG": {
 5-			Desc:   "send a shrug to the current channel ¯\\_(ツ)_/¯",
 6-			Handle: commandDoShrug,
 7+			Desc:    "send a shrug to the current channel ¯\\_(ツ)_/¯",
 8+			MaxArgs: maxArgsInfinite,
 9+			Handle:  commandDoShrug,
10 		},
11 		"TABLEFLIP": {
12 			Desc:   "send a tableflip to the current channel (╯°□°)╯︵ ┻━┻",
+14, -4
 1@@ -113,8 +113,9 @@ type Config struct {
 2 
 3 	Colors ui.ConfigColors
 4 
 5-	Debug     bool
 6-	Transient bool
 7+	Debug             bool
 8+	Transient         bool
 9+	LocalIntegrations bool
10 }
11 
12 func DefaultHighlightPath() (string, error) {
13@@ -157,7 +158,9 @@ func Defaults() Config {
14 				Self:   tcell.ColorRed,
15 			},
16 		},
17-		Debug: false,
18+		Debug:             false,
19+		Transient:         false,
20+		LocalIntegrations: true,
21 	}
22 }
23 
24@@ -436,10 +439,17 @@ func unmarshal(filename string, cfg *Config) (err error) {
25 			if err := d.ParseParams(&transient); err != nil {
26 				return err
27 			}
28-
29 			if cfg.Transient, err = strconv.ParseBool(transient); err != nil {
30 				return err
31 			}
32+		case "local-integrations":
33+			var localIntegrations string
34+			if err := d.ParseParams(&localIntegrations); err != nil {
35+				return err
36+			}
37+			if cfg.LocalIntegrations, err = strconv.ParseBool(localIntegrations); err != nil {
38+				return err
39+			}
40 		default:
41 			return fmt.Errorf("unknown directive %q", d.Name)
42 		}
+2, -2
 1@@ -1,5 +1,5 @@
 2 [Desktop Entry]
 3-Version=1.0
 4+Version=1.5
 5 Name=senpai
 6 
 7 GenericName=IRC Client
 8@@ -8,6 +8,6 @@ Keywords=Chat;IRC;IM;Messaging
 9 Categories=Network;InstantMessaging;Chat;IRCClient;ConsoleOnly
10 
11 Type=Application
12-Icon=utilities-terminal
13+Icon=senpai
14 Terminal=true
15 Exec=senpai
+8, -1
 1@@ -203,7 +203,14 @@ colors {
 2 *-transient*
 3 	Advanced.
 4 	Run an ephemeral instance without disk reads/writes (except for the initial
 5-	configuration).
 6+	configuration). Can be useful for public instances.
 7+	Defaults to false.
 8+
 9+*-local-integrations*
10+	Advanced.
11+	Enables integrations with the local system (e.g. notifications through
12+	DBus). Can be useful to disable on systems planned to be used through SSH.
13+	Defaults to true.
14 
15 # EXAMPLES
16 
M go.mod
+2, -1
 1@@ -7,10 +7,11 @@ require (
 2 	github.com/delthas/go-libnp v0.0.0-20221222161248-0e45ece1f878
 3 	github.com/delthas/go-localeinfo v0.0.0-20221116001557-686a1e185118
 4 	github.com/gdamore/tcell/v2 v2.6.1-0.20230327043120-47ec3a77754f
 5+	github.com/godbus/dbus/v5 v5.1.0
 6 	github.com/mattn/go-runewidth v0.0.15
 7 	golang.org/x/net v0.18.0
 8 	golang.org/x/time v0.4.0
 9 	mvdan.cc/xurls/v2 v2.5.0
10 )
11 
12-replace github.com/gdamore/tcell/v2 => github.com/delthas/tcell/v2 v2.4.1-0.20230710100648-1489e78d90fb
13+replace github.com/gdamore/tcell/v2 => github.com/delthas/tcell/v2 v2.4.1-0.20240531114655-837a7d7b4e80
M go.sum
+2, -2
 1@@ -6,8 +6,8 @@ github.com/delthas/go-libnp v0.0.0-20221222161248-0e45ece1f878 h1:v8W8eW7eb2bHFX
 2 github.com/delthas/go-libnp v0.0.0-20221222161248-0e45ece1f878/go.mod h1:aGVXnhWpDlt5U4SphG97o1gszctZKvBTXy320E8Buw4=
 3 github.com/delthas/go-localeinfo v0.0.0-20221116001557-686a1e185118 h1:Xzf9ra1QRJXD62gwudjI2iBq7x9CusvHd83Dg2OnUmE=
 4 github.com/delthas/go-localeinfo v0.0.0-20221116001557-686a1e185118/go.mod h1:sG54BxlyQgIskYURLrg7mvhoGBe0Qq12DNtYRALwNa4=
 5-github.com/delthas/tcell/v2 v2.4.1-0.20230710100648-1489e78d90fb h1:x0hrYPzXpmn3L/4QnW0UXJnHX9oz0OcZNcsSgregusw=
 6-github.com/delthas/tcell/v2 v2.4.1-0.20230710100648-1489e78d90fb/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y=
 7+github.com/delthas/tcell/v2 v2.4.1-0.20240531114655-837a7d7b4e80 h1:acsB0QcLr1zyWOfFcLf78ZuTmtaf/LORGP9jB87U7x0=
 8+github.com/delthas/tcell/v2 v2.4.1-0.20240531114655-837a7d7b4e80/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y=
 9 github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
10 github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
11 github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+0, -0
+0, -0
+93, -0
 1@@ -0,0 +1,93 @@
 2+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 3+<svg
 4+   version="1.0"
 5+   width="128"
 6+   height="128"
 7+   viewBox="0 0 128 128"
 8+   preserveAspectRatio="xMidYMid"
 9+   id="svg3479"
10+   sodipodi:docname="icon.svg"
11+   inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
12+   inkscape:export-filename="icon.png"
13+   inkscape:export-xdpi="96"
14+   inkscape:export-ydpi="96"
15+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
16+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
17+   xmlns="http://www.w3.org/2000/svg"
18+   xmlns:svg="http://www.w3.org/2000/svg">
19+  <defs
20+     id="defs3483" />
21+  <sodipodi:namedview
22+     id="namedview3481"
23+     pagecolor="#ffffff"
24+     bordercolor="#000000"
25+     borderopacity="0.25"
26+     inkscape:showpageshadow="2"
27+     inkscape:pageopacity="0.0"
28+     inkscape:pagecheckerboard="true"
29+     inkscape:deskcolor="#d1d1d1"
30+     showgrid="false"
31+     inkscape:zoom="4"
32+     inkscape:cx="-0.125"
33+     inkscape:cy="39.5"
34+     inkscape:window-width="1920"
35+     inkscape:window-height="1003"
36+     inkscape:window-x="0"
37+     inkscape:window-y="0"
38+     inkscape:window-maximized="1"
39+     inkscape:current-layer="svg3479" />
40+  <g
41+     id="layer1"
42+     inkscape:label="bg"
43+     style="fill:#ffffff;fill-opacity:1"
44+     transform="matrix(0.5,0,0,0.5,-0.04910925,1.6651491)">
45+    <path
46+       d="m 104.93059,244.22515 c -10.165747,-1.21785 -23.526487,-10.95321 -29.463217,-8.68031 -22.24324,8.51592 -46.15474,14.61633 -69.1464293,9.11534 -13.02622,-7.24614 10.2177793,-12.83119 15.3464293,-17.71534 6.8822,-5.79118 17.43606,-12.65692 10.9856,-23.23925 -6.78257,-10.27433 -9.79723,-21.6306 -11.44847,-33.22892 -2.13943,-15.02726 -1.90743,-30.87623 2.2285,-45.48183 1.21444,-11.49591 6.79834,-22.57484 3.93437,-34.325001 -0.78559,-13.35391 -2.76141,-27.15536 -0.61677,-40.47812 2.74232,-17.03572 10.65432,-46.1953996 16.99177,-48.8968795 9.53301,2.4527799 40.98926,18.8077195 55.53125,26.9249995 11.689307,-4.85371 44.804157,-8.88091 63.200007,-0.48125 7.10846,-3.5044 38.20103,-20.5061196 55.96875,-24.5437496 21.18763,29.5973096 20.53081,61.6060896 19.9961,94.4929686 -0.94077,11.881982 4.4766,23.410432 4.6038,35.359572 0.63665,20.68499 -0.8976,47.28222 -9.97284,63.001 -9.07523,15.71877 -22.6399,23.75964 -29.53217,29.5964 -27.17856,19.79263 -64.8632,22.62281 -98.60668,18.58037 z"
47+       id="path3459"
48+       sodipodi:nodetypes="sscccaccacccccczcs"
49+       inkscape:label="path3459"
50+       style="display:inline;fill:#ffffff;fill-opacity:1;stroke-width:0.25" />
51+  </g>
52+  <g
53+     fill="#ae1c3e"
54+     id="g3461"
55+     style="display:inline"
56+     transform="matrix(0.125,0,0,0.125,-1.2404222,1.3125691)">
57+    <path
58+       d="M 429.25283,979.72123 C 388.58988,974.84984 335.14692,935.90839 311.4,945 222.42704,979.06366 126.78105,1003.4653 34.814258,981.46133 -17.290612,952.47677 75.685376,930.13658 96.2,910.6 123.72878,887.43527 165.94423,859.97232 140.14238,817.64297 113.0121,776.54566 100.95349,731.12059 94.348505,684.72732 85.790813,624.61825 86.718794,561.22239 103.2625,502.8 108.12029,456.81635 130.45589,412.50063 119,365.5 115.85765,312.08433 107.95436,256.87855 116.5329,203.5875 127.50221,135.44462 159.15018,18.805918 184.5,8 222.63202,17.811086 348.45702,83.230874 406.625,115.7 453.38221,96.28515 585.84159,80.176359 659.425,113.775 687.85883,99.757373 812.2291,31.750515 883.3,15.6 c 84.75052,118.38924 82.12325,246.42436 79.98438,377.97188 -3.76306,47.5279 17.90642,93.64169 18.41523,141.43828 2.54657,82.73994 -3.59041,189.12888 -39.89136,252.00397 -36.30095,62.8751 -90.5596,95.03859 -118.12871,118.38561 C 714.9653,984.57026 564.22674,995.891 429.25283,979.72123 Z M 634.03281,937.20156 C 738.63162,914.40544 849.6249,850.69192 902.23204,772.80404 954.83918,694.91616 955.22204,552.74752 922.8,470.2 886.69807,421.92322 845.9285,503.25797 799.275,491.1625 778.40073,451.16921 787.9339,390.9059 753.94688,351.73125 730.72388,333.14636 694.60838,381.08906 675,402 651.03278,423.62811 618.27321,423.31475 593.20332,403.11541 556.54774,378.84317 518.48929,308.90638 506.6125,301.8 467.14975,278.18777 417.0012,422.02277 379,445.2 347.51789,435.00073 316.13891,399.59444 293.2125,390.7375 277.59504,422.47841 268.65135,509.09683 217,476 109.44556,440.94812 135.70188,636.20062 144.9,687.775 c 6.88827,46.64852 29.44388,89.68865 50.3,131.05625 26.22145,41.75614 -52.04564,96.68553 -82.14257,124.24478 -29.308662,29.09579 83.93913,11.02304 119.09062,-7.59478 44.31718,-23.47238 88.97238,-43.57365 136.44116,-18.46877 78.442,41.48572 176.12397,36.07016 265.4436,20.18908 z M 186.4,402 C 443.56992,125.11145 709.49727,252.20559 874.40156,371.91719 892.14964,386.00736 912.96754,402.50182 888.125,372.0375 849.28566,307.67218 800.46119,246.33937 733,210.2 630.05449,154.34323 501.55005,137.33389 397.1625,184 317.09423,216.25051 249.77229,276.1874 204.7,349.8 c -8.36438,9.14385 -46.30266,81.47 -18.3,52.2 z M 908.2,302 c -9.74086,-60.0409 -15.8199,-121.64067 -37,-179.2 -42.50967,3.2415 -83.00387,20.21343 -120.30469,40.02969 C 810.78958,199.8115 867.8333,246.26382 907.1,306 Z M 175,301.9 C 213.16155,246.44818 264.04943,199.5736 323.2,167.1 280.11719,137.38433 267.53567,134.05195 223,122.4 187.57338,150.59491 164.94853,253.90394 175,301.9 Z"
59+       id="path5617"
60+       sodipodi:nodetypes="sscccaccacccccczcsczcccccsccccccssccccccccccccccccc"
61+       inkscape:label="path3459"
62+       style="display:inline" />
63+  </g>
64+  <g
65+     fill="#290225"
66+     id="g3457"
67+     transform="matrix(0.125,0,0,0.125,-1.2404222,1.3125691)">
68+    <path
69+       d="M 523.89756,864.80123 C 487.19342,864.1306 450.28522,852.26521 422.31631,827.99814 403.99497,812.17597 386.3883,792.62565 382.0027,768.0033 c -6.2748,-10.75998 -2.25189,-25.10976 10.22706,-28.99153 10.53946,-3.50982 21.7721,-1.32749 32.5553,-0.48554 15.44431,10.52796 18.3642,31.72315 32.17712,43.19145 72.28607,63.8139 141.24031,32.30483 183.91029,-33.06162 5.23999,-12.4268 20.85981,-13.8226 32.42347,-10.97094 13.38499,2.36866 22.38351,16.43701 18.74151,29.56063 -4.07066,15.85353 -13.6748,29.73373 -22.84155,43.0151 -21.2275,22.61414 -48.23242,40.41912 -78.5021,48.09542 -21.68598,5.67189 -44.42667,7.76436 -66.79624,6.44496 z"
70+       id="path3451"
71+       sodipodi:nodetypes="cccccccccccc" />
72+    <path
73+       d="m 437.63785,591.11841 c -19.24093,-55.25607 -64.6646,-91.56451 -106.95892,-42.90313 -16.17392,18.60877 -18.99634,59.54456 -51.05341,53.52092 -9.82422,-1.84601 -13.75801,-17.01306 -13.07888,-26.98611 1.98258,-29.11439 23.58004,-57.70687 48.50857,-72.87745 26.11412,-15.89208 63.27587,-22.59306 91.15614,-10.05528 35.39748,15.91827 83.02104,59.59116 67.24744,95.05334 -4.88667,10.98619 -31.86689,15.60294 -35.82094,4.24771 z"
74+       id="path3453"
75+       sodipodi:nodetypes="sssaaaas" />
76+    <path
77+       d="m 631.10457,589.84476 c 19.24093,-55.25607 64.6646,-91.56451 106.95892,-42.90313 16.17392,18.60877 18.99634,59.54456 51.05341,53.52092 9.82422,-1.84601 13.75801,-17.01306 13.07888,-26.98611 -1.98258,-29.11439 -23.58004,-57.70687 -48.50857,-72.87745 -26.11412,-15.89208 -63.27587,-22.59306 -91.15614,-10.05528 -35.39748,15.91827 -83.02104,59.59116 -67.24744,95.05334 4.88667,10.98619 31.86689,15.60294 35.82094,4.24771 z"
78+       id="path5567"
79+       sodipodi:nodetypes="sssaaaas" />
80+  </g>
81+  <g
82+     fill="#ed91c0"
83+     id="g3467"
84+     style="display:inline"
85+     transform="matrix(0.125,0,0,0.125,-1.2404222,1.3125691)">
86+    <path
87+       d="m 330.5,681.3 c 0,29.4 -23.8,53.2 -53.2,53.2 -29.4,0 -53.2,-23.8 -53.2,-53.2 0,-29.3 23.8,-53.2 53.2,-53.2 29.4,0 53.2,23.9 53.2,53.2 z m -65.5,52"
88+       id="path3463" />
89+    <path
90+       d="m 852.8,678.5 c 0,29.4 -23.8,53.2 -53.2,53.2 -29.4,0 -53.3,-23.8 -53.3,-53.2 0,-29.4 23.9,-53.3 53.3,-53.3 29.4,0 53.2,23.9 53.2,53.3 z M 785,729.7"
91+       id="path3465"
92+       style="display:inline" />
93+  </g>
94+</svg>
+0, -0
+25, -12
 1@@ -189,13 +189,14 @@ func (l *Line) NewLines(width int) []int {
 2 }
 3 
 4 type buffer struct {
 5-	netID      string
 6-	netName    string
 7-	title      string
 8-	highlights int
 9-	unread     bool
10-	read       time.Time
11-	openedOnce bool
12+	netID         string
13+	netName       string
14+	title         string
15+	highlights    int
16+	notifications []int
17+	unread        bool
18+	read          time.Time
19+	openedOnce    bool
20 
21 	// This is the "last read" timestamp when the buffer was last focused.
22 	// If the "last read" timestamp changes while the buffer is focused,
23@@ -275,9 +276,8 @@ func (bs *BufferList) To(i int) bool {
24 		if len(bs.list) <= bs.current {
25 			bs.current = len(bs.list) - 1
26 		}
27+		bs.clearRead(i)
28 		b := bs.list[bs.current]
29-		b.highlights = 0
30-		b.unread = false
31 		b.unreadRuler = b.read
32 		if len(b.lines) > 0 {
33 			l := b.lines[len(b.lines)-1]
34@@ -383,6 +383,7 @@ func (bs *BufferList) Remove(netID, title string) bool {
35 	}
36 	updated := bs.current == idx
37 
38+	bs.clearRead(idx)
39 	bs.list = append(bs.list[:idx], bs.list[idx+1:]...)
40 	if bs.current >= idx {
41 		bs.current--
42@@ -406,6 +407,7 @@ func (bs *BufferList) RemoveNetwork(netID string) {
43 		if idx == bs.current {
44 			updated = true
45 		}
46+		bs.clearRead(idx)
47 		bs.list = append(bs.list[:idx], bs.list[idx+1:]...)
48 		if bs.current >= idx {
49 			bs.current--
50@@ -526,8 +528,20 @@ func (bs *BufferList) SetTopic(netID, title string, topic string) {
51 	b.topic = topic
52 }
53 
54+func (bs *BufferList) clearRead(i int) {
55+	b := &bs.list[i]
56+	b.highlights = 0
57+	b.unread = false
58+	if len(b.notifications) > 0 {
59+		for _, id := range b.notifications {
60+			notifyClose(id)
61+		}
62+		b.notifications = b.notifications[:0]
63+	}
64+}
65+
66 func (bs *BufferList) SetRead(netID, title string, timestamp time.Time) {
67-	_, b := bs.at(netID, title)
68+	i, b := bs.at(netID, title)
69 	if b == nil {
70 		return
71 	}
72@@ -543,8 +557,7 @@ func (bs *BufferList) SetRead(netID, title string, timestamp time.Time) {
73 		}
74 	}
75 	if clearRead {
76-		b.highlights = 0
77-		b.unread = false
78+		bs.clearRead(i)
79 	}
80 	if b.read.Before(timestamp) {
81 		b.read = timestamp
+6, -0
1@@ -0,0 +1,6 @@
2+package ui
3+
4+type NotifyEvent struct {
5+	NetID  string
6+	Buffer string
7+}
+13, -0
 1@@ -0,0 +1,13 @@
 2+//go:build !linux
 3+// +build !linux
 4+
 5+package ui
 6+
 7+func (ui *UI) notify(title, content string) int {
 8+	ui.screen.Notify(title, content)
 9+	return -1
10+}
11+
12+func notifyClose(id int) {}
13+
14+func NotifyStart(f func(event NotifyEvent)) {}
+93, -0
 1@@ -0,0 +1,93 @@
 2+//go:build linux
 3+// +build linux
 4+
 5+package ui
 6+
 7+import (
 8+	"sync"
 9+
10+	"github.com/godbus/dbus/v5"
11+)
12+
13+var notificationsLock sync.Mutex
14+var notifications = make(map[int]*NotifyEvent)
15+
16+func notifyDBus(title, content string) int {
17+	conn, err := dbus.SessionBus()
18+	if err != nil {
19+		return -1
20+	}
21+	var r uint32
22+	obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
23+	err = obj.Call("org.freedesktop.Notifications.Notify", 0, "senpai", uint32(0), "image-path", title, content, []string{
24+		"default", "Open",
25+	}, map[string]dbus.Variant{
26+		"category":      dbus.MakeVariant("im.received"),
27+		"desktop-entry": dbus.MakeVariant("senpai"),
28+		"image-path":    dbus.MakeVariant("senpai"),
29+		"urgency":       dbus.MakeVariant(uint8(1)), // Normal
30+	}, int32(-1)).Store(&r)
31+	if err != nil {
32+		return -1
33+	}
34+	return int(r)
35+}
36+
37+func (ui *UI) notify(target NotifyEvent, title, content string) int {
38+	if ui.config.LocalIntegrations {
39+		id := notifyDBus(title, content)
40+		if id > 0 {
41+			notificationsLock.Lock()
42+			notifications[id] = &target
43+			notificationsLock.Unlock()
44+			return id
45+		}
46+	}
47+
48+	ui.screen.Notify(title, content)
49+	return -1
50+}
51+
52+func notifyClose(id int) {
53+	conn, err := dbus.SessionBus()
54+	if err != nil {
55+		return
56+	}
57+	obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
58+	obj.Call("org.freedesktop.Notifications.CloseNotification", 0, uint32(id))
59+}
60+
61+func NotifyStart(opened func(*NotifyEvent)) {
62+	conn, err := dbus.SessionBus()
63+	if err != nil {
64+		return
65+	}
66+	if err := conn.AddMatchSignal(
67+		dbus.WithMatchObjectPath("/org/freedesktop/Notifications"),
68+		dbus.WithMatchInterface("org.freedesktop.Notifications"),
69+		dbus.WithMatchSender("org.freedesktop.Notifications.ActionInvoked"),
70+	); err != nil {
71+		return
72+	}
73+	c := make(chan *dbus.Signal, 64)
74+	conn.Signal(c)
75+	go func() {
76+		for v := range c {
77+			switch v.Name {
78+			case "org.freedesktop.Notifications.NotificationClosed":
79+				id := int(v.Body[0].(uint32))
80+				notificationsLock.Lock()
81+				delete(notifications, id)
82+				notificationsLock.Unlock()
83+			case "org.freedesktop.Notifications.ActionInvoked":
84+				id := int(v.Body[0].(uint32))
85+				notificationsLock.Lock()
86+				target, ok := notifications[id]
87+				notificationsLock.Unlock()
88+				if ok {
89+					opened(target)
90+				}
91+			}
92+		}
93+	}()
94+}
+32, -13
 1@@ -12,16 +12,17 @@ import (
 2 )
 3 
 4 type Config struct {
 5-	NickColWidth     int
 6-	ChanColWidth     int
 7-	ChanColEnabled   bool
 8-	MemberColWidth   int
 9-	MemberColEnabled bool
10-	TextMaxWidth     int
11-	AutoComplete     func(cursorIdx int, text []rune) []Completion
12-	Mouse            bool
13-	MergeLine        func(former *Line, addition Line)
14-	Colors           ConfigColors
15+	NickColWidth      int
16+	ChanColWidth      int
17+	ChanColEnabled    bool
18+	MemberColWidth    int
19+	MemberColEnabled  bool
20+	TextMaxWidth      int
21+	AutoComplete      func(cursorIdx int, text []rune) []Completion
22+	Mouse             bool
23+	MergeLine         func(former *Line, addition Line)
24+	Colors            ConfigColors
25+	LocalIntegrations bool
26 }
27 
28 type ConfigColors struct {
29@@ -78,6 +79,7 @@ func New(config Config) (ui *UI, err error) {
30 	ui.screen.EnablePaste()
31 	ui.screen.SetCursorStyle(tcell.CursorStyleSteadyBar)
32 	ui.screen.SetTitle("senpai")
33+	ui.screen.SetAppID("senpai")
34 
35 	_, h := ui.screen.Size()
36 	ui.screen.Clear()
37@@ -298,6 +300,24 @@ func (ui *UI) RemoveNetworkBuffers(netID string) {
38 
39 func (ui *UI) AddLine(netID, buffer string, line Line) {
40 	ui.bs.AddLine(netID, buffer, line)
41+
42+	curNetID, curBuffer := ui.bs.Current()
43+	_, b := ui.bs.at(netID, buffer)
44+	if b != nil && line.Notify == NotifyHighlight && (curNetID != netID || curBuffer != buffer) {
45+		var header string
46+		if buffer != line.Head {
47+			header = fmt.Sprintf("%s — %s", buffer, line.Head)
48+		} else {
49+			header = line.Head
50+		}
51+		id := ui.notify(NotifyEvent{
52+			NetID:  netID,
53+			Buffer: buffer,
54+		}, header, line.Body.String())
55+		if id >= 0 {
56+			b.notifications = append(b.notifications, id)
57+		}
58+	}
59 }
60 
61 func (ui *UI) AddLines(netID, buffer string, before, after []Line) {
62@@ -328,10 +348,9 @@ func (ui *UI) JumpBufferIndex(i int) bool {
63 	return false
64 }
65 
66-func (ui *UI) JumpBufferNetwork(netID, sub string) bool {
67-	subLower := strings.ToLower(sub)
68+func (ui *UI) JumpBufferNetwork(netID, buffer string) bool {
69 	for i, b := range ui.bs.list {
70-		if b.netID == netID && strings.Contains(strings.ToLower(b.title), subLower) {
71+		if b.netID == netID && strings.ToLower(b.title) == strings.ToLower(buffer) {
72 			if ui.bs.To(i) {
73 				ui.memberOffset = 0
74 			}