feat(desktop): per-event notification sounds and alert controls (#968)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Taylor Ho
2026-06-11 07:31:19 -07:00
committed by GitHub
parent 1ca16c898c
commit 4e4dc723e4
36 changed files with 942 additions and 215 deletions

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="10.00" y="3.00" width="5" height="74.00" rx="2.50"/><rect x="18.33" y="8.36" width="5" height="63.28" rx="2.50"/><rect x="26.67" y="18.64" width="5" height="42.72" rx="2.50"/><rect x="35.00" y="11.99" width="5" height="56.02" rx="2.50"/><rect x="43.33" y="13.08" width="5" height="53.85" rx="2.50"/><rect x="51.67" y="20.68" width="5" height="38.63" rx="2.50"/><rect x="60.00" y="23.10" width="5" height="33.80" rx="2.50"/><rect x="68.33" y="22.30" width="5" height="35.40" rx="2.50"/><rect x="76.67" y="25.61" width="5" height="28.78" rx="2.50"/><rect x="85.00" y="28.18" width="5" height="23.64" rx="2.50"/><rect x="93.33" y="28.91" width="5" height="22.17" rx="2.50"/><rect x="101.67" y="33.65" width="5" height="12.71" rx="2.50"/><rect x="110.00" y="32.97" width="5" height="14.06" rx="2.50"/><rect x="118.33" y="33.99" width="5" height="12.02" rx="2.50"/><rect x="126.67" y="35.91" width="5" height="8.19" rx="2.50"/><rect x="135.00" y="36.06" width="5" height="7.89" rx="2.50"/><rect x="143.33" y="36.28" width="5" height="7.43" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="37.35" width="5" height="5.30" rx="2.50"/><rect x="168.33" y="37.88" width="5" height="4.24" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="19.67" width="5" height="40.65" rx="2.50"/><rect x="10.00" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="18.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="26.67" y="10.11" width="5" height="59.77" rx="2.50"/><rect x="35.00" y="27.76" width="5" height="24.49" rx="2.50"/><rect x="43.33" y="20.11" width="5" height="39.78" rx="2.50"/><rect x="51.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="60.00" y="17.60" width="5" height="44.80" rx="2.50"/><rect x="68.33" y="30.95" width="5" height="18.09" rx="2.50"/><rect x="76.67" y="31.55" width="5" height="16.89" rx="2.50"/><rect x="85.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="93.33" y="33.83" width="5" height="12.35" rx="2.50"/><rect x="101.67" y="36.44" width="5" height="7.11" rx="2.50"/><rect x="110.00" y="36.38" width="5" height="7.25" rx="2.50"/><rect x="118.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="126.67" y="36.61" width="5" height="6.79" rx="2.50"/><rect x="135.00" y="37.94" width="5" height="4.13" rx="2.50"/><rect x="143.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="7.87" width="5" height="64.26" rx="2.50"/><rect x="10.00" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="18.33" y="11.60" width="5" height="56.79" rx="2.50"/><rect x="26.67" y="14.94" width="5" height="50.12" rx="2.50"/><rect x="35.00" y="21.58" width="5" height="36.83" rx="2.50"/><rect x="43.33" y="21.79" width="5" height="36.43" rx="2.50"/><rect x="51.67" y="23.09" width="5" height="33.82" rx="2.50"/><rect x="60.00" y="28.66" width="5" height="22.67" rx="2.50"/><rect x="68.33" y="29.10" width="5" height="21.80" rx="2.50"/><rect x="76.67" y="29.98" width="5" height="20.04" rx="2.50"/><rect x="85.00" y="32.32" width="5" height="15.36" rx="2.50"/><rect x="93.33" y="33.17" width="5" height="13.65" rx="2.50"/><rect x="101.67" y="33.96" width="5" height="12.08" rx="2.50"/><rect x="110.00" y="35.46" width="5" height="9.08" rx="2.50"/><rect x="118.33" y="36.38" width="5" height="7.25" rx="2.50"/><rect x="126.67" y="36.65" width="5" height="6.71" rx="2.50"/><rect x="135.00" y="36.68" width="5" height="6.64" rx="2.50"/><rect x="143.33" y="37.11" width="5" height="5.77" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="10.00" y="9.00" width="5" height="62.01" rx="2.50"/><rect x="18.33" y="7.32" width="5" height="65.36" rx="2.50"/><rect x="26.67" y="11.42" width="5" height="57.16" rx="2.50"/><rect x="35.00" y="14.72" width="5" height="50.56" rx="2.50"/><rect x="43.33" y="17.31" width="5" height="45.37" rx="2.50"/><rect x="51.67" y="20.92" width="5" height="38.17" rx="2.50"/><rect x="60.00" y="24.76" width="5" height="30.47" rx="2.50"/><rect x="68.33" y="23.17" width="5" height="33.67" rx="2.50"/><rect x="76.67" y="23.37" width="5" height="33.27" rx="2.50"/><rect x="85.00" y="29.60" width="5" height="20.80" rx="2.50"/><rect x="93.33" y="30.37" width="5" height="19.26" rx="2.50"/><rect x="101.67" y="30.79" width="5" height="18.42" rx="2.50"/><rect x="110.00" y="33.84" width="5" height="12.32" rx="2.50"/><rect x="118.33" y="35.06" width="5" height="9.88" rx="2.50"/><rect x="126.67" y="36.65" width="5" height="6.69" rx="2.50"/><rect x="135.00" y="36.73" width="5" height="6.54" rx="2.50"/><rect x="143.33" y="37.24" width="5" height="5.53" rx="2.50"/><rect x="151.67" y="37.62" width="5" height="4.76" rx="2.50"/><rect x="160.00" y="37.89" width="5" height="4.22" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="10.28" width="5" height="59.44" rx="2.50"/><rect x="10.00" y="14.91" width="5" height="50.18" rx="2.50"/><rect x="18.33" y="21.57" width="5" height="36.86" rx="2.50"/><rect x="26.67" y="34.24" width="5" height="11.52" rx="2.50"/><rect x="35.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="43.33" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="51.67" y="12.11" width="5" height="55.79" rx="2.50"/><rect x="60.00" y="14.22" width="5" height="51.56" rx="2.50"/><rect x="68.33" y="20.95" width="5" height="38.10" rx="2.50"/><rect x="76.67" y="20.07" width="5" height="39.86" rx="2.50"/><rect x="85.00" y="24.93" width="5" height="30.14" rx="2.50"/><rect x="93.33" y="23.98" width="5" height="32.04" rx="2.50"/><rect x="101.67" y="32.74" width="5" height="14.52" rx="2.50"/><rect x="110.00" y="29.44" width="5" height="21.11" rx="2.50"/><rect x="118.33" y="37.66" width="5" height="4.67" rx="2.50"/><rect x="126.67" y="35.15" width="5" height="9.71" rx="2.50"/><rect x="135.00" y="35.99" width="5" height="8.01" rx="2.50"/><rect x="143.33" y="36.24" width="5" height="7.51" rx="2.50"/><rect x="151.67" y="37.56" width="5" height="4.88" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="5.39" width="5" height="69.22" rx="2.50"/><rect x="10.00" y="4.44" width="5" height="71.11" rx="2.50"/><rect x="18.33" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="26.67" y="7.87" width="5" height="64.26" rx="2.50"/><rect x="35.00" y="24.86" width="5" height="30.28" rx="2.50"/><rect x="43.33" y="12.97" width="5" height="54.05" rx="2.50"/><rect x="51.67" y="12.07" width="5" height="55.86" rx="2.50"/><rect x="60.00" y="19.31" width="5" height="41.39" rx="2.50"/><rect x="68.33" y="26.49" width="5" height="27.03" rx="2.50"/><rect x="76.67" y="28.00" width="5" height="24.01" rx="2.50"/><rect x="85.00" y="24.67" width="5" height="30.65" rx="2.50"/><rect x="93.33" y="29.64" width="5" height="20.72" rx="2.50"/><rect x="101.67" y="30.30" width="5" height="19.40" rx="2.50"/><rect x="110.00" y="35.63" width="5" height="8.74" rx="2.50"/><rect x="118.33" y="36.84" width="5" height="6.32" rx="2.50"/><rect x="126.67" y="37.50" width="5" height="5.01" rx="2.50"/><rect x="135.00" y="34.64" width="5" height="10.72" rx="2.50"/><rect x="143.33" y="35.92" width="5" height="8.16" rx="2.50"/><rect x="151.67" y="36.39" width="5" height="7.21" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="10.00" y="13.42" width="5" height="53.15" rx="2.50"/><rect x="18.33" y="23.34" width="5" height="33.31" rx="2.50"/><rect x="26.67" y="21.82" width="5" height="36.35" rx="2.50"/><rect x="35.00" y="23.34" width="5" height="33.32" rx="2.50"/><rect x="43.33" y="25.31" width="5" height="29.38" rx="2.50"/><rect x="51.67" y="28.81" width="5" height="22.38" rx="2.50"/><rect x="60.00" y="28.42" width="5" height="23.15" rx="2.50"/><rect x="68.33" y="27.25" width="5" height="25.50" rx="2.50"/><rect x="76.67" y="30.28" width="5" height="19.43" rx="2.50"/><rect x="85.00" y="32.79" width="5" height="14.41" rx="2.50"/><rect x="93.33" y="33.78" width="5" height="12.44" rx="2.50"/><rect x="101.67" y="34.80" width="5" height="10.41" rx="2.50"/><rect x="110.00" y="35.70" width="5" height="8.61" rx="2.50"/><rect x="118.33" y="35.96" width="5" height="8.07" rx="2.50"/><rect x="126.67" y="35.88" width="5" height="8.25" rx="2.50"/><rect x="135.00" y="36.59" width="5" height="6.83" rx="2.50"/><rect x="143.33" y="37.17" width="5" height="5.66" rx="2.50"/><rect x="151.67" y="37.79" width="5" height="4.41" rx="2.50"/><rect x="160.00" y="37.97" width="5" height="4.06" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="14.16" width="5" height="51.68" rx="2.50"/><rect x="10.00" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="18.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="26.67" y="20.62" width="5" height="38.76" rx="2.50"/><rect x="35.00" y="16.12" width="5" height="47.76" rx="2.50"/><rect x="43.33" y="19.55" width="5" height="40.89" rx="2.50"/><rect x="51.67" y="30.74" width="5" height="18.51" rx="2.50"/><rect x="60.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="68.33" y="37.01" width="5" height="5.98" rx="2.50"/><rect x="76.67" y="32.34" width="5" height="15.32" rx="2.50"/><rect x="85.00" y="33.33" width="5" height="13.33" rx="2.50"/><rect x="93.33" y="33.12" width="5" height="13.76" rx="2.50"/><rect x="101.67" y="35.70" width="5" height="8.60" rx="2.50"/><rect x="110.00" y="36.60" width="5" height="6.79" rx="2.50"/><rect x="118.33" y="37.52" width="5" height="4.95" rx="2.50"/><rect x="126.67" y="37.00" width="5" height="6.00" rx="2.50"/><rect x="135.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="143.33" y="37.66" width="5" height="4.68" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="10.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="18.33" y="31.80" width="5" height="16.40" rx="2.50"/><rect x="26.67" y="29.26" width="5" height="21.48" rx="2.50"/><rect x="35.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="43.33" y="34.63" width="5" height="10.73" rx="2.50"/><rect x="51.67" y="31.00" width="5" height="17.99" rx="2.50"/><rect x="60.00" y="32.54" width="5" height="14.91" rx="2.50"/><rect x="68.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="76.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="85.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="93.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="101.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="110.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="118.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="126.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="135.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="143.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="14.62" width="5" height="50.75" rx="2.50"/><rect x="10.00" y="4.48" width="5" height="71.03" rx="2.50"/><rect x="18.33" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="26.67" y="6.08" width="5" height="67.84" rx="2.50"/><rect x="35.00" y="10.60" width="5" height="58.80" rx="2.50"/><rect x="43.33" y="8.43" width="5" height="63.15" rx="2.50"/><rect x="51.67" y="18.86" width="5" height="42.28" rx="2.50"/><rect x="60.00" y="20.74" width="5" height="38.52" rx="2.50"/><rect x="68.33" y="25.25" width="5" height="29.49" rx="2.50"/><rect x="76.67" y="28.57" width="5" height="22.87" rx="2.50"/><rect x="85.00" y="32.22" width="5" height="15.55" rx="2.50"/><rect x="93.33" y="31.86" width="5" height="16.28" rx="2.50"/><rect x="101.67" y="32.85" width="5" height="14.30" rx="2.50"/><rect x="110.00" y="35.16" width="5" height="9.67" rx="2.50"/><rect x="118.33" y="35.30" width="5" height="9.40" rx="2.50"/><rect x="126.67" y="36.28" width="5" height="7.45" rx="2.50"/><rect x="135.00" y="36.90" width="5" height="6.20" rx="2.50"/><rect x="143.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="10.00" y="2.17" width="5" height="75.67" rx="2.50"/><rect x="18.33" y="10.04" width="5" height="59.92" rx="2.50"/><rect x="26.67" y="12.17" width="5" height="55.66" rx="2.50"/><rect x="35.00" y="21.25" width="5" height="37.51" rx="2.50"/><rect x="43.33" y="26.84" width="5" height="26.32" rx="2.50"/><rect x="51.67" y="26.12" width="5" height="27.75" rx="2.50"/><rect x="60.00" y="26.64" width="5" height="26.72" rx="2.50"/><rect x="68.33" y="30.07" width="5" height="19.85" rx="2.50"/><rect x="76.67" y="30.79" width="5" height="18.42" rx="2.50"/><rect x="85.00" y="32.20" width="5" height="15.59" rx="2.50"/><rect x="93.33" y="33.44" width="5" height="13.11" rx="2.50"/><rect x="101.67" y="33.88" width="5" height="12.24" rx="2.50"/><rect x="110.00" y="35.00" width="5" height="10.00" rx="2.50"/><rect x="118.33" y="35.81" width="5" height="8.39" rx="2.50"/><rect x="126.67" y="36.57" width="5" height="6.87" rx="2.50"/><rect x="135.00" y="37.21" width="5" height="5.58" rx="2.50"/><rect x="143.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="151.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="160.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" fill="currentColor" aria-hidden="true"><rect x="1.67" y="6.75" width="5" height="66.50" rx="2.50"/><rect x="10.00" y="2.00" width="5" height="76.00" rx="2.50"/><rect x="18.33" y="8.01" width="5" height="63.98" rx="2.50"/><rect x="26.67" y="14.07" width="5" height="51.85" rx="2.50"/><rect x="35.00" y="34.92" width="5" height="10.15" rx="2.50"/><rect x="43.33" y="17.60" width="5" height="44.80" rx="2.50"/><rect x="51.67" y="22.20" width="5" height="35.60" rx="2.50"/><rect x="60.00" y="23.30" width="5" height="33.41" rx="2.50"/><rect x="68.33" y="22.91" width="5" height="34.17" rx="2.50"/><rect x="76.67" y="31.39" width="5" height="17.23" rx="2.50"/><rect x="85.00" y="29.49" width="5" height="21.03" rx="2.50"/><rect x="93.33" y="29.16" width="5" height="21.67" rx="2.50"/><rect x="101.67" y="35.82" width="5" height="8.37" rx="2.50"/><rect x="110.00" y="34.37" width="5" height="11.27" rx="2.50"/><rect x="118.33" y="35.79" width="5" height="8.42" rx="2.50"/><rect x="126.67" y="35.92" width="5" height="8.17" rx="2.50"/><rect x="135.00" y="36.59" width="5" height="6.83" rx="2.50"/><rect x="143.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="151.67" y="37.65" width="5" height="4.69" rx="2.50"/><rect x="160.00" y="37.96" width="5" height="4.09" rx="2.50"/><rect x="168.33" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="176.67" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="185.00" y="38.00" width="5" height="4.00" rx="2.50"/><rect x="193.33" y="38.00" width="5" height="4.00" rx="2.50"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,150 @@
// Generates a waveform SVG next to each mp3 in the given directory, for the
// notification sound picker (SoundPicker.tsx renders them via CSS mask).
// Requires ffmpeg on PATH.
//
// node scripts/generate-sound-waveforms.mjs public/sounds
import { execFileSync } from "node:child_process";
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { join, basename } from "node:path";
import { tmpdir } from "node:os";
const SOUNDS_DIR = process.argv[2];
if (!SOUNDS_DIR) {
console.error("usage: node generate-sound-waveforms.mjs <sounds-dir>");
process.exit(1);
}
const BARS = 24;
const WIDTH = 200;
const HEIGHT = 80;
const BAR_WIDTH = 5;
const MAX_BAR = 76; // tallest bar, leaves vertical breathing room
const MIN_BAR = 4; // floor so quiet buckets render a short capsule
const GAMMA = 1.6; // contrast curve: deepens valleys, keeps peaks tall
const DETAIL_GAIN = 1.8; // unsharp mask on bar levels: amplifies each bar's deviation from its neighborhood so peaks push higher and valleys dip lower
const TAIL_FLOOR = 0.1; // windows quieter than this fraction of the loudest window are trimmed
const mp3s = readdirSync(SOUNDS_DIR)
.filter((f) => f.endsWith(".mp3"))
.sort();
for (const mp3 of mp3s) {
const name = basename(mp3, ".mp3");
const raw = join(tmpdir(), `${name}.s16le`);
execFileSync("ffmpeg", [
"-y",
"-v",
"error",
"-i",
join(SOUNDS_DIR, mp3),
"-ac",
"1",
"-f",
"s16le",
"-acodec",
"pcm_s16le",
raw,
]);
const buf = readFileSync(raw);
const samples = new Int16Array(
buf.buffer,
buf.byteOffset,
buf.byteLength / 2,
);
// File peak for normalization + silence threshold.
let filePeak = 1;
for (const s of samples) {
const a = Math.abs(s);
if (a > filePeak) filePeak = a;
}
// Trim by windowed RMS energy rather than single-sample threshold —
// notification sounds carry long reverb tails that read as dead space.
// Keep the region holding the audible body (windows above TAIL_FLOOR of
// the loudest window), so the shape spans the full bar count.
const WINDOW = 1024;
const windowCount = Math.ceil(samples.length / WINDOW);
const windowRms = new Array(windowCount).fill(0);
for (let w = 0; w < windowCount; w++) {
const from = w * WINDOW;
const to = Math.min(samples.length, from + WINDOW);
let sumSquares = 0;
for (let j = from; j < to; j++) sumSquares += samples[j] * samples[j];
windowRms[w] = Math.sqrt(sumSquares / Math.max(1, to - from));
}
const maxWindowRms = Math.max(...windowRms, 1);
const tailThreshold = maxWindowRms * TAIL_FLOOR;
let firstWindow = 0;
while (
firstWindow < windowCount - 1 &&
windowRms[firstWindow] < tailThreshold
)
firstWindow++;
let lastWindow = windowCount - 1;
while (lastWindow > firstWindow && windowRms[lastWindow] < tailThreshold)
lastWindow--;
const trimmed = samples.subarray(
firstWindow * WINDOW,
Math.min(samples.length, (lastWindow + 1) * WINDOW),
);
// Sample a narrow window centered in each bar's bucket instead of
// max-pooling the whole bucket — max-pooling erases amplitude modulation
// (beats, double hits), which is what makes the shapes distinct. Then a
// gamma curve to stretch the range.
const peaks = new Array(BARS).fill(0);
const bucketSize = Math.max(1, Math.floor(trimmed.length / BARS));
const window = Math.max(64, Math.floor(bucketSize / 4));
for (let i = 0; i < BARS; i++) {
const center = i * bucketSize + Math.floor(bucketSize / 2);
const from = Math.max(0, center - Math.floor(window / 2));
const to = Math.min(trimmed.length, from + window);
let peak = 0;
for (let j = from; j < to; j++) {
const a = Math.abs(trimmed[j]);
if (a > peak) peak = a;
}
peaks[i] = peak;
}
const maxLevel = Math.max(...peaks, 1);
for (let i = 0; i < BARS; i++) {
peaks[i] = (peaks[i] / maxLevel) ** GAMMA;
}
// Local-contrast pass: push each bar away from the average of its
// neighbors, then renormalize so the tallest bar still hits MAX_BAR.
const sharpened = peaks.map((p, i) => {
let sum = 0;
let count = 0;
for (let j = Math.max(0, i - 2); j <= Math.min(BARS - 1, i + 2); j++) {
sum += peaks[j];
count++;
}
const local = sum / count;
return Math.max(0, p + DETAIL_GAIN * (p - local));
});
const maxSharpened = Math.max(...sharpened, 0.001);
for (let i = 0; i < BARS; i++) {
peaks[i] = Math.min(1, sharpened[i] / maxSharpened);
}
const step = WIDTH / BARS;
const bars = peaks
.map((p, i) => {
const h = Math.max(MIN_BAR, p * MAX_BAR);
const x = (i * step + (step - BAR_WIDTH) / 2).toFixed(2);
const y = ((HEIGHT - h) / 2).toFixed(2);
return `<rect x="${x}" y="${y}" width="${BAR_WIDTH}" height="${h.toFixed(2)}" rx="${(BAR_WIDTH / 2).toFixed(2)}"/>`;
})
.join("");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" fill="currentColor" aria-hidden="true">${bars}</svg>\n`;
writeFileSync(join(SOUNDS_DIR, `${name}.svg`), svg);
const seconds = (trimmed.length / 44100).toFixed(2);
console.log(
`${name}.svg (${seconds}s audible, peak ${(filePeak / 32768).toFixed(2)})`,
);
}

View File

@@ -24,6 +24,7 @@ import {
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications";
import { getThreadReference } from "@/features/messages/lib/threading";
import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify";
import { useThreadFollows } from "@/features/messages/lib/useThreadFollows";
import {
useHomeFeedNotifications,
@@ -37,7 +38,10 @@ import {
setDesktopAppBadge,
type DesktopNotificationTarget,
} from "@/features/notifications/lib/desktop";
import { playNotificationSound } from "@/features/notifications/lib/sound";
import {
playNotificationSound,
resolveSlotSound,
} from "@/features/notifications/lib/sound";
import { PreventSleepProvider } from "@/features/agents/usePreventSleep";
import {
usePresenceSession,
@@ -237,7 +241,10 @@ export function AppShell() {
const handleDmNotification = React.useEffectEvent(
(event: RelayEvent, channel: Channel) => {
if (!notificationSettings.settings.desktopEnabled) {
if (
!notificationSettings.settings.desktopEnabled ||
!notificationSettings.settings.slotAlertsEnabled.dm
) {
return;
}
@@ -267,7 +274,9 @@ export function AppShell() {
},
}).then((didSend) => {
if (!didSend) return;
if (notificationSettings.settings.soundEnabled) playNotificationSound();
playNotificationSound(
resolveSlotSound(notificationSettings.settings, "dm"),
);
void requestDockBounce();
});
},
@@ -292,6 +301,57 @@ export function AppShell() {
[channels, selectedChannelId],
);
const handleThreadReplyDesktopNotification = React.useEffectEvent(
(channelId: string, event: RelayEvent) => {
if (
!notificationSettings.settings.desktopEnabled ||
!notificationSettings.settings.slotAlertsEnabled.thread_reply
) {
return;
}
// Replies that @-mention the user are owned by the home-feed mention
// path — skip them here so they don't notify (and sound) twice.
const pubkey = identityQuery.data?.pubkey?.trim().toLowerCase() ?? "";
if (hasMentionForEvent(event, pubkey)) {
return;
}
const channel = channels.find((entry) => entry.id === channelId);
const channelName = channel?.name?.trim() || "Thread";
const content = event.content.trim();
const body =
content.length > 0
? content.length > 140
? `${content.slice(0, 137).trimEnd()}...`
: content
: "New reply";
const threadRootId = getThreadReference(event.tags).rootId ?? null;
void sendDesktopNotification({
title: `Reply in ${channelName}`,
body,
target: {
channelId,
channelName,
content: event.content,
createdAt: event.created_at,
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
threadRootId,
},
}).then((didSend) => {
if (!didSend) return;
playNotificationSound(
resolveSlotSound(notificationSettings.settings, "thread_reply"),
);
void requestDockBounce();
});
},
);
const {
followedRootIds,
isFollowing: isFollowingThread,
@@ -324,9 +384,11 @@ export function AppShell() {
relayClient,
currentPubkey: identityQuery.data?.pubkey,
mutedChannelIds,
notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing,
onChannelMessage: handleChannelNotification,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedOnLiveMention,
onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification,
followedRootIds,
},
);
@@ -723,13 +785,16 @@ export function AppShell() {
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
onSetSlotAlertsEnabled={
notificationSettings.setSlotAlertsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
onSetNotifyWhileViewing={
notificationSettings.setNotifyWhileViewing
}
onSetSoundEnabled={notificationSettings.setSoundEnabled}
onSetAllSlotAlertsEnabled={
notificationSettings.setAllSlotAlertsEnabled
}
onSetSoundForSlot={notificationSettings.setSoundForSlot}
section={settingsSection}
/>
</React.Suspense>

View File

@@ -21,6 +21,11 @@ import { isDmNotifiableKind } from "./isDmNotifiableKind";
export type UseLiveChannelUpdatesOptions = {
currentPubkey?: string;
/**
* When true, DM notifications also fire for the channel the user is
* currently viewing (normally suppressed).
*/
notifyForActiveChannel?: boolean;
onDmMessage?: (event: RelayEvent, channel: Channel) => void;
onLiveMention?: () => void;
/**
@@ -31,6 +36,16 @@ export type UseLiveChannelUpdatesOptions = {
*/
onChannelMessage?: (channelId: string, event: RelayEvent) => void;
onThreadReplyNotification?: (channelId: string, event: RelayEvent) => void;
/**
* Fired for replies in threads the user authored, participated in, or
* follows (non-DM channels only — the DM path owns those). Follows the DM
* active-channel rule: suppressed for the channel being viewed unless
* notifyForActiveChannel opts in.
*/
onThreadReplyDesktopNotification?: (
channelId: string,
event: RelayEvent,
) => void;
onSelfChannelMessage?: (event: RelayEvent) => void;
participatedRootIds?: ReadonlySet<string>;
followedRootIds?: ReadonlySet<string>;
@@ -139,8 +154,9 @@ export function useLiveChannelUpdates(
return;
}
// Don't fire a notification for the channel the user is already viewing.
if (channelId === activeChannelId) {
// Don't fire a notification for the channel the user is already viewing,
// unless the notify-while-viewing setting opts in.
if (channelId === activeChannelId && !options.notifyForActiveChannel) {
return;
}
@@ -155,7 +171,8 @@ export function useLiveChannelUpdates(
// Track DM events even for the active channel so the dedup set stays
// current. The handler itself skips firing the notification callback
// when the user is already viewing the DM.
// when the user is already viewing the DM (unless opted in via
// notifyForActiveChannel).
handleDmEvent(event);
if (!liveChannelIds.has(channelId)) {
@@ -193,13 +210,19 @@ export function useLiveChannelUpdates(
})
) {
options.onChannelMessage?.(channelId, event);
if (channelId !== activeChannelId) {
const ref = getThreadReference(event.tags);
const isThreadReply =
ref.parentId !== null && !isBroadcastReply(event.tags);
if (isThreadReply) {
const ref = getThreadReference(event.tags);
const isThreadReply =
ref.parentId !== null && !isBroadcastReply(event.tags);
if (isThreadReply) {
if (channelId !== activeChannelId) {
options.onThreadReplyNotification?.(channelId, event);
}
if (
!dmChannelMap.has(channelId) &&
(channelId !== activeChannelId || options.notifyForActiveChannel)
) {
options.onThreadReplyDesktopNotification?.(channelId, event);
}
}
}

View File

@@ -9,6 +9,16 @@ import {
requestDesktopNotificationAccess,
type DesktopNotificationPermissionState,
} from "./lib/desktop";
import {
COMING_SOON_SLOTS,
DEFAULT_SLOT_ALERTS_ENABLED,
DEFAULT_SLOT_SOUNDS,
SOUND_NAMES,
SOUND_SLOTS,
type SlotSounds,
type SoundName,
type SoundSlot,
} from "./lib/sound";
import {
readStoredSeenFeedIds,
useFeedDesktopNotifications,
@@ -17,25 +27,62 @@ import {
export type { DesktopNotificationPermissionState } from "./lib/desktop";
const NOTIFICATION_SETTINGS_STORAGE_KEY = "sprout-notification-settings.v1";
// v2: settings model reworked around per-event rows (flutter default sound,
// slotAlertsEnabled, no singleSound/soundEnabled) — v1 values are abandoned.
const NOTIFICATION_SETTINGS_STORAGE_KEY = "sprout-notification-settings.v2";
const HOME_FEED_SEEN_MAX_ITEMS = 500;
export type NotificationSettings = {
desktopEnabled: boolean;
homeBadgeEnabled: boolean;
mentions: boolean;
needsAction: boolean;
soundEnabled: boolean;
notifyWhileViewing: boolean;
sounds: SlotSounds;
slotAlertsEnabled: Record<SoundSlot, boolean>;
/**
* Per-row state captured when the master switch bulk-disables, so turning
* it back on restores the user's granular picks instead of enabling all.
* Cleared by any individual row toggle.
*/
slotAlertsSnapshot: Record<SoundSlot, boolean> | null;
};
const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
desktopEnabled: true,
homeBadgeEnabled: true,
mentions: true,
needsAction: true,
soundEnabled: true,
notifyWhileViewing: false,
sounds: { ...DEFAULT_SLOT_SOUNDS },
slotAlertsEnabled: { ...DEFAULT_SLOT_ALERTS_ENABLED },
slotAlertsSnapshot: null,
};
const SOUND_NAME_SET = new Set<SoundName>(SOUND_NAMES);
function sanitizeSoundsMap(value: unknown): SlotSounds {
const result = { ...DEFAULT_SLOT_SOUNDS };
if (!value || typeof value !== "object") return result;
const candidate = value as Partial<Record<SoundSlot, unknown>>;
for (const slot of SOUND_SLOTS) {
const picked = candidate[slot];
if (typeof picked === "string" && SOUND_NAME_SET.has(picked as SoundName)) {
result[slot] = picked as SoundName;
}
}
return result;
}
function sanitizeSlotAlertsEnabled(value: unknown): Record<SoundSlot, boolean> {
const result = { ...DEFAULT_SLOT_ALERTS_ENABLED };
if (!value || typeof value !== "object") return result;
const candidate = value as Partial<Record<SoundSlot, unknown>>;
for (const slot of SOUND_SLOTS) {
const picked = candidate[slot];
if (typeof picked === "boolean") {
result[slot] = picked;
}
}
return result;
}
function notificationSettingsStorageKey(pubkey: string) {
return `${NOTIFICATION_SETTINGS_STORAGE_KEY}:${pubkey}`;
}
@@ -55,18 +102,17 @@ function sanitizeNotificationSettings(value: unknown): NotificationSettings {
typeof candidate.homeBadgeEnabled === "boolean"
? candidate.homeBadgeEnabled
: DEFAULT_NOTIFICATION_SETTINGS.homeBadgeEnabled,
mentions:
typeof candidate.mentions === "boolean"
? candidate.mentions
: DEFAULT_NOTIFICATION_SETTINGS.mentions,
needsAction:
typeof candidate.needsAction === "boolean"
? candidate.needsAction
: DEFAULT_NOTIFICATION_SETTINGS.needsAction,
soundEnabled:
typeof candidate.soundEnabled === "boolean"
? candidate.soundEnabled
: DEFAULT_NOTIFICATION_SETTINGS.soundEnabled,
notifyWhileViewing:
typeof candidate.notifyWhileViewing === "boolean"
? candidate.notifyWhileViewing
: DEFAULT_NOTIFICATION_SETTINGS.notifyWhileViewing,
sounds: sanitizeSoundsMap(candidate.sounds),
slotAlertsEnabled: sanitizeSlotAlertsEnabled(candidate.slotAlertsEnabled),
slotAlertsSnapshot:
candidate.slotAlertsSnapshot != null &&
typeof candidate.slotAlertsSnapshot === "object"
? sanitizeSlotAlertsEnabled(candidate.slotAlertsSnapshot)
: null,
};
}
@@ -219,26 +265,67 @@ export function useNotificationSettings(pubkey?: string) {
}));
}, []);
const setMentionsEnabled = React.useCallback((enabled: boolean) => {
const setNotifyWhileViewing = React.useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
mentions: enabled,
notifyWhileViewing: enabled,
}));
}, []);
const setNeedsActionEnabled = React.useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
needsAction: enabled,
}));
const setAllSlotAlertsEnabled = React.useCallback((enabled: boolean) => {
setSettings((current) => {
const next = { ...current.slotAlertsEnabled };
if (!enabled) {
// Super-switch off: remember the granular picks, zero the live rows.
for (const slot of SOUND_SLOTS) {
if (!COMING_SOON_SLOTS.has(slot)) {
next[slot] = false;
}
}
return {
...current,
slotAlertsEnabled: next,
slotAlertsSnapshot: { ...current.slotAlertsEnabled },
};
}
// Super-switch on: restore the snapshot when it has anything on,
// otherwise enable every live row.
const snapshot = current.slotAlertsSnapshot;
const snapshotHasAlerts =
snapshot != null &&
SOUND_SLOTS.some(
(slot) => !COMING_SOON_SLOTS.has(slot) && snapshot[slot],
);
for (const slot of SOUND_SLOTS) {
if (!COMING_SOON_SLOTS.has(slot)) {
next[slot] = snapshotHasAlerts ? (snapshot?.[slot] ?? true) : true;
}
}
return { ...current, slotAlertsEnabled: next, slotAlertsSnapshot: null };
});
}, []);
const setSoundEnabled = React.useCallback((enabled: boolean) => {
setSettings((current) => ({
...current,
soundEnabled: enabled,
}));
}, []);
const setSlotAlertsEnabled = React.useCallback(
(slot: SoundSlot, enabled: boolean) => {
setSettings((current) => ({
...current,
slotAlertsEnabled: { ...current.slotAlertsEnabled, [slot]: enabled },
// A manual row toggle supersedes any pending super-switch snapshot.
slotAlertsSnapshot: null,
}));
},
[],
);
const setSoundForSlot = React.useCallback(
(slot: SoundSlot, name: SoundName) => {
setSettings((current) => ({
...current,
sounds: { ...current.sounds, [slot]: name },
}));
},
[],
);
return {
errorMessage,
@@ -246,9 +333,10 @@ export function useNotificationSettings(pubkey?: string) {
permission,
setDesktopEnabled,
setHomeBadgeEnabled,
setMentionsEnabled,
setNeedsActionEnabled,
setSoundEnabled,
setAllSlotAlertsEnabled,
setNotifyWhileViewing,
setSlotAlertsEnabled,
setSoundForSlot,
settings,
};
}

View File

@@ -1,20 +1,156 @@
let cachedAudio: HTMLAudioElement | null = null;
import {
KIND_APPROVAL_REQUEST,
KIND_JOB_ACCEPTED,
KIND_JOB_ERROR,
KIND_JOB_PROGRESS,
KIND_JOB_RESULT,
} from "@/shared/constants/kinds";
import type { FeedItemCategory } from "@/shared/api/types";
function getNotificationAudio(): HTMLAudioElement {
if (!cachedAudio) {
cachedAudio = new Audio("/sounds/desktop-notification.mp3");
}
return cachedAudio;
export const SOUND_NAMES = [
"bong",
"boo",
"dng",
"doo",
"doodone",
"doong",
"doop",
"flirl",
"flutter",
"oh-no",
"ping",
"unison",
] as const;
export type SoundName = (typeof SOUND_NAMES)[number];
export const SOUND_SLOTS = [
"dm",
"mention",
"thread_reply",
"needs_action",
"job_accepted",
"job_progress",
"job_result",
"job_error",
] as const;
export type SoundSlot = (typeof SOUND_SLOTS)[number];
export const SLOT_LABELS: Record<SoundSlot, string> = {
dm: "Direct messages",
mention: "@Mentions",
thread_reply: "Thread replies",
needs_action: "Needs action",
job_accepted: "Agent: job accepted",
job_progress: "Agent: progress update",
job_result: "Agent: job result",
job_error: "Agent: job error",
};
// The agent job protocol (kinds 43001-43006) is defined and queryable but
// nothing emits the events yet — buzz-acp publishes plain stream messages.
// These slots stay wired (resolver, defaults, settings) but render disabled
// with a "coming soon" badge until an emitter exists.
export const COMING_SOON_SLOTS: ReadonlySet<SoundSlot> = new Set([
"job_accepted",
"job_progress",
"job_result",
"job_error",
]);
export const SLOT_DESCRIPTIONS: Record<SoundSlot, string> = {
dm: "When someone messages you directly.",
mention: "When someone tags you in a channel.",
thread_reply: "When someone replies in a thread you follow or posted in.",
needs_action: "When an approval or reminder is waiting on you.",
job_accepted: "When an agent picks up a job.",
job_progress: "While an agent works through a job.",
job_result: "When an agent finishes a job.",
job_error: "When an agent job fails.",
};
export const RECOMMENDED_SOUND_BY_SLOT: Record<SoundSlot, SoundName> = {
dm: "unison",
mention: "ping",
thread_reply: "doop",
needs_action: "doodone",
job_accepted: "boo",
job_progress: "dng",
job_result: "unison",
job_error: "oh-no",
};
export type SlotSounds = Record<SoundSlot, SoundName>;
export const DEFAULT_SLOT_SOUNDS: SlotSounds = {
dm: "flutter",
mention: "flutter",
thread_reply: "flutter",
needs_action: "flutter",
job_accepted: "flutter",
job_progress: "flutter",
job_result: "flutter",
job_error: "flutter",
};
/** Per-event alerts (notification + sound) on/off. */
export const DEFAULT_SLOT_ALERTS_ENABLED: Record<SoundSlot, boolean> = {
dm: true,
mention: true,
thread_reply: true,
needs_action: true,
job_accepted: true,
job_progress: false,
job_result: true,
job_error: true,
};
export type SoundPreferences = {
sounds: SlotSounds;
};
export function resolveSlotSound(
prefs: SoundPreferences,
slot: SoundSlot,
): SoundName {
return prefs.sounds[slot];
}
export function playNotificationSound(): void {
export function slotForFeedKind(
kind: number,
category: FeedItemCategory,
): SoundSlot {
if (category === "mention") return "mention";
if (kind === KIND_JOB_ACCEPTED) return "job_accepted";
if (kind === KIND_JOB_PROGRESS) return "job_progress";
if (kind === KIND_JOB_RESULT) return "job_result";
if (kind === KIND_JOB_ERROR) return "job_error";
if (kind === KIND_APPROVAL_REQUEST) return "needs_action";
return "needs_action";
}
const cache = new Map<SoundName, HTMLAudioElement>();
function getAudio(name: SoundName): HTMLAudioElement {
let audio = cache.get(name);
if (!audio) {
audio = new Audio(`/sounds/${name}.mp3`);
cache.set(name, audio);
}
return audio;
}
export function playNotificationSound(
name: SoundName,
): HTMLAudioElement | null {
try {
const audio = getNotificationAudio();
const audio = getAudio(name);
audio.currentTime = 0;
audio.play().catch(() => {
// Best-effort — user may not have interacted with the page yet.
});
return audio;
} catch {
// Best-effort only.
return null;
}
}

View File

@@ -18,7 +18,11 @@ import {
requestDesktopNotificationAccess,
sendDesktopNotification,
} from "./lib/desktop";
import { playNotificationSound } from "./lib/sound";
import {
playNotificationSound,
resolveSlotSound,
slotForFeedKind,
} from "./lib/sound";
import type { NotificationSettings } from "./hooks";
const HOME_FEED_SEEN_STORAGE_KEY = "sprout-home-feed-seen.v1";
@@ -117,8 +121,9 @@ export function useFeedDesktopNotifications(
title: notificationTitle(item, senderName),
});
if (didSend && settings.soundEnabled) {
playNotificationSound();
if (didSend) {
const slot = slotForFeedKind(item.kind, item.category);
playNotificationSound(resolveSlotSound(settings, slot));
}
},
);
@@ -149,8 +154,8 @@ export function useFeedDesktopNotifications(
const nextSeenItemIds = new Set(seenItemIdsRef.current);
const newItems = settings.desktopEnabled
? eligibleFeedNotificationItems(feed, {
mentions: settings.mentions,
needsAction: settings.needsAction,
mentions: settings.slotAlertsEnabled.mention,
needsAction: settings.slotAlertsEnabled.needs_action,
})
.filter((item) => !nextSeenItemIds.has(item.id))
.filter(
@@ -204,7 +209,7 @@ export function useFeedDesktopNotifications(
normalizedPubkey,
profiles,
settings.desktopEnabled,
settings.mentions,
settings.needsAction,
settings.slotAlertsEnabled.mention,
settings.slotAlertsEnabled.needs_action,
]);
}

View File

@@ -1,9 +1,24 @@
import { useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import type {
DesktopNotificationPermissionState,
NotificationSettings,
} from "@/features/notifications/hooks";
import {
COMING_SOON_SLOTS,
RECOMMENDED_SOUND_BY_SLOT,
SLOT_DESCRIPTIONS,
SLOT_LABELS,
SOUND_SLOTS,
type SoundName,
type SoundSlot,
} from "@/features/notifications/lib/sound";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Switch } from "@/shared/ui/switch";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import { SoundPicker } from "./SoundPicker";
export function NotificationSettingsCard({
isUpdatingDesktopNotifications,
@@ -11,24 +26,37 @@ export function NotificationSettingsCard({
notificationPermission,
notificationSettings,
onSetDesktopNotificationsEnabled,
onSetAllSlotAlertsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetSoundEnabled,
onSetSlotAlertsEnabled,
onSetNotifyWhileViewing,
onSetSoundForSlot,
}: {
isUpdatingDesktopNotifications: boolean;
notificationErrorMessage: string | null;
notificationPermission: DesktopNotificationPermissionState;
notificationSettings: NotificationSettings;
onSetDesktopNotificationsEnabled: (enabled: boolean) => Promise<boolean>;
onSetAllSlotAlertsEnabled: (enabled: boolean) => void;
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
onSetSoundEnabled: (enabled: boolean) => void;
onSetSlotAlertsEnabled: (slot: SoundSlot, enabled: boolean) => void;
onSetNotifyWhileViewing: (enabled: boolean) => void;
onSetSoundForSlot: (slot: SoundSlot, name: SoundName) => void;
}) {
const permissionBlocked =
notificationPermission === "denied" ||
notificationPermission === "unsupported";
// The parent Sound switch derives from its children: on when any live
// event row is on, and toggling it bulk-sets every live row.
const anyAlertsOn = SOUND_SLOTS.some(
(slot) =>
!COMING_SOON_SLOTS.has(slot) &&
notificationSettings.slotAlertsEnabled[slot],
);
const [showComingSoon, setShowComingSoon] = useState(false);
const visibleSlots = SOUND_SLOTS.filter(
(slot) => showComingSoon || !COMING_SOON_SLOTS.has(slot),
);
return (
<section className="min-w-0" data-testid="settings-notifications">
@@ -49,122 +77,197 @@ export function NotificationSettingsCard({
: "Off"}
</span>
<SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="desktop-alerts-switch"
>
{isUpdatingDesktopNotifications
? "Requesting..."
: "Desktop alerts"}
</label>
<p className="text-sm font-normal text-muted-foreground">
{notificationSettings.desktopEnabled
? "Native desktop alerts are enabled for the categories you have armed below."
: "Request OS permission and surface new mentions or needs-action items outside the app."}
</p>
</div>
<Switch
checked={notificationSettings.desktopEnabled}
data-testid="notifications-desktop-toggle"
disabled={isUpdatingDesktopNotifications}
id="desktop-alerts-switch"
onCheckedChange={(checked) => {
void onSetDesktopNotificationsEnabled(checked);
}}
/>
</SettingsOptionRow>
<div className="flex flex-col gap-4">
<SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="desktop-alerts-switch"
>
{isUpdatingDesktopNotifications
? "Requesting..."
: "Desktop alerts"}
</label>
<p className="text-sm font-normal text-muted-foreground">
{notificationSettings.desktopEnabled
? "Native desktop alerts are enabled for the categories you have armed below."
: "Request OS permission and surface new mentions or needs-action items outside the app."}
</p>
</div>
<Switch
checked={notificationSettings.desktopEnabled}
data-testid="notifications-desktop-toggle"
disabled={isUpdatingDesktopNotifications}
id="desktop-alerts-switch"
onCheckedChange={(checked) => {
void onSetDesktopNotificationsEnabled(checked);
}}
/>
</SettingsOptionRow>
<SettingsOptionRow>
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="notification-sound-switch"
>
Notification sound
</label>
<p className="text-sm font-normal text-muted-foreground">
Play a sound when a desktop notification fires.
</p>
</div>
<Switch
checked={
notificationSettings.desktopEnabled &&
notificationSettings.soundEnabled
}
data-testid="notifications-sound-toggle"
disabled={!notificationSettings.desktopEnabled}
id="notification-sound-switch"
onCheckedChange={(checked) => {
onSetSoundEnabled(checked);
}}
/>
</SettingsOptionRow>
<SettingsOptionRow>
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="notify-while-viewing-switch"
>
Notify while viewing
</label>
<p className="text-sm font-normal text-muted-foreground">
Also alert for direct messages in the conversation you have
open.
</p>
</div>
<Switch
checked={
notificationSettings.desktopEnabled &&
notificationSettings.notifyWhileViewing
}
data-testid="notifications-notify-while-viewing-toggle"
disabled={!notificationSettings.desktopEnabled}
id="notify-while-viewing-switch"
onCheckedChange={(checked) => {
onSetNotifyWhileViewing(checked);
}}
/>
</SettingsOptionRow>
</SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<label className="text-sm font-medium" htmlFor="home-badge-switch">
Home badge
</label>
<p className="text-sm font-normal text-muted-foreground">
Show a Home badge for mentions and needs-action items in the
sidebar.
</p>
</div>
<Switch
checked={notificationSettings.homeBadgeEnabled}
data-testid="notifications-home-badge-toggle"
id="home-badge-switch"
onCheckedChange={(checked) => {
onSetHomeBadgeEnabled(checked);
}}
/>
</SettingsOptionRow>
{notificationSettings.desktopEnabled ? (
<>
<SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="notification-sound-switch"
>
Sound
</label>
<p className="text-sm font-normal text-muted-foreground">
Alert with a sound for the events below.
</p>
</div>
<Switch
checked={anyAlertsOn}
data-testid="notifications-sound-toggle"
id="notification-sound-switch"
onCheckedChange={(checked) => {
onSetAllSlotAlertsEnabled(checked);
}}
/>
</SettingsOptionRow>
</SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<label className="text-sm font-medium" htmlFor="mentions-switch">
@Mentions
</label>
<p className="text-sm font-normal text-muted-foreground">
Alert when someone tags your pubkey in a channel you can access.
</p>
</div>
<Switch
checked={notificationSettings.mentions}
data-testid="notifications-mentions-toggle"
id="mentions-switch"
onCheckedChange={(checked) => {
onSetMentionNotificationsEnabled(checked);
}}
/>
</SettingsOptionRow>
{anyAlertsOn ? (
<>
<SettingsOptionGroup>
{visibleSlots.map((slot) => {
const comingSoon = COMING_SOON_SLOTS.has(slot);
const alertsOn =
notificationSettings.slotAlertsEnabled[slot];
return (
<SettingsOptionRow
aria-disabled={comingSoon || undefined}
className={cn(
comingSoon && "cursor-not-allowed opacity-40",
)}
key={slot}
>
<div className="min-w-0">
<span className="flex items-center gap-2 text-sm font-medium">
{SLOT_LABELS[slot]}
{comingSoon ? (
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[10px] font-normal uppercase tracking-wide text-muted-foreground">
Coming soon
</span>
) : null}
</span>
<p className="text-sm font-normal text-muted-foreground">
{SLOT_DESCRIPTIONS[slot]}
</p>
</div>
<span className="flex items-center gap-3">
<span
className={cn(
"transition-opacity duration-200",
!alertsOn && "pointer-events-none opacity-40",
)}
>
<SoundPicker
disabled={comingSoon || !alertsOn}
onChange={(next) => onSetSoundForSlot(slot, next)}
recommended={RECOMMENDED_SOUND_BY_SLOT[slot]}
value={notificationSettings.sounds[slot]}
/>
</span>
<Switch
checked={alertsOn && !comingSoon}
data-testid={`notifications-alerts-enabled-${slot}`}
disabled={comingSoon}
id={`alerts-enabled-${slot}-switch`}
onCheckedChange={(checked) => {
onSetSlotAlertsEnabled(slot, checked);
}}
/>
</span>
</SettingsOptionRow>
);
})}
</SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="needs-action-switch"
>
Needs action
</label>
<p className="text-sm font-normal text-muted-foreground">
Alert for reminders and workflow approvals that are waiting on
you.
</p>
</div>
<Switch
checked={notificationSettings.needsAction}
data-testid="notifications-needs-action-toggle"
id="needs-action-switch"
onCheckedChange={(checked) => {
onSetNeedsActionNotificationsEnabled(checked);
}}
/>
</SettingsOptionRow>
</SettingsOptionGroup>
<div className="flex justify-center">
<Button
data-testid="notifications-toggle-coming-soon"
onClick={() => setShowComingSoon((current) => !current)}
size="sm"
type="button"
variant="secondary"
>
{showComingSoon ? (
<>
<ChevronUp className="h-3.5 w-3.5" />
Show less
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" />
View all
</>
)}
</Button>
</div>
</>
) : null}
</>
) : null}
<SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="home-badge-switch"
>
Home badge
</label>
<p className="text-sm font-normal text-muted-foreground">
Show a Home badge for mentions and needs-action items in the
sidebar.
</p>
</div>
<Switch
checked={notificationSettings.homeBadgeEnabled}
data-testid="notifications-home-badge-toggle"
id="home-badge-switch"
onCheckedChange={(checked) => {
onSetHomeBadgeEnabled(checked);
}}
/>
</SettingsOptionRow>
</SettingsOptionGroup>
</div>
{permissionBlocked && (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">

View File

@@ -8,10 +8,7 @@ export function SettingsOptionGroup({
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border/70 bg-background/70 shadow-xs divide-y divide-border/55",
className,
)}
className={cn("overflow-hidden rounded-2xl bg-muted/20", className)}
{...props}
/>
);

View File

@@ -23,6 +23,7 @@ import type {
DesktopNotificationPermissionState,
NotificationSettings,
} from "@/features/notifications/hooks";
import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound";
import { RelayMembersSettingsCard } from "@/features/relay-members/ui/RelayMembersSettingsCard";
import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiSettingsCard";
import { cn } from "@/shared/lib/cn";
@@ -77,9 +78,10 @@ export type SettingsPanelProps = {
notificationSettings: NotificationSettings;
onSetDesktopNotificationsEnabled: (enabled: boolean) => Promise<boolean>;
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
onSetSoundEnabled: (enabled: boolean) => void;
onSetSlotAlertsEnabled: (slot: SoundSlot, enabled: boolean) => void;
onSetNotifyWhileViewing: (enabled: boolean) => void;
onSetAllSlotAlertsEnabled: (enabled: boolean) => void;
onSetSoundForSlot: (slot: SoundSlot, name: SoundName) => void;
};
export const settingsSections: SettingsSectionDescriptor[] = [
@@ -301,13 +303,10 @@ export function renderSettingsSection(
props.onSetDesktopNotificationsEnabled
}
onSetHomeBadgeEnabled={props.onSetHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
props.onSetMentionNotificationsEnabled
}
onSetNeedsActionNotificationsEnabled={
props.onSetNeedsActionNotificationsEnabled
}
onSetSoundEnabled={props.onSetSoundEnabled}
onSetSlotAlertsEnabled={props.onSetSlotAlertsEnabled}
onSetNotifyWhileViewing={props.onSetNotifyWhileViewing}
onSetAllSlotAlertsEnabled={props.onSetAllSlotAlertsEnabled}
onSetSoundForSlot={props.onSetSoundForSlot}
/>
);
case "experimental":

View File

@@ -1,5 +1,6 @@
import type { DesktopNotificationPermissionState } from "@/features/notifications/hooks";
import type { NotificationSettings } from "@/features/notifications/hooks";
import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound";
import type { SettingsSection } from "@/features/settings/ui/SettingsPanels";
import { SettingsView } from "@/features/settings/ui/SettingsView";
@@ -14,9 +15,10 @@ type SettingsScreenProps = {
onSectionChange: (section: SettingsSection) => void;
onSetDesktopNotificationsEnabled: (enabled: boolean) => Promise<boolean>;
onSetHomeBadgeEnabled: (enabled: boolean) => void;
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
onSetSoundEnabled: (enabled: boolean) => void;
onSetSlotAlertsEnabled: (slot: SoundSlot, enabled: boolean) => void;
onSetNotifyWhileViewing: (enabled: boolean) => void;
onSetAllSlotAlertsEnabled: (enabled: boolean) => void;
onSetSoundForSlot: (slot: SoundSlot, name: SoundName) => void;
section: SettingsSection;
};
@@ -31,9 +33,10 @@ export function SettingsScreen({
onSectionChange,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetSoundEnabled,
onSetSlotAlertsEnabled,
onSetNotifyWhileViewing,
onSetAllSlotAlertsEnabled,
onSetSoundForSlot,
section,
}: SettingsScreenProps) {
return (
@@ -48,11 +51,10 @@ export function SettingsScreen({
onSectionChange={onSectionChange}
onSetDesktopNotificationsEnabled={onSetDesktopNotificationsEnabled}
onSetHomeBadgeEnabled={onSetHomeBadgeEnabled}
onSetMentionNotificationsEnabled={onSetMentionNotificationsEnabled}
onSetNeedsActionNotificationsEnabled={
onSetNeedsActionNotificationsEnabled
}
onSetSoundEnabled={onSetSoundEnabled}
onSetSlotAlertsEnabled={onSetSlotAlertsEnabled}
onSetNotifyWhileViewing={onSetNotifyWhileViewing}
onSetAllSlotAlertsEnabled={onSetAllSlotAlertsEnabled}
onSetSoundForSlot={onSetSoundForSlot}
section={section}
/>
);

View File

@@ -119,9 +119,10 @@ export function SettingsView({
onSectionChange,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetSoundEnabled,
onSetSlotAlertsEnabled,
onSetNotifyWhileViewing,
onSetAllSlotAlertsEnabled,
onSetSoundForSlot,
section,
}: SettingsViewProps) {
const { isMobile, open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar();
@@ -289,9 +290,10 @@ export function SettingsView({
notificationSettings,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetSoundEnabled,
onSetSlotAlertsEnabled,
onSetNotifyWhileViewing,
onSetAllSlotAlertsEnabled,
onSetSoundForSlot,
})}
</div>
</section>

View File

@@ -0,0 +1,145 @@
import { useRef, useState } from "react";
import { ChevronDown, Pause, Play } from "lucide-react";
import {
playNotificationSound,
SOUND_NAMES,
type SoundName,
} from "@/features/notifications/lib/sound";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
function sortedSounds(recommended: SoundName): SoundName[] {
const others = SOUND_NAMES.filter((n) => n !== recommended)
.slice()
.sort();
return [recommended, ...others];
}
// The waveform SVGs use fill="currentColor", which an <img> can't inherit,
// so render them as a mask over the current text color instead.
function Waveform({
name,
className,
}: {
name: SoundName;
className?: string;
}) {
const maskImage = `url(/sounds/${name}.svg)`;
return (
<span
aria-hidden="true"
className={cn("inline-block shrink-0 bg-current", className)}
style={{
maskImage,
maskPosition: "center",
maskRepeat: "no-repeat",
maskSize: "contain",
WebkitMaskImage: maskImage,
WebkitMaskPosition: "center",
WebkitMaskRepeat: "no-repeat",
WebkitMaskSize: "contain",
}}
/>
);
}
export function SoundPicker({
recommended,
value,
disabled,
onChange,
}: {
recommended: SoundName;
value: SoundName;
disabled?: boolean;
onChange: (next: SoundName) => void;
}) {
const items = sortedSounds(recommended);
const [isPlaying, setIsPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
function togglePreview() {
if (isPlaying) {
audioRef.current?.pause();
setIsPlaying(false);
return;
}
const audio = playNotificationSound(value);
if (!audio) return;
audioRef.current = audio;
setIsPlaying(true);
const stop = () => setIsPlaying(false);
audio.addEventListener("ended", stop, { once: true });
audio.addEventListener("pause", stop, { once: true });
}
return (
<span className="inline-flex items-center gap-1.5">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-7 min-w-40 justify-between gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
disabled={disabled}
size="sm"
type="button"
variant="ghost"
>
<span className="truncate">{value}</span>
<span className="flex items-center gap-1.5">
<Waveform className="h-6 w-15 opacity-70" name={value} />
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-80 min-w-72 overflow-y-auto"
>
<DropdownMenuRadioGroup
onValueChange={(next) => onChange(next as SoundName)}
value={value}
>
{items.map((name) => (
<DropdownMenuRadioItem key={name} value={name}>
<span className="flex w-full items-center justify-between gap-3">
<span>{name}</span>
<span className="flex items-center gap-2">
{name === recommended ? (
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
rec.
</span>
) : null}
<Waveform className="h-6 w-15 opacity-70" name={name} />
</span>
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button
aria-label={isPlaying ? `Pause ${value}` : `Preview ${value}`}
className="h-7 w-7 rounded-full border border-border/50 bg-muted/45 p-0 text-foreground shadow-none hover:bg-muted/70"
disabled={disabled}
onClick={togglePreview}
size="sm"
type="button"
variant="ghost"
>
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
</span>
);
}