atagen

macltok

public
Star0
Watch0

Commit fa5b4e09943a

tarball

atagen <boss@atagen.co>2026-10-24 09:36 UTC

6 files changed+213-75
timeline: show a room's pins as their own timeline and add set_pinned

Pinned events open as TimelineId::Pinned on the sdk's PinnedEvents focus instead of a one-shot list, because a MediaId only resolves while the timeline that registered it is open, and a list would hand back attachments nobody can load. Threads and pins now share the off-actor build and queue, keyed by TimelineId. set_pinned rewrites m.room.pinned_events from the server's copy so a pin made elsewhere a moment earlier survives, and pin and power level changes now refetch summaries since neither raises a notable room info update.
Modified file: src/engine/mutations.rs+52 additions−1 deletions
@@ -15,6 +15,7 @@ use matrix_sdk::{
1515 },
1616 state::get_state_event_for_key,
1717 },
18+ api::error::ErrorKind as ApiErrorKind,
1819 events::{
1920 AnyGlobalAccountDataEventContent, GlobalAccountDataEventType, MessageLikeEventType,
2021 StateEventType,
@@ -24,7 +25,7 @@ use matrix_sdk::{
2425 serde::{JsonObject, Raw},
2526 },
2627 };
27-use ruma::{OwnedRoomId, OwnedUserId};
28+use ruma::{EventId, OwnedRoomId, OwnedUserId};
2829 use serde_json::Value as JsonValue;
2930 use tokio::sync::mpsc;
3031
@@ -184,6 +185,7 @@ fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {
184185 | RoomCommand::SetUserPowerLevel { room_id, .. }
185186 | RoomCommand::MuteUser { room_id, .. }
186187 | RoomCommand::SetFavourite { room_id, .. }
188+ | RoomCommand::SetPinned { room_id, .. }
187189 | RoomCommand::SetOwnDisplayName { room_id, .. }
188190 | RoomCommand::SetOwnAvatar { room_id, .. }
189191 | RoomCommand::SetAccountData { room_id, .. },
@@ -334,6 +336,13 @@ async fn execute(
334336 } => mute_user(client, &room(&room_id)?, &user_id, muted)
335337 .await
336338 .map(done),
339+ RoomCommand::SetPinned {
340+ room_id,
341+ event_id,
342+ pinned,
343+ } => set_pinned(client, &room(&room_id)?, &event_id, pinned)
344+ .await
345+ .map(done),
337346 RoomCommand::SetFavourite { room_id, favourite } => room(&room_id)?
338347 .set_is_favourite(favourite, None)
339348 .await
@@ -644,6 +653,48 @@ async fn set_own_member_field(
644653 .map_err(|source| Error::from_sdk("updating your member event failed", source))
645654 }
646655
656+/// against the server's list, since the store's lags a sync and another client may have just pinned
657+async fn set_pinned(
658+ client: &Client,
659+ room: &Room,
660+ event_id: &EventId,
661+ pinned: bool,
662+) -> Result<(), Error> {
663+ let request = get_state_event_for_key::v3::Request::new(
664+ room.room_id().to_owned(),
665+ StateEventType::RoomPinnedEvents,
666+ String::new(),
667+ );
668+ let mut content: JsonObject = match client.send(request).await {
669+ Ok(response) => {
670+ serde_json::from_str(response.event_or_content.get()).map_err(|source| {
671+ Error::with_source(ErrorKind::Server, "the pinned list is malformed", source)
672+ })?
673+ }
674+ Err(error) if error.client_api_error_kind() == Some(&ApiErrorKind::NotFound) => {
675+ JsonObject::new()
676+ }
677+ Err(source) => return Err(Error::from_http("reading the pinned list failed", source)),
678+ };
679+ let list = content
680+ .entry("pinned")
681+ .or_insert_with(|| JsonValue::Array(Vec::new()));
682+ if !list.is_array() {
683+ *list = JsonValue::Array(Vec::new());
684+ }
685+ let list = list.as_array_mut().expect("just made it an array");
686+ let present = list.iter().any(|id| id.as_str() == Some(event_id.as_str()));
687+ match (pinned, present) {
688+ (true, true) | (false, false) => return Ok(()),
689+ (true, false) => list.push(event_id.as_str().into()),
690+ (false, true) => list.retain(|id| id.as_str() != Some(event_id.as_str())),
691+ }
692+ room.send_state_event_raw("m.room.pinned_events", "", JsonValue::Object(content))
693+ .await
694+ .map(drop)
695+ .map_err(|source| Error::from_sdk("updating the pinned list failed", source))
696+}
697+
647698 fn done<T>(_: T) -> CommandOutput {
648699 CommandOutput::Done
649700 }
Modified file: src/engine/room.rs+73 additions−56 deletions
@@ -2,7 +2,7 @@ use std::collections::{HashMap, VecDeque};
22
33 use matrix_sdk::{Room, RoomState};
44 use matrix_sdk_ui::timeline::{RoomExt, Timeline, TimelineFocus, TimelineReadReceiptTracking};
5-use ruma::{OwnedEventId, OwnedRoomId};
5+use ruma::OwnedRoomId;
66 use tokio::sync::mpsc;
77
88 use super::executor::{AbortHandle, JoinError, TaskSet, is_panic, resume_unwind};
@@ -222,28 +222,52 @@ struct Timelines {
222222 impl Timelines {
223223 async fn execute(&mut self, command: TimelineCommand, reply: Responder, room: &Room) {
224224 match command {
225- TimelineCommand::OpenThread { root_event_id, .. } => {
226- self.threads.open(root_event_id, reply, room);
225+ TimelineCommand::OpenThread {
226+ room_id,
227+ root_event_id,
228+ } => self
229+ .threads
230+ .open(TimelineId::thread(room_id, root_event_id), reply, room),
231+ TimelineCommand::CloseThread {
232+ room_id,
233+ root_event_id,
234+ } => {
235+ let id = TimelineId::thread(room_id, root_event_id);
236+ reply.done(self.threads.close_one(&id).await);
227237 }
228- TimelineCommand::CloseThread { root_event_id, .. } => {
229- reply.done(self.threads.close_one(&root_event_id).await);
238+ TimelineCommand::OpenPinned(room_id) => {
239+ self.threads.open(TimelineId::Pinned(room_id), reply, room);
240+ }
241+ TimelineCommand::ClosePinned(room_id) => {
242+ reply.done(self.threads.close_one(&TimelineId::Pinned(room_id)).await);
230243 }
231244 TimelineCommand::Resync {
232245 timeline_id: TimelineId::Room(_),
233246 } => reply.done(self.room.resync().await),
234247 command => {
235- let root = command
248+ let Some(id) = command
236249 .timeline_id()
237- .and_then(TimelineId::thread_root)
238- .cloned();
239- let Some(root) = root else {
250+ .filter(|id| !matches!(id, TimelineId::Room(_)))
251+ .cloned()
252+ else {
240253 return self.room.execute(command, reply, &mut self.work).await;
241254 };
255+ if matches!(id, TimelineId::Pinned(_))
256+ && matches!(
257+ command,
258+ TimelineCommand::Send { .. } | TimelineCommand::Paginate { .. }
259+ )
260+ {
261+ return reply.done(Err(Error::new(
262+ ErrorKind::Invalid,
263+ "the pinned timeline can't send or paginate",
264+ )));
265+ }
242266 if let TimelineCommand::MarkRead {
243267 timeline_id,
244268 event_id,
245269 } = &command
246- && !self.threads.open.contains_key(&root)
270+ && !self.threads.open.contains_key(&id)
247271 {
248272 let client = room.client();
249273 let (timeline_id, event_id) = (timeline_id.clone(), event_id.clone());
@@ -254,7 +278,7 @@ impl Timelines {
254278 });
255279 return;
256280 }
257- match self.threads.route(root, command, reply) {
281+ match self.threads.route(id, command, reply) {
258282 Routed::Open(session, TimelineCommand::Resync { .. }, reply) => {
259283 reply.done(session.resync().await);
260284 }
@@ -273,31 +297,25 @@ impl Timelines {
273297 room_id: &OwnedRoomId,
274298 context: &SessionContext,
275299 ) {
276- let Some((root, queued, timeline)) = self.threads.built(built) else {
300+ let Some((id, queued, timeline)) = self.threads.built(built) else {
277301 return;
278302 };
279303 let timeline = match timeline {
280304 Ok(timeline) => timeline,
281305 Err(error) => {
282- tracing::warn!(%error, %room_id, %root, "thread timeline build failed");
283- let error =
284- Error::with_source(ErrorKind::Timeline, "thread timeline build failed", error);
306+ tracing::warn!(%error, %room_id, ?id, "timeline build failed");
307+ let error = Error::with_source(ErrorKind::Timeline, "timeline build failed", error);
285308 for (_, reply) in queued {
286309 reply.done(Err(error.clone()));
287310 }
288311 return;
289312 }
290313 };
291- let id = TimelineId::thread(room_id.clone(), root.clone());
292- let Ok(session) = TimelineSession::start(id, timeline, context.clone()).await else {
314+ let Ok(session) = TimelineSession::start(id.clone(), timeline, context.clone()).await
315+ else {
293316 return;
294317 };
295- let session = self
296- .threads
297- .open
298- .entry(root)
299- .insert_entry(session)
300- .into_mut();
318+ let session = self.threads.open.entry(id).insert_entry(session).into_mut();
301319 for (command, reply) in queued {
302320 match command {
303321 None => reply.done(Ok(())),
@@ -309,7 +327,7 @@ impl Timelines {
309327 }
310328
311329 type Built = (
312- OwnedEventId,
330+ TimelineId,
313331 u64,
314332 Result<Timeline, matrix_sdk_ui::timeline::Error>,
315333 );
@@ -328,9 +346,10 @@ struct OpeningThread {
328346 queued: Queued,
329347 }
330348
349+/// threads and the pinned timeline, which build off the actor and queue what arrives meanwhile
331350 struct Threads {
332- open: HashMap<OwnedEventId, TimelineSession>,
333- opening: HashMap<OwnedEventId, OpeningThread>,
351+ open: HashMap<TimelineId, TimelineSession>,
352+ opening: HashMap<TimelineId, OpeningThread>,
334353 builds: TaskSet<Built>,
335354 next_generation: u64,
336355 queued: usize,
@@ -349,40 +368,43 @@ impl Threads {
349368 }
350369 }
351370
352- fn enqueue(&mut self, root: &OwnedEventId, command: Option<TimelineCommand>, reply: Responder) {
371+ fn enqueue(&mut self, id: &TimelineId, command: Option<TimelineCommand>, reply: Responder) {
353372 if self.queued >= self.capacity {
354373 return reply.done(Err(Error::new(
355374 ErrorKind::QueueFull,
356375 "thread command queue is full",
357376 )));
358377 }
359- let Some(opening) = self.opening.get_mut(root) else {
378+ let Some(opening) = self.opening.get_mut(id) else {
360379 return reply.done(Err(Error::new(
361380 ErrorKind::TimelineNotOpen,
362- "thread timeline is not open",
381+ "timeline is not open",
363382 )));
364383 };
365384 opening.queued.push_back((command, reply));
366385 self.queued += 1;
367386 }
368387
369- fn open(&mut self, root: OwnedEventId, reply: Responder, room: &Room) {
370- if self.open.contains_key(&root) {
388+ fn open(&mut self, id: TimelineId, reply: Responder, room: &Room) {
389+ if self.open.contains_key(&id) {
371390 return reply.done(Ok(()));
372391 }
373- if !self.opening.contains_key(&root) {
392+ if !self.opening.contains_key(&id) {
374393 let generation = self.next_generation;
375394 self.next_generation = self.next_generation.wrapping_add(1);
376395 let room = room.clone();
377- let build_root = root.clone();
396+ let build_id = id.clone();
378397 let build = self.builds.spawn(async move {
379- let focus = TimelineFocus::Thread {
380- root_event_id: build_root.clone(),
398+ let focus = match &build_id {
399+ TimelineId::Thread { root_event_id, .. } => TimelineFocus::Thread {
400+ root_event_id: root_event_id.clone(),
401+ },
402+ _ => TimelineFocus::PinnedEvents,
381403 };
382- (build_root, generation, build_timeline(&room, focus).await)
404+ (build_id, generation, build_timeline(&room, focus).await)
383405 });
384406 self.opening.insert(
385- root.clone(),
407+ id.clone(),
386408 OpeningThread {
387409 generation,
388410 build,
@@ -390,20 +412,15 @@ impl Threads {
390412 },
391413 );
392414 }
393- self.enqueue(&root, None, reply);
415+ self.enqueue(&id, None, reply);
394416 }
395417
396- fn route(
397- &mut self,
398- root: OwnedEventId,
399- command: TimelineCommand,
400- reply: Responder,
401- ) -> Routed<'_> {
402- if !self.open.contains_key(&root) {
403- self.enqueue(&root, Some(command), reply);
418+ fn route(&mut self, id: TimelineId, command: TimelineCommand, reply: Responder) -> Routed<'_> {
419+ if !self.open.contains_key(&id) {
420+ self.enqueue(&id, Some(command), reply);
404421 return Routed::Queued;
405422 }
406- let session = self.open.get_mut(&root).expect("thread was just checked");
423+ let session = self.open.get_mut(&id).expect("timeline was just checked");
407424 Routed::Open(session, command, reply)
408425 }
409426
@@ -412,36 +429,36 @@ impl Threads {
412429 &mut self,
413430 built: Result<Built, JoinError>,
414431 ) -> Option<(
415- OwnedEventId,
432+ TimelineId,
416433 Queued,
417434 Result<Timeline, matrix_sdk_ui::timeline::Error>,
418435 )> {
419- let (root, generation, timeline) = match built {
436+ let (id, generation, timeline) = match built {
420437 Ok(built) => built,
421438 Err(error) if error.is_cancelled() => return None,
422439 Err(error) => resume_unwind(error),
423440 };
424441 if self
425442 .opening
426- .get(&root)
443+ .get(&id)
427444 .is_none_or(|opening| opening.generation != generation)
428445 {
429446 return None;
430447 }
431- let opening = self.opening.remove(&root)?;
448+ let opening = self.opening.remove(&id)?;
432449 self.queued -= opening.queued.len();
433- Some((root, opening.queued, timeline))
450+ Some((id, opening.queued, timeline))
434451 }
435452
436- async fn close_one(&mut self, root: &OwnedEventId) -> Result<(), Error> {
437- if let Some(session) = self.open.remove(root) {
453+ async fn close_one(&mut self, id: &TimelineId) -> Result<(), Error> {
454+ if let Some(session) = self.open.remove(id) {
438455 session.close().await;
439456 return Ok(());
440457 }
441- let Some(opening) = self.opening.remove(root) else {
458+ let Some(opening) = self.opening.remove(id) else {
442459 return Err(Error::new(
443460 ErrorKind::TimelineNotOpen,
444- "thread timeline is not open",
461+ "timeline is not open",
445462 ));
446463 };
447464 opening.build.abort();
Modified file: src/engine/watchers.rs+31 additions−13 deletions
@@ -401,22 +401,38 @@ pub(super) async fn encryption(client: Client, events: EventSender) {
401401 }
402402 }
403403
404-/// space orders are room account data, which never touches room info
404+/// space orders, pins and power levels all feed summaries without raising a notable room info update
405405 pub(super) async fn rooms(client: Client, events: EventSender) {
406406 let (_, mut rooms) = client.rooms_stream();
407407 let mut info = client.room_info_notable_update_receiver();
408- let order_changed = Arc::new(Notify::new());
409- let handler = client.add_event_handler({
410- let order_changed = order_changed.clone();
411- move |event: Raw<AnyRoomAccountDataEvent>| {
412- let order_changed = order_changed.clone();
413- async move {
414- if event_type(&event).as_deref() == Some(space_order::EVENT_TYPE) {
415- order_changed.notify_one();
408+ let changed = Arc::new(Notify::new());
409+ let handlers = [
410+ client.add_event_handler({
411+ let changed = changed.clone();
412+ move |event: Raw<AnyRoomAccountDataEvent>| {
413+ let changed = changed.clone();
414+ async move {
415+ if event_type(&event).as_deref() == Some(space_order::EVENT_TYPE) {
416+ changed.notify_one();
417+ }
416418 }
417419 }
418- }
419- });
420+ }),
421+ client.add_event_handler({
422+ let changed = changed.clone();
423+ move |event: Raw<AnySyncStateEvent>| {
424+ let changed = changed.clone();
425+ async move {
426+ if matches!(
427+ event_type(&event).as_deref(),
428+ Some("m.room.pinned_events" | "m.room.power_levels")
429+ ) {
430+ changed.notify_one();
431+ }
432+ }
433+ }
434+ }),
435+ ];
420436 loop {
421437 if events.send(Event::RoomsChanged).await.is_err() {
422438 break;
@@ -424,14 +440,16 @@ pub(super) async fn rooms(client: Client, events: EventSender) {
424440 tokio::select! {
425441 update = rooms.next() => if update.is_none() { break },
426442 update = info.recv() => if matches!(update, Err(RecvError::Closed)) { break },
427- () = order_changed.notified() => {}
443+ () = changed.notified() => {}
428444 }
429445 // a sync touching fifty rooms is one invalidation, not fifty
430446 while let Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) =
431447 info.try_recv()
432448 {}
433449 }
434- client.remove_event_handler(handler);
450+ for handler in handlers {
451+ client.remove_event_handler(handler);
452+ }
435453 }
436454
437455 /// packs live in room state and account data, and leaving a room takes its packs with it
Modified file: src/model.rs+24 additions−4 deletions
@@ -115,6 +115,12 @@ pub enum RoomCommand {
115115 user_id: OwnedUserId,
116116 muted: bool,
117117 },
118+ /// pinning appends, so the newest pin is last like everywhere else
119+ SetPinned {
120+ room_id: OwnedRoomId,
121+ event_id: OwnedEventId,
122+ pinned: bool,
123+ },
118124 /// adding m.favourite drops m.lowpriority, the way Element does
119125 SetFavourite {
120126 room_id: OwnedRoomId,
@@ -240,6 +246,10 @@ pub enum TimelineCommand {
240246 room_id: OwnedRoomId,
241247 root_event_id: OwnedEventId,
242248 },
249+ /// the room's pinned events as a timeline of their own, kept current as pins change.
250+ /// it can't send or paginate, the pins are all it holds
251+ OpenPinned(OwnedRoomId),
252+ ClosePinned(OwnedRoomId),
243253 /// Re-emit an authoritative reset for an open room or thread without
244254 /// closing it. The reset arrives before this command's terminal result.
245255 Resync {
@@ -282,7 +292,10 @@ impl TimelineCommand {
282292 #[must_use]
283293 pub const fn room_id(&self) -> &OwnedRoomId {
284294 match self {
285- Self::OpenThread { room_id, .. } | Self::CloseThread { room_id, .. } => room_id,
295+ Self::OpenThread { room_id, .. }
296+ | Self::CloseThread { room_id, .. }
297+ | Self::OpenPinned(room_id)
298+ | Self::ClosePinned(room_id) => room_id,
286299 Self::Resync { timeline_id }
287300 | Self::Paginate { timeline_id, .. }
288301 | Self::Send { timeline_id, .. }
@@ -296,7 +309,10 @@ impl TimelineCommand {
296309 #[must_use]
297310 pub const fn timeline_id(&self) -> Option<&TimelineId> {
298311 match self {
299- Self::OpenThread { .. } | Self::CloseThread { .. } => None,
312+ Self::OpenThread { .. }
313+ | Self::CloseThread { .. }
314+ | Self::OpenPinned(_)
315+ | Self::ClosePinned(_) => None,
300316 Self::Resync { timeline_id }
301317 | Self::Paginate { timeline_id, .. }
302318 | Self::Send { timeline_id, .. }
@@ -317,13 +333,14 @@ pub enum TimelineId {
317333 room_id: OwnedRoomId,
318334 root_event_id: OwnedEventId,
319335 },
336+ Pinned(OwnedRoomId),
320337 }
321338
322339 impl TimelineId {
323340 #[must_use]
324341 pub const fn room_id(&self) -> &OwnedRoomId {
325342 match self {
326- Self::Room(room_id) | Self::Thread { room_id, .. } => room_id,
343+ Self::Room(room_id) | Self::Thread { room_id, .. } | Self::Pinned(room_id) => room_id,
327344 }
328345 }
329346
@@ -338,7 +355,7 @@ impl TimelineId {
338355 #[must_use]
339356 pub fn thread_root(&self) -> Option<&OwnedEventId> {
340357 match self {
341- Self::Room(_) => None,
358+ Self::Room(_) | Self::Pinned(_) => None,
342359 Self::Thread { root_event_id, .. } => Some(root_event_id),
343360 }
344361 }
@@ -974,6 +991,9 @@ pub struct RoomSummary {
974991 pub space_order: Option<String>,
975992 /// you can send im.ponies.room_emotes here, so you can make and edit its packs
976993 pub can_edit_packs: bool,
994+ /// m.room.pinned_events in order, the newest pin last
995+ pub pinned_events: Vec<OwnedEventId>,
996+ pub can_pin: bool,
977997 }
978998
979999 #[derive(Clone, Debug, Eq, PartialEq)]
Modified file: src/normalise/room.rs+7 additions−1 deletions
@@ -4,7 +4,8 @@ use matrix_sdk::{
44 deserialized_responses::SyncOrStrippedState,
55 latest_events::LatestEventValue,
66 ruma::events::{
7- AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncStateEvent,
7+ AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, StateEventType,
8+ SyncStateEvent,
89 room::message::{Relation, RoomMessageEventContent},
910 space::child::SpaceChildEventContent,
1011 },
@@ -108,6 +109,11 @@ async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {
108109 None
109110 },
110111 can_edit_packs: super::can_edit_packs(room).await,
112+ pinned_events: room.pinned_event_ids().unwrap_or_default(),
113+ can_pin: room.state() == RoomState::Joined
114+ && room.power_levels().await.is_ok_and(|levels| {
115+ levels.user_can_send_state(room.own_user_id(), StateEventType::RoomPinnedEvents)
116+ }),
111117 }
112118 }
113119
Modified file: src/transport.rs+26 additions
@@ -286,6 +286,32 @@ impl ClientHandle {
286286 .await
287287 }
288288
289+ /// the room's pins as `TimelineId::Pinned`, kept current as they change. the room has to be open
290+ pub async fn open_pinned(&self, room_id: OwnedRoomId) -> Result<(), Error> {
291+ self.run(Command::Timeline(TimelineCommand::OpenPinned(room_id)))
292+ .await
293+ }
294+
295+ pub async fn close_pinned(&self, room_id: OwnedRoomId) -> Result<(), Error> {
296+ self.run(Command::Timeline(TimelineCommand::ClosePinned(room_id)))
297+ .await
298+ }
299+
300+ /// reads the server's list so a pin made elsewhere just before isn't dropped
301+ pub async fn set_pinned(
302+ &self,
303+ room_id: OwnedRoomId,
304+ event_id: OwnedEventId,
305+ pinned: bool,
306+ ) -> Result<(), Error> {
307+ self.run(Command::Room(RoomCommand::SetPinned {
308+ room_id,
309+ event_id,
310+ pinned,
311+ }))
312+ .await
313+ }
314+
289315 pub async fn resync(&self, timeline_id: TimelineId) -> Result<(), Error> {
290316 self.run(Command::Timeline(TimelineCommand::Resync { timeline_id }))
291317 .await