feat(notify): Desktop-Benachrichtigung für den Ruhezeit-Toast
quiet_mode = "toast" war bisher nur eine Protokollzeile. Jetzt zeigt der Daemon eine Desktop-Benachrichtigung — über die Bordmittel des Systems, exec-basiert wie osbuiltin, ohne Bibliothek und ohne CGO: notify-send (Linux), osascript display notification (macOS), PowerShell Toast (Windows; Text base64, kein Quoting-Problem). Best effort: fehlt das Werkzeug, bleibt es bei der Protokollzeile plus Start-Warnung; `talk doctor` zeigt den Weg unter „Toast". Der Aufruf läuft asynchron, ein hängender Notification-Daemon blockiert die Queue nie. Queue: OnToast-Hook, gefeuert bei Toast-Verwurf beim Eintritt und im Worker (nie unter dem Mutex). Tests: notify-Paket mit fake notify-send im PATH, Prozessgrenze: Ruhezeit + toast → fake notify-send bekommt Titel und Text, der Provider keinen Treffer. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9ca32bed9
commit
9e2aa506a6
7 files changed
+279
-6
No files matched your search
+16
-2
@@ -16,6 +16,7 @@ import (
|
|||||||
"code.m3ta.dev/m3tam3re/talk/internal/audio"
|
"code.m3ta.dev/m3tam3re/talk/internal/audio"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
|
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
|
||||||
|
"code.m3ta.dev/m3tam3re/talk/internal/notify"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
||||||
@@ -117,10 +118,13 @@ func doctorChecks(probe bool) []check {
|
|||||||
// 4. Provider-Stufen (+ 5. Gesamt-Verfügbarkeit)
|
// 4. Provider-Stufen (+ 5. Gesamt-Verfügbarkeit)
|
||||||
out = append(out, checkProviders(cfg, probe)...)
|
out = append(out, checkProviders(cfg, probe)...)
|
||||||
|
|
||||||
// 6. Mute
|
// 6. Benachrichtigungen (Ruhezeit-Toast)
|
||||||
|
out = append(out, checkNotify(cfg))
|
||||||
|
|
||||||
|
// 7. Mute
|
||||||
out = append(out, checkMute())
|
out = append(out, checkMute())
|
||||||
|
|
||||||
// 7. Gesetze
|
// 8. Gesetze
|
||||||
out = append(out, checkLaws(cfg))
|
out = append(out, checkLaws(cfg))
|
||||||
|
|
||||||
return out
|
return out
|
||||||
@@ -271,6 +275,16 @@ func probeTier(p provider.Provider) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkNotify(cfg config.Config) check {
|
||||||
|
if notify.Available() {
|
||||||
|
return check{"Toast", statusOK, notify.Describe() + " — für quiet_mode = toast"}
|
||||||
|
}
|
||||||
|
if cfg.QuietMode == queue.QuietModeToast {
|
||||||
|
return check{"Toast", statusWarn, notify.Describe() + " — quiet_mode = toast wird nur protokolliert"}
|
||||||
|
}
|
||||||
|
return check{"Toast", statusOK, notify.Describe() + " (nur für quiet_mode = toast relevant)"}
|
||||||
|
}
|
||||||
|
|
||||||
func checkMute() check {
|
func checkMute() check {
|
||||||
dir, err := config.Dir(getenv)
|
dir, err := config.Dir(getenv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||||
|
"code.m3ta.dev/m3tam3re/talk/internal/notify"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||||
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
||||||
@@ -103,6 +104,23 @@ func Run(ctx context.Context, opts Options) error {
|
|||||||
return err
|
return err
|
||||||
}, lw)
|
}, lw)
|
||||||
|
|
||||||
|
// Ruhezeit-Toast: Desktop-Benachrichtigung statt Audio (quiet_mode =
|
||||||
|
// "toast"); best effort, Fehler nur im Protokoll. Läuft asynchron, damit
|
||||||
|
// ein hängender Notification-Daemon nie die Queue blockiert.
|
||||||
|
if cfg.QuietMode == queue.QuietModeToast {
|
||||||
|
if notify.Available() {
|
||||||
|
q.OnToast(func(item queue.Item) {
|
||||||
|
go func() {
|
||||||
|
if err := notify.Show(context.Background(), item.Text); err != nil {
|
||||||
|
logger.Printf("toast: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.Printf("warnung: quiet_mode = toast, aber %s — Ruhezeit-Ansagen werden nur protokolliert", notify.Describe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir
|
// Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir
|
||||||
// läuft der Daemon ohne beides weiter (Warnung) statt zu scheitern.
|
// läuft der Daemon ohne beides weiter (Warnung) statt zu scheitern.
|
||||||
var mutePath, pidPath string
|
var mutePath, pidPath string
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
// Package notify implementiert Best-effort-Desktop-Notifications parallel
|
|
||||||
// zum Audio (beeep: notify-send / macOS Notification Center / Windows Toast),
|
|
||||||
// mit Abbrechen-Button, wo das OS ihn hergibt. Implementierung folgt.
|
|
||||||
package notify
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Package notify zeigt Desktop-Benachrichtigungen — best effort, über die
|
||||||
|
// Bordmittel des Betriebssystems, ohne Bibliotheks-Abhängigkeit und ohne
|
||||||
|
// CGO (dieselbe Linie wie provider/osbuiltin):
|
||||||
|
//
|
||||||
|
// Linux: notify-send (libnotify; braucht einen Notification-Daemon)
|
||||||
|
// macOS: osascript `display notification`
|
||||||
|
// Windows: PowerShell Toast (Windows.UI.Notifications)
|
||||||
|
//
|
||||||
|
// Fehlende Werkzeuge sind ein klarer Fehler für den Aufrufer (Protokoll),
|
||||||
|
// nie ein Abbruch: Eine Benachrichtigung ist Zusatz, kein Vertrag.
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf16"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Title ist der Titel aller talk-Benachrichtigungen.
|
||||||
|
const Title = "talk"
|
||||||
|
|
||||||
|
// timeout begrenzt den Aufruf des OS-Werkzeugs.
|
||||||
|
const timeout = 10 * time.Second
|
||||||
|
|
||||||
|
// Available meldet, ob auf diesem System ein Benachrichtigungs-Weg
|
||||||
|
// existiert (Binary vorhanden; ob ein Daemon lauscht, zeigt erst Show).
|
||||||
|
func Available() bool {
|
||||||
|
_, err := exec.LookPath(tool())
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe nennt den Weg für `talk doctor`.
|
||||||
|
func Describe() string {
|
||||||
|
t := tool()
|
||||||
|
if _, err := exec.LookPath(t); err != nil {
|
||||||
|
return t + " nicht gefunden"
|
||||||
|
}
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return "osascript (Notification Center)"
|
||||||
|
case "windows":
|
||||||
|
return "powershell (Windows Toast)"
|
||||||
|
default:
|
||||||
|
return "notify-send (libnotify)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tool() string {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
return "osascript"
|
||||||
|
case "windows":
|
||||||
|
return "powershell"
|
||||||
|
default:
|
||||||
|
return "notify-send"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show zeigt eine Benachrichtigung mit Title und text und wartet auf das
|
||||||
|
// Ende des OS-Aufrufs (nicht auf das Schließen der Benachrichtigung).
|
||||||
|
func Show(ctx context.Context, text string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
script := fmt.Sprintf("display notification %s with title %s", appleString(text), appleString(Title))
|
||||||
|
cmd = exec.CommandContext(ctx, "osascript", "-e", script)
|
||||||
|
case "windows":
|
||||||
|
cmd = exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", psEncode(toastScript(text)))
|
||||||
|
default:
|
||||||
|
cmd = exec.CommandContext(ctx, "notify-send", "--app-name="+Title, "--urgency=normal", Title, text)
|
||||||
|
}
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("notify: %s fehlgeschlagen: %w: %s", tool(), err, bytes.TrimSpace(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// appleString macht aus s ein AppleScript-Stringliteral.
|
||||||
|
func appleString(s string) string {
|
||||||
|
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toastScript ist der PowerShell-Toast (Windows 10/11); der Text wird
|
||||||
|
// base64 übergeben, damit es kein Quoting-Problem gibt.
|
||||||
|
func toastScript(text string) string {
|
||||||
|
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
|
||||||
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
||||||
|
$msg = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s'))
|
||||||
|
$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
||||||
|
$nodes = $xml.GetElementsByTagName('text')
|
||||||
|
$nodes.Item(0).AppendChild($xml.CreateTextNode('%s')) | Out-Null
|
||||||
|
$nodes.Item(1).AppendChild($xml.CreateTextNode($msg)) | Out-Null
|
||||||
|
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show([Windows.UI.Notifications.ToastNotification]::new($xml))
|
||||||
|
`, base64.StdEncoding.EncodeToString([]byte(text)), Title, Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// psEncode kodiert ein Skript für -EncodedCommand (UTF-16LE + Base64).
|
||||||
|
func psEncode(script string) string {
|
||||||
|
runes := utf16.Encode([]rune(script))
|
||||||
|
buf := make([]byte, len(runes)*2)
|
||||||
|
for i, r := range runes {
|
||||||
|
binary.LittleEndian.PutUint16(buf[i*2:], r)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(buf)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestShowRuftNotifySend: Auf Linux geht Show über notify-send — hier ein
|
||||||
|
// Fake im PATH, der seine Argumente protokolliert (echter Prozess, kein
|
||||||
|
// Mock-Framework). Titel und Text kommen unverändert an.
|
||||||
|
func TestShowRuftNotifySend(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("notify-send-Weg gibt es nur auf Linux")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
seen := filepath.Join(dir, "seen.txt")
|
||||||
|
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"" + seen + "\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "notify-send"), []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
|
||||||
|
if !Available() {
|
||||||
|
t.Fatal("Available = false mit Fake im PATH")
|
||||||
|
}
|
||||||
|
if err := Show(context.Background(), "Ruhezeit: Build fertig"); err != nil {
|
||||||
|
t.Fatalf("Show: %v", err)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(seen)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args := strings.Split(strings.TrimSpace(string(b)), "\n")
|
||||||
|
if args[len(args)-1] != "Ruhezeit: Build fertig" || args[len(args)-2] != Title {
|
||||||
|
t.Errorf("notify-send-Argumente = %q, want … %q %q", args, Title, "Ruhezeit: Build fertig")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShowOhneWerkzeug: fehlt das Werkzeug, ist das ein klarer Fehler.
|
||||||
|
func TestShowOhneWerkzeug(t *testing.T) {
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
if Available() {
|
||||||
|
t.Fatal("Available = true ohne Werkzeug")
|
||||||
|
}
|
||||||
|
if err := Show(context.Background(), "x"); err == nil {
|
||||||
|
t.Error("Show ohne Werkzeug: kein Fehler")
|
||||||
|
}
|
||||||
|
if !strings.Contains(Describe(), "nicht gefunden") {
|
||||||
|
t.Errorf("Describe = %q", Describe())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppleString(t *testing.T) {
|
||||||
|
if got := appleString(`sag "hallo" \ tschüss`); got != `"sag \"hallo\" \\ tschüss"` {
|
||||||
|
t.Errorf("appleString = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,7 @@ type Queue struct {
|
|||||||
limit *RateLimiter
|
limit *RateLimiter
|
||||||
speak Speaker
|
speak Speaker
|
||||||
log io.Writer
|
log io.Writer
|
||||||
|
toast func(Item) // Ruhezeit-Toast (optional; gesetzt vor dem ersten Enqueue)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
notEmpty *sync.Cond // Signal: neues Item; Broadcast: Close/Slot frei
|
notEmpty *sync.Cond // Signal: neues Item; Broadcast: Close/Slot frei
|
||||||
@@ -146,6 +147,18 @@ func New(laws Laws, speak Speaker, log io.Writer) *Queue {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnToast setzt die Benachrichtigung, die in der Ruhezeit mit quiet_mode =
|
||||||
|
// "toast" statt Audio ausgelöst wird (Daemon: Desktop-Notification). Ohne
|
||||||
|
// Setzen bleibt es bei der Protokollzeile. Vor dem ersten Enqueue setzen.
|
||||||
|
func (q *Queue) OnToast(fn func(Item)) { q.toast = fn }
|
||||||
|
|
||||||
|
// fireToast ruft die Toast-Benachrichtigung auf (nie unter dem Mutex).
|
||||||
|
func (q *Queue) fireToast(item Item) {
|
||||||
|
if q.toast != nil {
|
||||||
|
q.toast(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Enqueue stellt item in die Warteschlange und liefert die Aufnahme-
|
// Enqueue stellt item in die Warteschlange und liefert die Aufnahme-
|
||||||
// Entscheidung: Aufgenommene tragen ihre ID (bei leerer ID neu vergeben:
|
// Entscheidung: Aufgenommene tragen ihre ID (bei leerer ID neu vergeben:
|
||||||
// kurz und zufällig). Ruhezeit-Verwurf/Toast und low über dem Fenster-Budget
|
// kurz und zufällig). Ruhezeit-Verwurf/Toast und low über dem Fenster-Budget
|
||||||
@@ -176,6 +189,7 @@ func (q *Queue) Enqueue(item Item) (Admission, error) {
|
|||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
q.logf("queue: ansage (%q, priorität %s) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
|
q.logf("queue: ansage (%q, priorität %s) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
|
||||||
item.Text, item.Priority, q.laws.Quiet)
|
item.Text, item.Priority, q.laws.Quiet)
|
||||||
|
q.fireToast(item)
|
||||||
return Admission{Discarded: DiscardQuietHours, Toast: true}, nil
|
return Admission{Discarded: DiscardQuietHours, Toast: true}, nil
|
||||||
case verdictDiscardRate:
|
case verdictDiscardRate:
|
||||||
q.mu.Unlock()
|
q.mu.Unlock()
|
||||||
@@ -365,6 +379,7 @@ func (q *Queue) loop() {
|
|||||||
case verdictToast:
|
case verdictToast:
|
||||||
q.logf("queue: ansage %s (%q) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
|
q.logf("queue: ansage %s (%q) durch toast ersetzt — ruhezeit %s aktiv (modus toast)",
|
||||||
item.ID, item.Text, q.laws.Quiet)
|
item.ID, item.Text, q.laws.Quiet)
|
||||||
|
q.fireToast(item)
|
||||||
case verdictDiscardExpired:
|
case verdictDiscardExpired:
|
||||||
q.logf("queue: ansage %s (%q) verworfen — %s gewartet, älter als max_age %s",
|
q.logf("queue: ansage %s (%q) verworfen — %s gewartet, älter als max_age %s",
|
||||||
item.ID, item.Text, time.Since(item.enqueued).Round(time.Second), q.laws.MaxAge)
|
item.ID, item.Text, time.Since(item.enqueued).Round(time.Second), q.laws.MaxAge)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package test
|
||||||
|
|
||||||
|
// Prozessgrenzen-Test für den Ruhezeit-Toast: quiet_mode = toast in aktiver
|
||||||
|
// Ruhezeit löst eine Desktop-Benachrichtigung aus — hier ein fake
|
||||||
|
// notify-send im PATH des Daemons, das seine Argumente protokolliert.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuhezeitToastZeigtNotification(t *testing.T) {
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("fake notify-send gibt es nur auf Linux")
|
||||||
|
}
|
||||||
|
root := t.TempDir()
|
||||||
|
bin := filepath.Join(root, "bin")
|
||||||
|
if err := os.MkdirAll(bin, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := filepath.Join(root, "seen.txt")
|
||||||
|
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"" + seen + "\"\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "notify-send"), []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fake := newFakeProvider(t, 0)
|
||||||
|
d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
||||||
|
"TALK_QUIET_HOURS": fensterUm(),
|
||||||
|
"TALK_QUIET_MODE": "toast",
|
||||||
|
"PATH": bin + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||||
|
})
|
||||||
|
|
||||||
|
code, m := speak(t, d, `{"text":"Build fertig, bitte Review"}`)
|
||||||
|
if code != 202 || m["discarded"] != "quiet_hours" || m["toast"] != true {
|
||||||
|
t.Fatalf("speak in Ruhezeit (toast): %d %v", code, m)
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for {
|
||||||
|
if b, err := os.ReadFile(seen); err == nil {
|
||||||
|
if !strings.Contains(string(b), "Build fertig, bitte Review") || !strings.Contains(string(b), "talk") {
|
||||||
|
t.Errorf("notify-send-Argumente = %q", b)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("notify-send wurde nicht gerufen — Daemon-Log:\n%s", d.stderr.String())
|
||||||
|
}
|
||||||
|
time.Sleep(25 * time.Millisecond)
|
||||||
|
}
|
||||||
|
fake.assertNoHits(t, 200*time.Millisecond)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user