Skip to main content

tuwunel_api/client/sync/
profiles.rs

1use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
2
3use futures::{Stream, StreamExt, TryStreamExt, future::ready, stream::once};
4use itertools::{Either, Itertools};
5use ruma::{
6	OwnedRoomId, OwnedUserId, RoomId, UserId,
7	api::client::{
8		filter::{FilterDefinition, LazyLoadOptions},
9		sync::sync_events::v3::{JoinedRoom, Rooms, State, UserUpdate},
10	},
11	events::{AnySyncStateEvent, StateEventType, room::member::MembershipState},
12	profile::ProfileFieldName,
13	serde::Raw,
14};
15use serde::Deserialize;
16use serde_json::Value;
17use tuwunel_core::{
18	Result,
19	utils::{
20		BoolExt, IterStream, TryReadyExt,
21		result::NotFound,
22		stream::{BroadbandExt, TryBroadbandExt},
23	},
24	warn,
25};
26use tuwunel_service::{Services, profile::ProfileChange};
27
28/// The profile fields one user changed or holds.
29///
30/// A set rather than a sequence because the two sources overlap: a user can
31/// reach the same field through the change log and through the membership this
32/// response names, and the field is read back once either way.
33pub(super) type Fields = BTreeSet<ProfileFieldName>;
34
35/// Every field the syncing user is entitled to read back, by user.
36///
37/// The change log is keyed per field, so one user appearing under several rooms
38/// or several counts folds into one entry here and is read back once.
39pub(super) type Changes = BTreeMap<OwnedUserId, Fields>;
40
41/// Candidate subjects with delta fields or a complete requested base.
42///
43/// Discovered deltas are restricted to the request selection and are subsumed
44/// by a base when the response also witnesses that subject.
45#[derive(Default)]
46struct Candidates(BTreeMap<OwnedUserId, Candidate>);
47
48type Delta = (OwnedUserId, ProfileFieldName);
49
50enum Input {
51	Collected(Candidates),
52	Base(OwnedUserId),
53	Delta(Delta),
54}
55
56/// Delta fields, with an empty set selecting the shared requested base.
57///
58/// A discovered delta always starts with one field, so the empty marker cannot
59/// collide with a delta and needs no additional per-subject discriminant.
60struct Candidate(Fields);
61
62/// One field paired with whatever reading it back produced.
63///
64/// Keeping the name beside the result lets each collector distinguish a
65/// confirmed absence from a storage or decoding failure.
66pub(super) type FieldValue = (ProfileFieldName, Result<Option<Value>>);
67
68/// The values one user's entry carries, by field name.
69///
70/// This is the wire shape MSC4429 gives `profile_updates`, a flat map where a
71/// `null` marks a field the profile no longer holds.
72type Updates = BTreeMap<ProfileFieldName, Value>;
73
74/// The MSC4429 `users` block of a legacy sync response.
75///
76/// Empty whenever the client asked for no profile fields, which is the default
77/// and what every client that has not opted in sends.
78type Users = BTreeMap<OwnedUserId, UserUpdate>;
79
80/// Just the membership of a member event's content.
81///
82/// The full content type carries fields this pass never reads, and a membership
83/// it does not know deserializes into the custom arm rather than failing, where
84/// it simply does not match below.
85#[derive(Deserialize)]
86struct MemberContent {
87	membership: MembershipState,
88}
89
90/// Collects the MSC4429 profile updates for a legacy sync response.
91///
92/// The filter selects fields, with an empty default opting out. Changes and
93/// current bases share a user-field set and require current shared membership,
94/// except for self. Read failures abort collection before its position can be
95/// acknowledged.
96#[tracing::instrument(name = "profiles", level = "trace", skip_all)]
97pub(super) async fn collect(
98	services: &Services,
99	sender_user: &UserId,
100	since: Option<u64>,
101	next_batch: u64,
102	filter: &FilterDefinition,
103	rooms: &Rooms,
104) -> Result<Users> {
105	let requested = filter.profile_fields.ids.as_slice();
106
107	if requested.is_empty() {
108		return Ok(Users::new());
109	}
110
111	let changes =
112		changed(services, sender_user, since.unwrap_or(0), next_batch, requested).await?;
113
114	let changes =
115		witnessed(services, sender_user, since.is_none(), rooms, filter, changes).await?;
116
117	let requested: Vec<_> = changes
118		.0
119		.values()
120		.any(is_base)
121		.then(|| {
122			requested
123				.iter()
124				.cloned()
125				.sorted_unstable()
126				.dedup()
127				.collect()
128		})
129		.unwrap_or_default();
130
131	changes
132		.0
133		.into_iter()
134		.stream()
135		.broad_then(|(user_id, fields)| {
136			collect_user(services, sender_user, user_id, fields, &requested)
137		})
138		.ready_try_filter_map(Result::Ok)
139		.ready_try_filter(|(_, update)| carries_a_field(update))
140		.try_collect()
141		.await
142}
143
144/// The fields changed in `(since, next_batch]` that this client may see.
145///
146/// The log is read under the syncing user's own prefix and under each room they
147/// have joined, because every write is recorded under both. Their own changes
148/// are a MUST so that their other devices learn of them, and the rooms are the
149/// whole joined set rather than the rooms this response carries: a member's new
150/// status matters to a client whose room had no events.
151#[tracing::instrument(level = "trace", skip_all)]
152async fn changed(
153	services: &Services,
154	sender_user: &UserId,
155	since: u64,
156	next_batch: u64,
157	requested: &[ProfileFieldName],
158) -> Result<Candidates> {
159	let rooms: Vec<OwnedRoomId> = services
160		.state_cache
161		.rooms_joined_checked(sender_user)
162		.map_ok(ToOwned::to_owned)
163		.try_collect()
164		.await?;
165
166	let peers = room_changes(services, &rooms, since, next_batch, requested);
167
168	services
169		.profile
170		.try_profile_changed(sender_user, since, Some(next_batch))
171		.ready_try_filter(move |(_, field)| was_requested(requested, field))
172		.map_ok(own_change)
173		.chain(peers)
174		.try_collect()
175		.await
176}
177
178#[tracing::instrument(level = "trace", skip_all)]
179fn room_changes<'a>(
180	services: &'a Services,
181	rooms: &'a [OwnedRoomId],
182	since: u64,
183	next_batch: u64,
184	requested: &'a [ProfileFieldName],
185) -> impl Stream<Item = Result<Input>> + Send + 'a {
186	rooms
187		.iter()
188		.map(move |room_id: &OwnedRoomId| {
189			room_changed(services, room_id, since, next_batch, requested)
190		})
191		.stream()
192		.flatten()
193}
194
195#[tracing::instrument(level = "trace", skip_all)]
196fn room_changed<'a>(
197	services: &'a Services,
198	room_id: &'a RoomId,
199	since: u64,
200	next_batch: u64,
201	requested: &'a [ProfileFieldName],
202) -> impl Stream<Item = Result<Input>> + Send + 'a {
203	services
204		.profile
205		.try_room_profile_changed(room_id, since, Some(next_batch))
206		.ready_try_filter(move |(_, field)| was_requested(requested, field))
207		.map_ok(own_change)
208}
209
210fn own_change((user_id, field): ProfileChange<'_>) -> Input {
211	Input::Delta((user_id.to_owned(), field.into()))
212}
213
214impl<T> FromIterator<T> for Candidates
215where
216	Self: Extend<T>,
217{
218	fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
219		let mut candidates = Self::default();
220		candidates.extend(iter);
221
222		candidates
223	}
224}
225
226impl Extend<Input> for Candidates {
227	fn extend<T: IntoIterator<Item = Input>>(&mut self, iter: T) {
228		for input in iter {
229			match input {
230				| Input::Collected(candidates) => merge_candidates(self, candidates),
231				| Input::Delta(change) => insert_delta(self, change),
232				| Input::Base(user_id) => {
233					self.0.insert(user_id, Candidate(Fields::new()));
234				},
235			}
236		}
237	}
238}
239
240fn insert_delta(candidates: &mut Candidates, (user_id, field): Delta) {
241	match candidates.0.entry(user_id) {
242		| Entry::Vacant(entry) => {
243			entry.insert(Candidate([field].into()));
244		},
245		| Entry::Occupied(entry) =>
246			if !is_base(entry.get()) {
247				entry.into_mut().0.insert(field);
248			},
249	}
250}
251
252fn merge_candidates(current: &mut Candidates, incoming: Candidates) {
253	if current.0.is_empty() {
254		current.0 = incoming.0;
255	} else {
256		for subject in incoming.0 {
257			merge_subject(current, subject);
258		}
259	}
260}
261
262fn merge_subject(candidates: &mut Candidates, (user_id, incoming): (OwnedUserId, Candidate)) {
263	match candidates.0.entry(user_id) {
264		| Entry::Occupied(entry) => merge_fields(entry.into_mut(), incoming),
265		| Entry::Vacant(entry) => {
266			entry.insert(incoming);
267		},
268	}
269}
270
271fn merge_fields(current: &mut Candidate, incoming: Candidate) {
272	match incoming {
273		| incoming if is_base(&incoming) => current.0.clear(),
274		| incoming if !is_base(current) => current.0.extend(incoming.0),
275		| _ => {},
276	}
277}
278
279fn is_base(candidate: &Candidate) -> bool { candidate.0.is_empty() }
280
281/// Records that one member changed one field.
282///
283/// Both halves are owned here, at the first point the borrowed change-log item
284/// would otherwise be retained.
285pub(super) fn fold_change(mut changes: Changes, (user_id, field): ProfileChange<'_>) -> Changes {
286	changes
287		.entry(user_id.to_owned())
288		.or_default()
289		.insert(field.into());
290
291	changes
292}
293
294/// Adds current bases for the initial scope and members this response names.
295///
296/// Non-lazy initial bases use current joined membership independently of event
297/// filtering. Lazy and incremental responses also introduce the subjects their
298/// member events name, while self gets an initial base even without rooms.
299#[tracing::instrument(level = "trace", skip_all)]
300async fn witnessed(
301	services: &Services,
302	sender_user: &UserId,
303	initial: bool,
304	rooms: &Rooms,
305	filter: &FilterDefinition,
306	changes: Candidates,
307) -> Result<Candidates> {
308	let own = initial.then_some(sender_user.to_owned());
309	let members = rooms
310		.join
311		.values()
312		.flat_map(joined_room_members)
313		.chain(own)
314		.map(Input::Base);
315
316	let members = once(ready(Ok(Input::Collected(changes)))).chain(members.try_stream());
317
318	if !initial {
319		return members.try_collect().await;
320	}
321
322	let rooms: Vec<OwnedRoomId> = services
323		.state_cache
324		.rooms_joined_checked(sender_user)
325		.map_ok(ToOwned::to_owned)
326		.try_collect()
327		.await?;
328
329	let initial = initial_bases(services, &rooms, filter);
330
331	members.chain(initial).try_collect().await
332}
333
334#[tracing::instrument(level = "trace", skip_all)]
335fn initial_bases<'a>(
336	services: &'a Services,
337	rooms: &'a [OwnedRoomId],
338	filter: &'a FilterDefinition,
339) -> impl Stream<Item = Result<Input>> + Send + 'a {
340	rooms
341		.iter()
342		.try_stream()
343		.broad_and_then(move |room_id: &OwnedRoomId| initial_room(services, room_id, filter))
344		.ready_try_filter_map(Result::Ok)
345		.map_ok(move |room_id: &RoomId| initial_members(services, room_id))
346		.try_flatten()
347		.map_ok(Input::Base)
348}
349
350#[tracing::instrument(level = "trace", skip_all)]
351async fn initial_room<'a>(
352	services: &Services,
353	room_id: &'a RoomId,
354	filter: &FilterDefinition,
355) -> Result<Option<&'a RoomId>> {
356	let lazy = lazy_room(services, room_id, filter)
357		.await?
358		.is_false()
359		.then_some(room_id);
360
361	Ok(lazy)
362}
363
364#[tracing::instrument(level = "trace", skip_all)]
365fn initial_members<'a>(
366	services: &'a Services,
367	room_id: &'a RoomId,
368) -> impl Stream<Item = Result<OwnedUserId>> + Send + 'a {
369	services
370		.state_cache
371		.room_members_checked(room_id)
372		.map_ok(ToOwned::to_owned)
373}
374
375#[tracing::instrument(level = "trace", skip_all)]
376async fn lazy_room(
377	services: &Services,
378	room_id: &RoomId,
379	filter: &FilterDefinition,
380) -> Result<bool> {
381	let options = [&filter.room.state.lazy_load_options, &filter.room.timeline.lazy_load_options];
382
383	if options
384		.into_iter()
385		.all(LazyLoadOptions::is_disabled)
386	{
387		return Ok(false);
388	}
389
390	let encrypted = services
391		.state_accessor
392		.room_state_get(room_id, &StateEventType::RoomEncryption, "")
393		.await
394		.optional()?;
395
396	Ok(encrypted.is_none())
397}
398
399/// The members one joined room's payload names.
400///
401/// Both sections are read, because a legacy `state` request omits an event the
402/// timeline already carries: a member who joined inside the timeline window
403/// appears there and in no other part of the response.
404fn joined_room_members(room: &JoinedRoom) -> impl Iterator<Item = OwnedUserId> {
405	let state = state_events(&room.state)
406		.iter()
407		.filter_map(present_member);
408
409	let timeline = room
410		.timeline
411		.events
412		.iter()
413		.filter_map(present_member);
414
415	state.chain(timeline)
416}
417
418fn state_events(state: &State) -> &[Raw<AnySyncStateEvent>] {
419	match state {
420		| State::Before(events) | State::After(events) | State::AfterUnstable(events) =>
421			events.events.as_slice(),
422	}
423}
424
425/// The subject of a membership event that can introduce a profile base.
426///
427/// Current shared membership is checked before reading any candidate's values.
428/// The state key is owned because a historical user ID may need JSON unescaping.
429fn present_member<T>(event: &Raw<T>) -> Option<OwnedUserId> {
430	event
431		.get_field("type")
432		.ok()
433		.flatten()
434		.filter(|event_type: &StateEventType| event_type.eq(&StateEventType::RoomMember))
435		.and_then(|_| event.get_field("content").ok().flatten())
436		.filter(MemberContent::is_present)
437		.and_then(|_| event.get_field("state_key").ok().flatten())
438}
439
440impl MemberContent {
441	/// Whether the member is in the room, or on their way in.
442	///
443	/// Invited subjects remain candidates when another joined room grants
444	/// current shared visibility.
445	fn is_present(&self) -> bool {
446		matches!(self.membership, MembershipState::Join | MembershipState::Invite)
447	}
448}
449
450/// Whether the client's filter asked for the field.
451///
452/// An empty list never reaches here: MSC4429 defaults it empty, which asks for
453/// no updates at all, and the entry point returns early on that.
454pub(super) fn was_requested(requested: &[ProfileFieldName], field: &str) -> bool {
455	requested
456		.iter()
457		.any(|name| name.as_str().eq(field))
458}
459
460fn carries_a_field(update: &UserUpdate) -> bool {
461	update
462		.profile_updates
463		.as_ref()
464		.is_some_and(|updates| !updates.is_empty())
465}
466
467#[tracing::instrument(level = "trace", skip_all)]
468async fn collect_user(
469	services: &Services,
470	sender_user: &UserId,
471	user_id: OwnedUserId,
472	fields: Candidate,
473	requested: &[ProfileFieldName],
474) -> Result<Option<(OwnedUserId, UserUpdate)>> {
475	if !visible(services, sender_user, &user_id).await? {
476		return Ok(None);
477	}
478
479	let fields = selected_fields(fields, requested);
480	let update = read_update(services, &user_id, fields).await?;
481
482	Ok(Some((user_id, update)))
483}
484
485fn selected_fields(
486	fields: Candidate,
487	requested: &[ProfileFieldName],
488) -> impl Iterator<Item = ProfileFieldName> + '_ {
489	if is_base(&fields) {
490		Either::Left(requested.iter().cloned())
491	} else {
492		Either::Right(fields.0.into_iter())
493	}
494}
495
496#[tracing::instrument(level = "trace", skip_all)]
497pub(super) async fn visible(
498	services: &Services,
499	sender_user: &UserId,
500	user_id: &UserId,
501) -> Result<bool> {
502	if sender_user == user_id {
503		return Ok(true);
504	}
505
506	services
507		.state_cache
508		.rooms_joined_checked(user_id)
509		.map_ok(ToOwned::to_owned)
510		.and_then(async |room_id| {
511			let joined = services
512				.state_cache
513				.get_joined_count(&room_id, sender_user)
514				.await
515				.optional()?;
516
517			Ok(joined.is_some())
518		})
519		.try_any(ready)
520		.await
521}
522
523/// Reads back what the collected fields hold now.
524///
525/// The log records that a field changed and never what it changed to, so the
526/// current value is the one to send. A field the log names but the profile no
527/// longer holds is the removal a client needs to clear its own copy, which the
528/// proposal spells as a `null` value.
529#[tracing::instrument(level = "trace", skip_all)]
530async fn read_update(
531	services: &Services,
532	user_id: &UserId,
533	fields: impl Iterator<Item = ProfileFieldName> + Send,
534) -> Result<UserUpdate> {
535	let profile_updates: Updates = fields
536		.stream()
537		.then(|name| read_field(services, user_id, name))
538		.map(field_update)
539		.try_collect()
540		.await?;
541
542	Ok(UserUpdate::new(profile_updates))
543}
544
545pub(super) async fn read_field(
546	services: &Services,
547	user_id: &UserId,
548	name: ProfileFieldName,
549) -> FieldValue {
550	let value = services
551		.profile
552		.profile_key(user_id, &name)
553		.await
554		.optional()
555		.inspect_err(
556			|error| warn!(%user_id, %name, %error, "Failed to read a changed profile field"),
557		);
558
559	(name, value)
560}
561
562fn field_update((name, value): FieldValue) -> Result<(ProfileFieldName, Value)> {
563	value.map(|value| (name, value.unwrap_or(Value::Null)))
564}
565
566#[cfg(test)]
567mod tests {
568	use std::iter::once;
569
570	use ruma::{
571		api::client::sync::sync_events::v3::{StateEvents, Timeline},
572		user_id,
573	};
574	use serde_json::{Value, json};
575	use tuwunel_core::{Err, Result};
576
577	use super::{
578		Candidates, Input, JoinedRoom, OwnedUserId, ProfileChange, ProfileFieldName, Raw, State,
579		Updates, field_update, is_base, joined_room_members, own_change, selected_fields,
580		was_requested,
581	};
582
583	fn field(name: &str) -> ProfileFieldName { name.into() }
584
585	fn members(room: &JoinedRoom) -> Vec<OwnedUserId> { joined_room_members(room).collect() }
586
587	fn with_delta(mut candidates: Candidates, change: ProfileChange<'_>) -> Candidates {
588		candidates.extend([own_change(change)]);
589
590		candidates
591	}
592
593	fn with_base(mut candidates: Candidates, user_id: OwnedUserId) -> Candidates {
594		candidates.extend([Input::Base(user_id)]);
595
596		candidates
597	}
598
599	#[test]
600	fn candidate_deltas_union_fields_without_duplicates() {
601		let user = user_id!("@alice:example.com");
602		let changes = [(user, "org.z"), (user, "org.a"), (user, "org.z")]
603			.into_iter()
604			.map(own_change)
605			.collect::<Candidates>();
606
607		let fields = changes
608			.0
609			.into_values()
610			.next()
611			.expect("one subject");
612
613		let selected = selected_fields(fields, &[]).collect::<Vec<_>>();
614
615		assert_eq!(selected, [field("org.a"), field("org.z")]);
616	}
617
618	#[test]
619	fn vacant_delta_and_existing_base_stay_distinct() {
620		let user = user_id!("@alice:example.com");
621		let vacant = once((user, "org.z"))
622			.map(own_change)
623			.collect::<Candidates>();
624
625		let existing = once(Input::Base(user.to_owned())).collect::<Candidates>();
626
627		let existing = with_delta(existing, (user, "org.z"));
628
629		assert!(!is_base(vacant.0.values().next().expect("one delta")));
630		assert!(is_base(existing.0.values().next().expect("one base")));
631	}
632
633	#[test]
634	fn full_bases_subsume_deltas_in_either_order() {
635		let user = user_id!("@alice:example.com");
636		let requested = [field("org.a"), field("org.z")];
637
638		let delta_first = once((user, "org.z"))
639			.map(own_change)
640			.collect::<Candidates>();
641
642		let delta_first = with_base(delta_first, user.to_owned());
643		let base_first = once(Input::Base(user.to_owned())).collect::<Candidates>();
644
645		let base_first = with_delta(base_first, (user, "org.z"));
646
647		for changes in [delta_first, base_first] {
648			let fields = changes
649				.0
650				.into_values()
651				.next()
652				.expect("one subject");
653
654			assert!(is_base(&fields), "a base keeps no per-subject field set");
655
656			let selected = selected_fields(fields, &requested).collect::<Vec<_>>();
657
658			assert_eq!(selected, [field("org.a"), field("org.z")]);
659		}
660	}
661
662	#[test]
663	fn repeated_base_subjects_share_one_selection() {
664		let alice = user_id!("@alice:example.com");
665		let bob = user_id!("@bob:example.com");
666		let changes = [bob, alice, bob, alice]
667			.into_iter()
668			.map(ToOwned::to_owned)
669			.map(Input::Base)
670			.collect::<Candidates>();
671
672		assert_eq!(changes.0.len(), 2);
673		assert!(changes.0.values().all(is_base));
674		assert_eq!(changes.0.into_keys().collect::<Vec<_>>(), [alice, bob]);
675	}
676
677	#[test]
678	fn room_candidate_merges_preserve_union_and_base() {
679		let alice = user_id!("@alice:example.com");
680		let bob = user_id!("@bob:example.com");
681
682		for order in [[0, 1, 2], [2, 0, 1], [1, 2, 0]] {
683			let rooms = order.map(|index| match index {
684				| 0 => [(alice, "org.a"), (bob, "org.a")]
685					.into_iter()
686					.map(own_change)
687					.collect::<Candidates>(),
688				| 1 => [(alice, "org.z"), (bob, "org.z")]
689					.into_iter()
690					.map(own_change)
691					.collect(),
692				| _ => once(Input::Base(alice.to_owned())).collect(),
693			});
694
695			let changes: Candidates = rooms.into_iter().map(Input::Collected).collect();
696
697			assert_eq!(changes.0.len(), 2);
698			assert!(is_base(changes.0.get(alice).expect("alice base")));
699
700			let bob = changes.0.into_values().last().expect("bob delta");
701			let fields = selected_fields(bob, &[]).collect::<Vec<_>>();
702
703			assert_eq!(fields, [field("org.a"), field("org.z")]);
704		}
705	}
706
707	#[test]
708	fn collected_seed_preserves_base_dominance_in_either_order() {
709		let user = user_id!("@alice:example.com");
710
711		for seed_first in [true, false] {
712			let seed = [(user, "org.a"), (user, "org.z")]
713				.into_iter()
714				.map(own_change)
715				.collect();
716
717			let inputs = match seed_first {
718				| true => [Input::Collected(seed), Input::Base(user.to_owned())],
719				| false => [Input::Base(user.to_owned()), Input::Collected(seed)],
720			};
721
722			let changes: Candidates = inputs.into_iter().collect();
723
724			assert_eq!(changes.0.len(), 1);
725			assert!(is_base(changes.0.get(user).expect("base survives seed")));
726		}
727	}
728
729	#[test]
730	fn only_the_filtered_fields_are_carried() {
731		let requested = [field("m.status"), field("displayname")];
732
733		assert!(was_requested(&requested, "m.status"));
734		assert!(was_requested(&requested, "displayname"));
735		assert!(!was_requested(&requested, "avatar_url"));
736		assert!(!was_requested(&[], "m.status"));
737	}
738
739	#[test]
740	fn an_absent_field_reads_as_a_removal() {
741		let updates = [
742			(field("m.status"), Ok(Some(json!({"emoji": "🏊"})))),
743			(field("displayname"), Ok(None)),
744		]
745		.into_iter()
746		.map(field_update)
747		.collect::<Result<Updates>>()
748		.expect("readable fields");
749
750		assert_eq!(updates.get(&field("m.status")), Some(&json!({"emoji": "🏊"})));
751		assert_eq!(updates.get(&field("displayname")), Some(&Value::Null));
752		field_update((field("avatar_url"), Err!("unreadable")))
753			.expect_err("unreadable fields abort collection");
754	}
755
756	/// One member event to build: its type, whom it names, and their membership.
757	type MemberRow<'a> = (&'a str, &'a str, &'a str);
758
759	fn events<T>(rows: &[MemberRow<'_>]) -> Vec<Raw<T>> {
760		rows.iter()
761			.map(|(event_type, state_key, membership)| {
762				json!({
763					"type": event_type,
764					"state_key": state_key,
765					"content": { "membership": membership },
766				})
767			})
768			.map(|event| Raw::new(&event).expect("event serializes"))
769			.map(|event| event.cast_ref_unchecked::<T>().clone())
770			.collect()
771	}
772
773	#[test]
774	fn only_present_members_are_witnessed() {
775		let room = JoinedRoom {
776			state: State::Before(StateEvents {
777				events: events(&[
778					("m.room.member", "@alice:example.com", "join"),
779					("m.room.topic", "", "join"),
780					("m.room.member", "@gone:example.com", "leave"),
781					("m.room.member", "@banned:example.com", "ban"),
782					("m.room.member", "@asked:example.com", "invite"),
783				]),
784			}),
785			..Default::default()
786		};
787
788		assert_eq!(members(&room), ["@alice:example.com", "@asked:example.com"]);
789	}
790
791	#[test]
792	fn a_state_key_needing_unescaping_is_witnessed() {
793		// A borrowed &str refuses a string the parser had to unescape, and the
794		// backslash a historical user id may carry forces exactly that.
795		let room = JoinedRoom {
796			state: State::Before(StateEvents {
797				events: events(&[("m.room.member", r"@od\d:example.com", "join")]),
798			}),
799			..Default::default()
800		};
801
802		assert_eq!(members(&room), [r"@od\d:example.com"]);
803	}
804
805	#[test]
806	fn a_member_joining_inside_the_timeline_is_witnessed() {
807		// A legacy `state` request omits an event the timeline already carries.
808		let room = JoinedRoom {
809			timeline: Timeline {
810				events: events(&[("m.room.member", "@late:example.com", "join")]),
811				..Default::default()
812			},
813			..Default::default()
814		};
815
816		assert_eq!(members(&room), ["@late:example.com"]);
817	}
818}