feat(desktop): per-event notification sounds and alert controls (#968)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BIN
desktop/public/sounds/bong.mp3
Normal file
1
desktop/public/sounds/bong.svg
Normal 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 |
BIN
desktop/public/sounds/boo.mp3
Normal file
1
desktop/public/sounds/boo.svg
Normal 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 |
BIN
desktop/public/sounds/dng.mp3
Normal file
1
desktop/public/sounds/dng.svg
Normal 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 |
BIN
desktop/public/sounds/doo.mp3
Normal file
1
desktop/public/sounds/doo.svg
Normal 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 |
BIN
desktop/public/sounds/doodone.mp3
Normal file
1
desktop/public/sounds/doodone.svg
Normal 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 |
BIN
desktop/public/sounds/doong.mp3
Normal file
1
desktop/public/sounds/doong.svg
Normal 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 |
BIN
desktop/public/sounds/doop.mp3
Normal file
1
desktop/public/sounds/doop.svg
Normal 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 |
BIN
desktop/public/sounds/flirl.mp3
Normal file
1
desktop/public/sounds/flirl.svg
Normal 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 |
1
desktop/public/sounds/flutter.svg
Normal 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 |
BIN
desktop/public/sounds/oh-no.mp3
Normal file
1
desktop/public/sounds/oh-no.svg
Normal 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 |
BIN
desktop/public/sounds/ping.mp3
Normal file
1
desktop/public/sounds/ping.svg
Normal 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 |
BIN
desktop/public/sounds/unison.mp3
Normal file
1
desktop/public/sounds/unison.svg
Normal 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 |
150
desktop/scripts/generate-sound-waveforms.mjs
Normal 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)})`,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
145
desktop/src/features/settings/ui/SoundPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||