| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | | use std::collections::{BTreeMap, HashMap}; |
| 13 | | use std::sync::{Arc, Mutex}; |
| 14 | | use tokio::sync::broadcast; |
| 15 | |
|
| 16 | |
|
| 17 | | #[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] |
| 18 | | pub struct RoomKey { |
| 19 | | pub repo_id: i64, |
| 20 | | pub bookmark: String, |
| 21 | | pub path: String, |
| 22 | | } |
| 23 | |
|
| 24 | |
|
| 25 | |
|
| 26 | | #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 27 | | pub struct Choice { |
| 28 | | pub choice: String, |
| 29 | | pub text: String, |
| 30 | | pub by: String, |
| 31 | | } |
| 32 | |
|
| 33 | |
|
| 34 | | #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] |
| 35 | | #[serde(tag = "kind", rename_all = "lowercase")] |
| 36 | | pub enum LiveEvent { |
| 37 | | |
| 38 | | Snapshot { present: Vec<String>, choices: BTreeMap<String, Choice>, keep: Option<Choice> }, |
| 39 | | |
| 40 | | Presence { present: Vec<String> }, |
| 41 | | |
| 42 | | Choice { region: String, choice: Choice, client: String }, |
| 43 | | |
| 44 | | Landed { url: String, by: String }, |
| 45 | | } |
| 46 | |
|
| 47 | |
|
| 48 | |
|
| 49 | | #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] |
| 50 | | #[serde(tag = "kind", rename_all = "lowercase")] |
| 51 | | pub enum RoomEvent { |
| 52 | | |
| 53 | | Choice { region: String, choice: Choice, client: String }, |
| 54 | | |
| 55 | | Landed { url: String, by: String }, |
| 56 | | } |
| 57 | |
|
| 58 | | struct Room { |
| 59 | | tx: broadcast::Sender<LiveEvent>, |
| 60 | | |
| 61 | | present: HashMap<String, String>, |
| 62 | | choices: BTreeMap<String, Choice>, |
| 63 | | } |
| 64 | |
|
| 65 | | impl Room { |
| 66 | 4× | fn names(&self) -> Vec<String> { |
| 67 | 4× | let mut v: Vec<String> = self.present.values().cloned().collect(); |
| 68 | 4× | v.sort(); |
| 69 | 4× | v.dedup(); |
| 70 | 4× | v |
| 71 | 4× | } |
| 72 | | } |
| 73 | |
|
| 74 | | #[derive(Default)] |
| 75 | | pub struct Rooms { |
| 76 | | rooms: Mutex<HashMap<RoomKey, Room>>, |
| 77 | | } |
| 78 | |
|
| 79 | |
|
| 80 | |
|
| 81 | | pub struct Seat { |
| 82 | | rooms: Arc<Rooms>, |
| 83 | | key: RoomKey, |
| 84 | | client: String, |
| 85 | | } |
| 86 | |
|
| 87 | | impl Drop for Seat { |
| 88 | 3× | fn drop(&mut self) { |
| 89 | 3× | self.rooms.leave(&self.key, &self.client); |
| 90 | 3× | } |
| 91 | | } |
| 92 | |
|
| 93 | | impl Rooms { |
| 94 | | |
| 95 | | |
| 96 | 3× | pub fn join(&self, key: RoomKey, client: &str, user: &str) -> (broadcast::Receiver<LiveEvent>, LiveEvent) { |
| 97 | 3× | let mut rooms = self.rooms.lock().unwrap(); |
| 98 | 3× | let room = |
| 99 | 3× | rooms.entry(key).or_insert_with(|| Room { tx: broadcast::channel(256).0, present: HashMap::new(), choices: BTreeMap::new() }); |
| 100 | 3× | room.present.insert(client.to_string(), user.to_string()); |
| 101 | 3× | let rx = room.tx.subscribe(); |
| 102 | 3× | let present = room.names(); |
| 103 | 3× | let _ = room.tx.send(LiveEvent::Presence { present: present.clone() }); |
| 104 | 3× | let mut choices = room.choices.clone(); |
| 105 | 3× | let keep = choices.remove("keep"); |
| 106 | 3× | (rx, LiveEvent::Snapshot { present, choices, keep }) |
| 107 | 3× | } |
| 108 | |
|
| 109 | | |
| 110 | 3× | pub fn seat(self: &Arc<Self>, key: RoomKey, client: &str) -> Seat { |
| 111 | 3× | Seat { rooms: Arc::clone(self), key, client: client.to_string() } |
| 112 | 3× | } |
| 113 | |
|
| 114 | 3× | fn leave(&self, key: &RoomKey, client: &str) { |
| 115 | 3× | let mut rooms = self.rooms.lock().unwrap(); |
| 116 | 3× | let Some(room) = rooms.get_mut(key) else { return }; |
| 117 | 3× | room.present.remove(client); |
| 118 | 3× | if room.present.is_empty() { |
| 119 | 2× | |
| 120 | 2× | rooms.remove(key); |
| 121 | 2× | } else { |
| 122 | 1× | let _ = room.tx.send(LiveEvent::Presence { present: room.names() }); |
| 123 | 1× | } |
| 124 | 3× | } |
| 125 | |
|
| 126 | | |
| 127 | 4× | pub fn choose(&self, key: &RoomKey, client: &str, region: &str, choice: Choice) -> bool { |
| 128 | 4× | self.apply(key, &RoomEvent::Choice { region: region.to_string(), choice, client: client.to_string() }) |
| 129 | 4× | } |
| 130 | |
|
| 131 | | |
| 132 | 1× | pub fn landed(&self, key: &RoomKey, url: &str, by: &str) { |
| 133 | 1× | self.apply(key, &RoomEvent::Landed { url: url.to_string(), by: by.to_string() }); |
| 134 | 1× | } |
| 135 | |
|
| 136 | | |
| 137 | | |
| 138 | | |
| 139 | 9× | pub fn apply(&self, key: &RoomKey, event: &RoomEvent) -> bool { |
| 140 | 9× | let mut rooms = self.rooms.lock().unwrap(); |
| 141 | 9× | let Some(room) = rooms.get_mut(key) else { return false }; |
| 142 | 6× | let told = match event { |
| 143 | 4× | RoomEvent::Choice { region, choice, client } => { |
| 144 | 4× | room.choices.insert(region.clone(), choice.clone()); |
| 145 | 4× | LiveEvent::Choice { region: region.clone(), choice: choice.clone(), client: client.clone() } |
| 146 | | } |
| 147 | 2× | RoomEvent::Landed { url, by } => LiveEvent::Landed { url: url.clone(), by: by.clone() }, |
| 148 | | }; |
| 149 | 6× | let _ = room.tx.send(told); |
| 150 | 6× | true |
| 151 | 9× | } |
| 152 | |
|
| 153 | | |
| 154 | 1× | pub fn open(&self) -> usize { |
| 155 | 1× | self.rooms.lock().unwrap().len() |
| 156 | 1× | } |
| 157 | | } |
| 158 | |
|
| 159 | | #[cfg(test)] |
| 160 | | mod tests { |
| 161 | | use super::*; |
| 162 | |
|
| 163 | 9× | fn key() -> RoomKey { |
| 164 | 9× | RoomKey { repo_id: 1, bookmark: "main".into(), path: "a.txt".into() } |
| 165 | 9× | } |
| 166 | |
|
| 167 | 5× | fn pick(choice: &str, by: &str) -> Choice { |
| 168 | 5× | Choice { choice: choice.into(), text: String::new(), by: by.into() } |
| 169 | 5× | } |
| 170 | |
|
| 171 | | #[test] |
| 172 | 1× | fn viewers_see_each_other_and_each_others_choices() { |
| 173 | 1× | let rooms = Arc::new(Rooms::default()); |
| 174 | 1× | let (mut a_rx, a_snap) = rooms.join(key(), "tab-a", "andy"); |
| 175 | 1× | assert_eq!(a_snap, LiveEvent::Snapshot { present: vec!["andy".into()], choices: BTreeMap::new(), keep: None }); |
| 176 | 1× | let seat_a = rooms.seat(key(), "tab-a"); |
| 177 | 1× | assert!(matches!(a_rx.try_recv(), Ok(LiveEvent::Presence { .. })), "a's own arrival"); |
| 178 | |
|
| 179 | 1× | assert!(rooms.choose(&key(), "tab-a", "1", pick("side2", "andy"))); |
| 180 | 1× | assert!(matches!(a_rx.try_recv(), Ok(LiveEvent::Choice { region, .. }) if region == "1")); |
| 181 | |
|
| 182 | | |
| 183 | 1× | let (mut b_rx, b_snap) = rooms.join(key(), "tab-b", "bea"); |
| 184 | 1× | let seat_b = rooms.seat(key(), "tab-b"); |
| 185 | 1× | let LiveEvent::Snapshot { present, choices, .. } = b_snap else { panic!() }; |
| 186 | 1× | assert_eq!(present, vec!["andy", "bea"]); |
| 187 | 1× | assert_eq!(choices["1"].choice, "side2"); |
| 188 | 1× | assert_eq!(a_rx.try_recv().unwrap(), LiveEvent::Presence { present: vec!["andy".into(), "bea".into()] }); |
| 189 | |
|
| 190 | | |
| 191 | 1× | rooms.choose(&key(), "tab-b", "1", pick("all", "bea")); |
| 192 | 1× | rooms.choose(&key(), "tab-b", "keep", pick("1", "bea")); |
| 193 | 1× | let _ = b_rx.try_recv(); |
| 194 | 1× | drop(seat_b); |
| 195 | 1× | assert_eq!(a_rx.try_recv().unwrap(), LiveEvent::Choice { region: "1".into(), choice: pick("all", "bea"), client: "tab-b".into() }); |
| 196 | 1× | let _ = a_rx.try_recv(); |
| 197 | 1× | assert_eq!(a_rx.try_recv().unwrap(), LiveEvent::Presence { present: vec!["andy".into()] }, "bea left"); |
| 198 | |
|
| 199 | 1× | rooms.landed(&key(), "/andy/demo/change/abc", "andy"); |
| 200 | 1× | assert!(matches!(a_rx.try_recv(), Ok(LiveEvent::Landed { .. }))); |
| 201 | |
|
| 202 | | |
| 203 | 1× | drop(seat_a); |
| 204 | 1× | assert_eq!(rooms.open(), 0); |
| 205 | 1× | assert!(!rooms.choose(&key(), "tab-a", "1", pick("none", "andy"))); |
| 206 | 1× | } |
| 207 | | } |