Skip to content

Commit ace4048

Browse files
backups: Support pinned messages
1 parent cd9a196 commit ace4048

File tree

8 files changed

+359
-3
lines changed

8 files changed

+359
-3
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
v0.86.8
22

3+
- backups: Support pinned messages

rust/message-backup/src/backup/chat.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ use view_once_message::*;
6666
mod voice_message;
6767
use voice_message::*;
6868

69+
mod pinned;
70+
use pinned::*;
71+
6972
mod poll;
7073
use poll::*;
7174

@@ -248,6 +251,12 @@ pub enum ChatItemError {
248251
PollTerminateNotInGroup(DestinationKind),
249252
/// poll terminate not from contact or self
250253
PollTerminateNotFromContact,
254+
/// pin message not from contact or self
255+
PinMessageNotFromContact,
256+
/// pin message sent to release notes
257+
PinMessageToReleaseNotes,
258+
/// invalid pin message {0}
259+
InvalidPinMessage(#[from] PinMessageError),
251260
}
252261

253262
#[derive(Debug, thiserror::Error)]
@@ -358,6 +367,7 @@ pub struct ChatItemData<M: Method + ReferencedTypes> {
358367
/// The position of this chat item among all chat items (across chats) in
359368
/// the source stream.
360369
pub total_chat_item_order_index: usize,
370+
pub pin_details: Option<PinDetails>,
361371
_limit_construction_to_module: (),
362372
}
363373

@@ -553,6 +563,7 @@ impl<
553563
let proto::ChatItem {
554564
chatId: _,
555565
authorId,
566+
pinDetails,
556567
item,
557568
directionalDetails,
558569
revisions,
@@ -805,6 +816,11 @@ impl<
805816
}
806817
}
807818

819+
let pin_details = pinDetails
820+
.into_option()
821+
.map(|x| x.try_into_with(context))
822+
.transpose()?;
823+
808824
Ok(ChatItemData {
809825
author: author.clone(),
810826
cached_author_kind,
@@ -815,6 +831,7 @@ impl<
815831
expire_start,
816832
expires_in,
817833
sms,
834+
pin_details,
818835
total_chat_item_order_index: Default::default(),
819836
_limit_construction_to_module: (),
820837
})
@@ -1128,6 +1145,7 @@ mod test {
11281145
expireStartDate: Some(MillisecondsSinceEpoch::TEST_VALUE.0),
11291146
expiresInMs: Some(24 * 60 * 60 * 1000),
11301147
dateSent: MillisecondsSinceEpoch::TEST_VALUE.0,
1148+
pinDetails: Some(proto::chat_item::PinDetails::test_data()).into(),
11311149
..Default::default()
11321150
}
11331151
}
@@ -1232,6 +1250,7 @@ mod test {
12321250
sent_at: Timestamp::test_value(),
12331251
sms: false,
12341252
total_chat_item_order_index: 0,
1253+
pin_details: Some(PinDetails::from_proto_test_data()),
12351254
_limit_construction_to_module: (),
12361255
})
12371256
)
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
//
2+
// Copyright 2025 Signal Messenger, LLC.
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
//
5+
6+
#[cfg(test)]
7+
use derive_where::derive_where;
8+
9+
use crate::backup::TryIntoWith;
10+
use crate::backup::frame::RecipientId;
11+
use crate::backup::method::LookupPair;
12+
use crate::backup::recipient::MinimalRecipientData;
13+
use crate::backup::time::{ReportUnusualTimestamp, Timestamp, TimestampError};
14+
use crate::proto::backup::PinMessageUpdate as PinMessageUpdateProto;
15+
use crate::proto::backup::chat_item::PinDetails as PinDetailsProto;
16+
use crate::proto::backup::chat_item::pin_details::PinExpiry as PinExpiryProto;
17+
18+
#[derive(Debug, serde::Serialize)]
19+
#[cfg_attr(test, derive_where(PartialEq; Recipient: PartialEq))]
20+
pub struct PinMessageUpdate<Recipient> {
21+
pub target_sent_timestamp: Timestamp,
22+
pub author: Recipient,
23+
_limit_construction_to_module: (),
24+
}
25+
26+
#[derive(Debug, serde::Serialize)]
27+
#[cfg_attr(test, derive(PartialEq))]
28+
pub enum PinExpiry {
29+
At(Timestamp),
30+
Never,
31+
}
32+
33+
#[derive(Debug, serde::Serialize)]
34+
#[cfg_attr(test, derive(PartialEq))]
35+
pub struct PinDetails {
36+
pub pinned_at: Timestamp,
37+
pub expires: PinExpiry,
38+
_limit_construction_to_module: (),
39+
}
40+
41+
#[derive(Debug, thiserror::Error, displaydoc::Display)]
42+
#[cfg_attr(test, derive(PartialEq))]
43+
pub enum PinMessageError {
44+
/// author id is not present
45+
UnknownAuthorId,
46+
/// author id is not self nor contact
47+
InvalidAuthorId,
48+
/// {0}
49+
InvalidTimestamp(#[from] TimestampError),
50+
/// pin expiration is not set
51+
ExpirationNotSet,
52+
/// pin message expires before it is pinned
53+
ExpiresBeforeBeingPinned,
54+
}
55+
56+
impl<R: Clone, C: LookupPair<RecipientId, MinimalRecipientData, R> + ReportUnusualTimestamp>
57+
TryIntoWith<PinMessageUpdate<R>, C> for PinMessageUpdateProto
58+
{
59+
type Error = PinMessageError;
60+
61+
fn try_into_with(self, context: &C) -> Result<PinMessageUpdate<R>, Self::Error> {
62+
let PinMessageUpdateProto {
63+
targetSentTimestamp,
64+
authorId,
65+
special_fields: _,
66+
} = self;
67+
let author_id = RecipientId(authorId);
68+
let Some((author_data, author)) = context.lookup_pair(&author_id) else {
69+
return Err(Self::Error::UnknownAuthorId);
70+
};
71+
match author_data {
72+
MinimalRecipientData::Self_ | MinimalRecipientData::Contact { .. } => {}
73+
MinimalRecipientData::Group { .. }
74+
| MinimalRecipientData::DistributionList { .. }
75+
| MinimalRecipientData::ReleaseNotes
76+
| MinimalRecipientData::CallLink { .. } => return Err(Self::Error::InvalidAuthorId),
77+
}
78+
let target_sent_timestamp = Timestamp::from_millis(
79+
targetSentTimestamp,
80+
"PinMessageUpdate.targetSentTimestamp",
81+
context,
82+
)?;
83+
Ok(PinMessageUpdate {
84+
target_sent_timestamp,
85+
author: author.clone(),
86+
_limit_construction_to_module: (),
87+
})
88+
}
89+
}
90+
91+
impl<C: ReportUnusualTimestamp> TryIntoWith<PinDetails, C> for PinDetailsProto {
92+
type Error = PinMessageError;
93+
94+
fn try_into_with(self, context: &C) -> Result<PinDetails, Self::Error> {
95+
let PinDetailsProto {
96+
pinnedAtTimestamp,
97+
pinExpiry,
98+
special_fields: _,
99+
} = self;
100+
let pinned_at =
101+
Timestamp::from_millis(pinnedAtTimestamp, "PinDetails.pinAtTimestamp", context)?;
102+
let expires = match pinExpiry {
103+
Some(PinExpiryProto::PinExpiresAtTimestamp(expiry)) => {
104+
let expiry = Timestamp::from_millis(
105+
expiry,
106+
"PinDetails.pinExpiry.pinExpiresAtTimestamp",
107+
context,
108+
)?;
109+
if expiry < pinned_at {
110+
return Err(PinMessageError::ExpiresBeforeBeingPinned);
111+
}
112+
PinExpiry::At(expiry)
113+
}
114+
Some(PinExpiryProto::PinNeverExpires(true)) => PinExpiry::Never,
115+
None | Some(PinExpiryProto::PinNeverExpires(false)) => {
116+
return Err(PinMessageError::ExpirationNotSet);
117+
}
118+
};
119+
Ok(PinDetails {
120+
pinned_at,
121+
expires,
122+
_limit_construction_to_module: (),
123+
})
124+
}
125+
}
126+
127+
#[cfg(test)]
128+
mod test {
129+
use assert_matches::assert_matches;
130+
use test_case::test_case;
131+
132+
use super::*;
133+
use crate::backup::recipient::FullRecipientData;
134+
use crate::backup::testutil::TestContext;
135+
use crate::backup::time::testutil::MillisecondsSinceEpoch;
136+
137+
impl PinExpiryProto {
138+
pub(crate) fn test_data() -> Self {
139+
PinExpiryProto::PinExpiresAtTimestamp(MillisecondsSinceEpoch::TEST_VALUE.0 + 1)
140+
}
141+
}
142+
143+
impl PinDetailsProto {
144+
pub(crate) fn test_data() -> Self {
145+
Self {
146+
pinnedAtTimestamp: MillisecondsSinceEpoch::TEST_VALUE.0,
147+
pinExpiry: Some(PinExpiryProto::test_data()),
148+
special_fields: Default::default(),
149+
}
150+
}
151+
}
152+
153+
impl PinDetails {
154+
pub(crate) fn from_proto_test_data() -> Self {
155+
Self {
156+
pinned_at: Timestamp::test_value(),
157+
expires: PinExpiry::At(Timestamp::from_millis_for_testing(
158+
MillisecondsSinceEpoch::TEST_VALUE.0 + 1,
159+
)),
160+
_limit_construction_to_module: (),
161+
}
162+
}
163+
}
164+
165+
impl PinMessageUpdateProto {
166+
pub(crate) fn test_data() -> Self {
167+
Self {
168+
targetSentTimestamp: MillisecondsSinceEpoch::TEST_VALUE.0,
169+
authorId: TestContext::SELF_ID.0,
170+
special_fields: Default::default(),
171+
}
172+
}
173+
}
174+
175+
impl PinMessageUpdate<FullRecipientData> {
176+
pub(crate) fn from_proto_test_data() -> Self {
177+
Self {
178+
target_sent_timestamp: Timestamp::test_value(),
179+
author: TestContext::test_recipient().clone(), // corresponds to Self
180+
_limit_construction_to_module: (),
181+
}
182+
}
183+
}
184+
185+
#[test_case(|_| {} => Ok(()); "happy path")]
186+
#[test_case(|x| x.targetSentTimestamp = Timestamp::INVALID_TIMESTAMP_MS =>
187+
Err(PinMessageError::InvalidTimestamp(TimestampError("PinMessageUpdate.targetSentTimestamp", Timestamp::INVALID_TIMESTAMP_MS)));
188+
"invalid timestamp")]
189+
#[test_case(|x| x.authorId = TestContext::NONEXISTENT_ID.0 => Err(PinMessageError::UnknownAuthorId); "missing author")]
190+
#[test_case(|x| x.authorId = TestContext::SELF_ID.0 => Ok(()); "author is self")]
191+
#[test_case(|x| x.authorId = TestContext::CONTACT_ID.0 => Ok(()); "author is contact")]
192+
#[test_case(|x| x.authorId = TestContext::GROUP_ID.0 => Err(PinMessageError::InvalidAuthorId); "author is group")]
193+
#[test_case(|x| x.authorId = TestContext::CALL_LINK_ID.0 => Err(PinMessageError::InvalidAuthorId); "author is call link")]
194+
#[test_case(|x| x.authorId = TestContext::RELEASE_NOTES_ID.0 => Err(PinMessageError::InvalidAuthorId); "author is release notes")]
195+
fn pin_message_update(modify: fn(&mut PinMessageUpdateProto)) -> Result<(), PinMessageError> {
196+
let mut update = PinMessageUpdateProto::test_data();
197+
modify(&mut update);
198+
update.try_into_with(&TestContext::default()).map(|_| ())
199+
}
200+
201+
#[test]
202+
fn pin_message_update_success() {
203+
let actual = PinMessageUpdateProto::test_data()
204+
.try_into_with(&TestContext::default())
205+
.expect("valid test data");
206+
assert_eq!(actual, PinMessageUpdate::from_proto_test_data())
207+
}
208+
209+
#[test_case(|_| {} => Ok(()); "happy path")]
210+
#[test_case(|x| x.pinnedAtTimestamp = Timestamp::INVALID_TIMESTAMP_MS =>
211+
Err(PinMessageError::InvalidTimestamp(TimestampError("PinDetails.pinAtTimestamp", Timestamp::INVALID_TIMESTAMP_MS)));
212+
"invalid timestamp")]
213+
#[test_case(|x| x.pinExpiry = None => Err(PinMessageError::ExpirationNotSet); "expiry not set")]
214+
#[test_case(|x| x.pinExpiry = Some(PinExpiryProto::PinExpiresAtTimestamp(Timestamp::INVALID_TIMESTAMP_MS)) =>
215+
Err(PinMessageError::InvalidTimestamp(TimestampError("PinDetails.pinExpiry.pinExpiresAtTimestamp", Timestamp::INVALID_TIMESTAMP_MS)));
216+
"invalid expiry timestamp")]
217+
#[test_case(|x| x.pinExpiry = Some(PinExpiryProto::PinNeverExpires(false)) => Err(PinMessageError::ExpirationNotSet); "expires never is false")]
218+
#[test_case(|x| {
219+
x.pinnedAtTimestamp = Timestamp::MAX_SAFE_TIMESTAMP_MS;
220+
x.pinExpiry = Some(PinExpiryProto::PinExpiresAtTimestamp(Timestamp::MAX_SAFE_TIMESTAMP_MS-1));
221+
} => Err(PinMessageError::ExpiresBeforeBeingPinned); "expires before being pinned")]
222+
fn pin_details(modify: fn(&mut PinDetailsProto)) -> Result<(), PinMessageError> {
223+
let mut message = PinDetailsProto::test_data();
224+
modify(&mut message);
225+
message.try_into_with(&TestContext::default()).map(|_| ())
226+
}
227+
228+
#[test]
229+
fn pin_details_success() {
230+
let actual = PinDetailsProto::test_data()
231+
.try_into_with(&TestContext::default())
232+
.expect("valid test data");
233+
assert_eq!(actual, PinDetails::from_proto_test_data())
234+
}
235+
236+
#[test]
237+
fn pin_details_concrete_expiration() {
238+
let original = PinDetailsProto {
239+
pinExpiry: Some(PinExpiryProto::PinExpiresAtTimestamp(42)),
240+
..PinDetailsProto::default()
241+
};
242+
let actual = original
243+
.try_into_with(&TestContext::default())
244+
.expect("valid test data");
245+
assert_matches!(actual.expires, PinExpiry::At(timestamp) => {
246+
assert_eq!(timestamp.as_millis(), 42)
247+
});
248+
}
249+
250+
#[test]
251+
fn pin_details_never_expires() {
252+
let original = PinDetailsProto {
253+
pinExpiry: Some(PinExpiryProto::PinNeverExpires(true)),
254+
..PinDetailsProto::default()
255+
};
256+
let actual = original
257+
.try_into_with(&TestContext::default())
258+
.expect("valid test data");
259+
assert_matches!(actual.expires, PinExpiry::Never);
260+
}
261+
}

0 commit comments

Comments
 (0)