Skip to content
210 linesCodeBlameRaw
1//! Sound settings: what a person hears when chat moves, kept with their
2//! account so a phone, a laptop and the desktop app all sound the same.
3//!
4//! One row per person (migration 0046); a person with no row has the
5//! defaults. Only the person reads or changes their own. Which sound plays
6//! when is the web app's rule (apps/web/app/lib/chat-sounds.ts); this only
7//! keeps the choices, checking each against the sets and cues g1t has.
8//! Do not disturb is the person's presence (services/notify), not a row here.
9
10use std::collections::BTreeMap;
11
12use g1t_contracts::identity::*;
13use g1t_contracts::time::SQL_NOW;
14use g1t_contracts::{FailureCode, Outcome};
15use serde::Deserialize;
16use worker::Result;
17
18use crate::Identity;
19use crate::security::is_person;
20
21/// `current` with `change` over it, each field checked. Refused whole when
22/// a set or a cue is not one g1t has, or the volume is out of range.
23pub fn apply_change(current: &SoundSettings, change: &SoundSettingsChange) -> std::result::Result<SoundSettings, String> {
24 let mut next = current.clone();
25 if let Some(on) = change.sounds_enabled {
26 next.sounds_enabled = on;
27 }
28 if let Some(set) = &change.sound_set {
29 let set = set.trim();
30 if !SOUND_SETS.contains(&set) {
31 return Err("That is not a sound set g1t has.".to_owned());
32 }
33 next.sound_set = set.to_owned();
34 }
35 if let Some(volume) = change.sound_volume {
36 if !(0..=100).contains(&volume) {
37 return Err("Volume is from 0 to 100.".to_owned());
38 }
39 next.sound_volume = volume as u8;
40 }
41 if let Some(cues) = &change.sound_cues {
42 for (cue, on) in cues {
43 if RETIRED_SOUND_CUES.contains(&cue.as_str()) {
44 continue;
45 }
46 if !SOUND_CUES.contains(&cue.as_str()) {
47 return Err("That is not a sound g1t plays.".to_owned());
48 }
49 next.sound_cues.insert(cue.clone(), *on);
50 }
51 }
52 if let Some(on) = change.desktop_toasts {
53 next.desktop_toasts = on;
54 }
55 Ok(next)
56}
57
58#[derive(Deserialize)]
59struct Row {
60 sounds_enabled: i64,
61 sound_set: String,
62 sound_volume: i64,
63 sound_cues: String,
64 desktop_toasts: i64,
65}
66
67/// A row as settings: a field that cannot be read falls back to its
68/// default rather than failing the page, and a cue that no longer exists
69/// is dropped.
70fn settings_from(row: Row) -> SoundSettings {
71 let defaults = SoundSettings::default();
72 let mut cues = defaults.sound_cues.clone();
73 let saved: BTreeMap<String, bool> = serde_json::from_str(&row.sound_cues).unwrap_or_default();
74 for (cue, on) in saved {
75 if SOUND_CUES.contains(&cue.as_str()) {
76 cues.insert(cue, on);
77 }
78 }
79 SoundSettings {
80 sounds_enabled: row.sounds_enabled != 0,
81 sound_set: if SOUND_SETS.contains(&row.sound_set.as_str()) { row.sound_set } else { defaults.sound_set },
82 sound_volume: u8::try_from(row.sound_volume.clamp(0, 100)).unwrap_or(DEFAULT_SOUND_VOLUME),
83 sound_cues: cues,
84 desktop_toasts: row.desktop_toasts != 0,
85 }
86}
87
88const COLUMNS: &str = "sounds_enabled, sound_set, sound_volume, sound_cues, desktop_toasts";
89
90impl Identity {
91 async fn read_sound_settings(&self, user_id: &str) -> Result<SoundSettings> {
92 let row = self
93 .db
94 .prepare(format!("SELECT {COLUMNS} FROM sound_settings WHERE user_id = ?"))
95 .bind(&[user_id.into()])?
96 .first::<Row>(None)
97 .await?;
98 Ok(row.map(settings_from).unwrap_or_default())
99 }
100
101 pub async fn sound_settings(&self, a: SoundSettingsArgs) -> Result<SoundSettings> {
102 if !is_person(&a.user) {
103 return Ok(SoundSettings::default());
104 }
105 self.read_sound_settings(&a.user.id).await
106 }
107
108 pub async fn set_sound_settings(&self, a: SetSoundSettingsArgs) -> Result<Outcome<SoundSettings>> {
109 if !is_person(&a.user) {
110 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person has sound settings."));
111 }
112 let current = self.read_sound_settings(&a.user.id).await?;
113 let next = match apply_change(&current, &a.change) {
114 Ok(next) => next,
115 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
116 };
117 // Only the cues that differ from their defaults are kept, so a new
118 // default later applies to everyone who never chose.
119 let defaults = SoundSettings::default();
120 let chosen: BTreeMap<&String, &bool> = next.sound_cues.iter().filter(|(cue, on)| defaults.sound_cues.get(*cue) != Some(on)).collect();
121 let cues = serde_json::to_string(&chosen)?;
122 let row = self
123 .db
124 .prepare(format!(
125 "INSERT INTO sound_settings (user_id, sounds_enabled, sound_set, sound_volume, sound_cues, desktop_toasts, updated_at)
126 VALUES (?1, ?2, ?3, ?4, ?5, ?6, {SQL_NOW})
127 ON CONFLICT (user_id) DO UPDATE SET sounds_enabled = excluded.sounds_enabled, sound_set = excluded.sound_set,
128 sound_volume = excluded.sound_volume, sound_cues = excluded.sound_cues, desktop_toasts = excluded.desktop_toasts,
129 updated_at = excluded.updated_at
130 RETURNING {COLUMNS}"
131 ))
132 .bind(&[
133 a.user.id.as_str().into(),
134 i32::from(next.sounds_enabled).into(),
135 next.sound_set.as_str().into(),
136 i32::from(next.sound_volume).into(),
137 cues.as_str().into(),
138 i32::from(next.desktop_toasts).into(),
139 ])?
140 .first::<Row>(None)
141 .await?;
142 Ok(match row {
143 Some(row) => Outcome::Ok(settings_from(row)),
144 None => Outcome::Ok(next),
145 })
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn cues(list: &[(&str, bool)]) -> BTreeMap<String, bool> {
154 list.iter().map(|(cue, on)| ((*cue).to_owned(), *on)).collect()
155 }
156
157 #[test]
158 fn the_defaults_play_everything_but_the_tick_as_you_send() {
159 let d = SoundSettings::default();
160 assert!(d.sounds_enabled);
161 assert_eq!(d.sound_set, "soft");
162 assert_eq!(d.sound_volume, 60);
163 assert_eq!(d.sound_cues.get("message"), Some(&true));
164 assert_eq!(d.sound_cues.get("mention"), Some(&true));
165 assert_eq!(d.sound_cues.get("sent"), Some(&false));
166 assert!(!d.desktop_toasts);
167 }
168
169 #[test]
170 fn a_change_touches_only_what_it_names() {
171 let d = SoundSettings::default();
172 let next = apply_change(&d, &SoundSettingsChange { sound_volume: Some(30), sound_cues: Some(cues(&[("sent", true)])), ..Default::default() }).unwrap();
173 assert_eq!(next.sound_volume, 30);
174 assert_eq!(next.sound_set, "soft");
175 assert_eq!(next.sound_cues.get("sent"), Some(&true));
176 assert_eq!(next.sound_cues.get("message"), Some(&true));
177 let bright = apply_change(&next, &SoundSettingsChange { sound_set: Some("bright".to_owned()), ..Default::default() }).unwrap();
178 assert_eq!(bright.sound_set, "bright");
179 assert_eq!(bright.sound_volume, 30);
180 }
181
182 #[test]
183 fn an_unknown_set_cue_or_volume_is_refused() {
184 let d = SoundSettings::default();
185 assert!(apply_change(&d, &SoundSettingsChange { sound_set: Some("loud".to_owned()), ..Default::default() }).is_err());
186 assert!(apply_change(&d, &SoundSettingsChange { sound_volume: Some(101), ..Default::default() }).is_err());
187 assert!(apply_change(&d, &SoundSettingsChange { sound_volume: Some(-1), ..Default::default() }).is_err());
188 assert!(apply_change(&d, &SoundSettingsChange { sound_cues: Some(cues(&[("call", false)])), ..Default::default() }).is_err());
189 assert!(apply_change(&d, &SoundSettingsChange { sound_cues: Some(cues(&[("klaxon", true)])), ..Default::default() }).is_err());
190 // A retired cue is left out, not refused.
191 let next = apply_change(&d, &SoundSettingsChange { sound_cues: Some(cues(&[("agent_done", false)])), ..Default::default() }).unwrap();
192 assert_eq!(next.sound_cues.get("agent_done"), None);
193 }
194
195 #[test]
196 fn a_row_reads_with_defaults_for_what_it_cannot_say() {
197 let row = Row { sounds_enabled: 0, sound_set: "gone".to_owned(), sound_volume: 400, sound_cues: "not json".to_owned(), desktop_toasts: 1 };
198 let s = settings_from(row);
199 assert!(!s.sounds_enabled);
200 assert_eq!(s.sound_set, "soft");
201 assert_eq!(s.sound_volume, 100);
202 assert_eq!(s.sound_cues, SoundSettings::default().sound_cues);
203 assert!(s.desktop_toasts);
204 let row = Row { sounds_enabled: 1, sound_set: "bright".to_owned(), sound_volume: 20, sound_cues: r#"{"sent":true,"old_cue":true,"agent_done":false}"#.to_owned(), desktop_toasts: 0 };
205 let s = settings_from(row);
206 assert_eq!(s.sound_cues.get("sent"), Some(&true));
207 assert_eq!(s.sound_cues.get("old_cue"), None);
208 assert_eq!(s.sound_cues.get("agent_done"), None);
209 }
210}