Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion crates/chatd/src/adapters/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,15 +650,22 @@ impl Discord {
self.ctx.deleted(channel, message).map_err(|err| Refusal::Refused(err.to_string()))
}

/// Answers, withdraws or ends a friendship, or blocks. One request, and only ever here:
/// Sends or answers a friend request, withdraws or ends a friendship, or blocks. One request:
/// managing friends is what Discord's anti-abuse system watches most closely.
///
/// The bodies and the context header are the web client's as the community documents them,
/// unconfirmed against a real account.
fn relate(&self, person: &str, action: RelateAction) -> Result<(), Refusal> {
if action == RelateAction::Add {
let state = crate::hub::lock(&self.state);
if person == state.me || state.relations.get(person).is_some_and(|relation| *relation != Relation::None) {
return Err(Refusal::Refused("A friend request cannot be sent to this person.".into()));
}
}
let path = format!("/users/@me/relationships/{person}");
let context = |location: &str| base64::engine::general_purpose::STANDARD.encode(json!({"location": location}).to_string());
let (method, body, location, becomes) = match action {
RelateAction::Add => ("PUT", Some(json!({})), "User Profile", Relation::Outgoing),
RelateAction::Accept => ("PUT", Some(json!({})), "Friends", Relation::Friend),
RelateAction::Block => ("PUT", Some(json!({"type": 2})), "ContextMenu", Relation::Blocked),
RelateAction::Ignore | RelateAction::Cancel | RelateAction::Remove => ("DELETE", None, "Friends", Relation::None),
Expand Down
21 changes: 21 additions & 0 deletions crates/chatd/src/adapters/discord/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,7 @@ fn system_line(kind: u8, who: &str, content: &str, mentioned: &str) -> Option<St
29 => format!("{who} is now a speaker."),
31 => format!("{who} changed the Stage topic: {content}"),
46 => "A poll has closed.".to_owned(),
67 => format!("{who} accepted your friend request."),
_ => return Some(String::new()),
})
}
Expand Down Expand Up @@ -970,6 +971,26 @@ mod tests {
matching.map(|span| &rich.text[span.start as usize..span.end as usize]).collect()
}

#[test]
fn friend_request_acceptance_is_a_system_message() {
let account = AccountKey { network: protocol::Network::Discord, account: "test".into() };
let posted: Posted = serde_json::from_value(serde_json::json!({
"id": "100", "channel_id": "200", "type": 67, "content": "",
"author": {"id": "300", "username": "casimir", "global_name": "Casimir Thorne"}
})).unwrap();
let mut place = Place { account: &account, me: "400", guild: None, names: &Nobody, author: None };
let (decoded, _) = message(&posted, &place);
assert_eq!(decoded.kind, MessageKind::System);
assert_eq!(decoded.text, "Casimir Thorne accepted your friend request.");
place.author = Some("Cas".into());
let (decoded, _) = message(&posted, &place);
assert_eq!(decoded.text, "Cas accepted your friend request.");
assert!(decoded.spans.is_empty());

let unknown = Posted { kind: Some(255), ..posted };
assert!(matches!(message(&unknown, &place).0.kind, MessageKind::Unsupported { .. }));
}

#[test]
fn inline_styles_leave_no_markup_and_cover_their_words() {
let rich = parsed("a **bold _both_** ~~gone~~ __under__ ||secret|| `x*y` snake_case_name 2 * 3 * 4");
Expand Down
1 change: 1 addition & 0 deletions crates/chatd/src/demo/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ impl Demo {
}
Command::Relate { person_id, action, reply } => {
let relation = match action {
RelateAction::Add => Relation::Outgoing,
RelateAction::Accept => Relation::Friend,
RelateAction::Block => Relation::Blocked,
RelateAction::Ignore | RelateAction::Cancel | RelateAction::Remove => Relation::None,
Expand Down
52 changes: 51 additions & 1 deletion crates/chatd/tests/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,13 @@ fn respond(shared: &Shared, seen: &Seen) -> (u16, Value) {
(204, Value::Null)
}
("PUT", ["users", "@me", "relationships", person]) => {
let kind = seen.body["type"].as_u64().unwrap_or(1);
let kind = seen.body["type"].as_u64().unwrap_or_else(|| {
if seen.headers.get("x-context-properties").is_some_and(|value| {
base64::engine::general_purpose::STANDARD.decode(value).ok()
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.is_some_and(|context| context["location"] == "User Profile")
}) { 4 } else { 1 }
});
let user = json!({"id": person, "username": "asker", "global_name": "Asker", "discriminator": "0", "avatar": null});
shared.push("RELATIONSHIP_ADD", json!({"id": person, "type": kind, "nickname": null, "user": user}));
(204, Value::Null)
Expand Down Expand Up @@ -1054,6 +1060,27 @@ fn my_picture_is_the_accounts_from_ready_and_follows_a_change_to_it() {
assert_eq!(mine(), ("Justin".to_owned(), Some("avatar:1000:me1hash".to_owned())));
}

#[test]
fn friend_request_acceptance_renders_from_history_and_live_events() {
let accepted = json!({
"id": "1300000010000000020", "channel_id": "3001", "type": 67,
"author": {"id": "2001", "username": "mara", "global_name": "Mara Okafor", "discriminator": "0"},
"content": "", "timestamp": "2026-10-13T08:15:00.000000+00:00"
});
let world = World::launch(&[], |discord| discord.script("/channels/3001/messages", 200, json!([accepted])));
wait("the account to connect", || (world.link() == Link::Connected).then_some(()));
let history = wait("friend acceptance from history", || world.messages("3001").into_iter()
.find(|message| message.id == "1300000010000000020"));
assert_eq!(history.kind, MessageKind::System);
assert_eq!(history.text, "Mara O accepted your friend request.");

let id = world.incoming("3001", None, "", json!({"type": 67}));
let live = wait("live friend acceptance", || world.messages("3001").into_iter().find(|message| message.id == id));
assert_eq!(live.kind, MessageKind::System);
assert_eq!(live.text, format!("{} accepted your friend request.", live.sender.name));
assert!(!live.text.contains("cannot show"));
}

#[test]
fn messages_map_to_the_one_model_and_follow_live_events() {
let world = World::start();
Expand Down Expand Up @@ -2241,6 +2268,29 @@ fn reactions_follow_the_channels_permissions_and_what_they_refuse_never_reaches_
assert_eq!(reacted(), 2);
}

#[test]
fn profile_friend_request_is_outgoing_and_can_be_cancelled() {
let world = World::start();
settled(&world);
let request = |action| world.ask(Request::Relate { person: world.key("2999"), action });
assert!(matches!(world.ask(Request::Relate { person: world.key("1000"), action: RelateAction::Add }), Response::Error { .. }));
assert!(world.discord.requests_to("PUT", "/users/@me/relationships/1000").is_empty());
assert_eq!(request(RelateAction::Add), Response::Ok);
wait("outgoing friend request", || world.people(Some(Relation::Outgoing), None).iter()
.any(|person| person.key.id == "2999").then_some(()));
let sent = world.discord.requests_to("PUT", "/users/@me/relationships/2999");
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].body, json!({}));
assert!(matches!(request(RelateAction::Add), Response::Error { .. }));
assert_eq!(world.discord.requests_to("PUT", "/users/@me/relationships/2999").len(), 1);
assert_eq!(request(RelateAction::Cancel), Response::Ok);
assert!(world.people(Some(Relation::Outgoing), None).iter().all(|person| person.key.id != "2999"));

world.discord.script("/users/@me/relationships/2999", 400, json!({"message": "Requests disabled", "code": 80000}));
assert!(matches!(request(RelateAction::Add), Response::Error { .. }));
assert!(world.people(Some(Relation::Outgoing), None).iter().all(|person| person.key.id != "2999"));
}

#[test]
fn friendships_presence_and_profiles_are_asked_of_discord_only_when_the_user_does() {
let world = World::start();
Expand Down
2 changes: 2 additions & 0 deletions crates/protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,8 @@ pub struct SharedItem {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RelateAction {
/// Send a friend request.
Add,
/// Accept their friend request.
Accept,
/// Dismiss their friend request without telling them.
Expand Down
23 changes: 13 additions & 10 deletions crates/tinyclient/src/gui/conversation/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,10 +619,7 @@ impl Pane {
.min_w_0()
.flex()
.flex_col()
.children(head.then(|| {
let header = self.header(message, in_header);
self.author_tap(header, "author-name", message, cx)
}))
.children(head.then(|| self.header(message, in_header, cx)))
.child(sent)
.children(self.views_line(message))
.children(self.reactions(message, window, cx))
Expand Down Expand Up @@ -697,16 +694,19 @@ impl Pane {
div().w_full().when(head && !root, |row| row.pt(px(8.))).child(inner).into_any_element()
}

/// On a phone a tap on who said it, their face or their name, opens their Profile page, as
/// a click on an @mention does. Mine and an app's open nothing; the desktop draws them as is.
/// Discord sender names open the same profile as @mentions. On phones, other networks
/// also let a person open someone else's profile from their name or face.
fn author_tap(&self, part: Div, what: &'static str, message: &Message, cx: &mut Context<Self>) -> AnyElement {
let person = self.person(&message.sender.id).filter(|_| self.metrics == Metrics::PHONE && !message.outgoing && !message.sender.bot);
let discord_name = what == "author-name" && self.key().is_some_and(|key| key.network == protocol::Network::Discord);
let person = self.person(&message.sender.id).filter(|_| discord_name || (self.metrics == Metrics::PHONE && !message.outgoing && !message.sender.bot));
let Some(person) = person else {
return part.into_any_element();
};
part.id(name_id(what, &message.id))
.cursor_pointer()
.when(what == "author-name", |name| name.hover(|style| style.underline()))
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
let (person, at) = (person.clone(), window.mouse_position());
let _ = this.shell.update(cx, |shell, cx| shell.open_profile(person, at, cx));
}))
Expand Down Expand Up @@ -784,7 +784,7 @@ impl Pane {
}

/// The head line: who, when, and the marks a message carries.
fn header(&self, message: &Message, receipt: Option<Receipt>) -> Div {
fn header(&self, message: &Message, receipt: Option<Receipt>, cx: &mut Context<Self>) -> Div {
let name = message.shown_sender().to_owned();
let phone = self.metrics == Metrics::PHONE;
let tag = |words: &'static str| {
Expand Down Expand Up @@ -812,7 +812,7 @@ impl Pane {
.flex()
.items_baseline()
.gap(px(8.))
.child(
.child(self.author_tap(
div()
.relative()
.min_w_0()
Expand All @@ -821,7 +821,10 @@ impl Pane {
.when(phone, |name| name.text_size(px(tokens_phone::scaled(15.))).line_height(px(tokens_phone::scaled(20.))))
.font_weight(theme::SEMIBOLD)
.child(name),
)
"author-name",
message,
cx,
))
.child(
div()
.flex_none()
Expand Down
1 change: 1 addition & 0 deletions crates/tinyclient/src/gui/people.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,7 @@ impl Shell {

fn relate_failed(action: RelateAction, name: &str) -> String {
match action {
RelateAction::Add => format!("Can't send a friend request to {name}"),
RelateAction::Accept => format!("Can't accept {name}"),
RelateAction::Ignore => format!("Can't ignore {name}"),
RelateAction::Cancel => format!("Can't cancel the request to {name}"),
Expand Down
18 changes: 18 additions & 0 deletions crates/tinyclient/src/gui/people/card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,24 @@ impl Floating {
.when(actions.message || actions.request || actions.call.is_some(), |top| {
top.child(self.card_actions(person, &actions, cx).mt(px(16.)))
});
let is_self = self.scene.accounts.iter().any(|own| own.key == person.key.account() && own.me == person.key.id);
if let Some((action, label)) = facts::friendship_action(person).filter(|_| !is_self) {
let who = person.clone();
let friendship = button("card-friendship", ButtonKind::Secondary, label).w_full();
let friendship = if seen.online {
friendship.on_click(cx.listener(move |this, _, _, cx| {
let who = who.clone();
this.shell_do(cx, move |shell, cx| shell.relate(&who, action, cx));
}))
} else {
friendship.opacity(0.4).cursor_default()
.tooltip(crate::gui::components::tip(format!("{} is offline", person.key.network.label()), None))
};
if person.relation == protocol::Relation::Outgoing {
top = top.children(draw::soft_line("Request sent".to_owned()));
}
top = top.child(friendship.mt(px(12.)));
}
if let Some(heads_up) = facts::heads_up(person, profile, seen.online) {
let words = facts::heads_up_words(&heads_up, &person.name, network, crate::view::now());
top = top.child(draw::heads_up_line(&words).mt(px(12.)));
Expand Down
4 changes: 3 additions & 1 deletion crates/tinyclient/src/gui/people/hub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,7 @@ pub trait Leaves {
impl Leaves for RelateAction {
fn leaves(self, before: Relation) -> Relation {
match self {
RelateAction::Add => Relation::Outgoing,
RelateAction::Accept => Relation::Friend,
RelateAction::Ignore | RelateAction::Cancel | RelateAction::Remove => match before {
Relation::Blocked => Relation::Blocked,
Expand Down Expand Up @@ -816,7 +817,8 @@ mod tests {

#[test]
fn an_action_the_network_agreed_to_changes_what_the_person_is() {
use RelateAction::{Accept, Block, Cancel, Ignore, Remove};
use RelateAction::{Accept, Add, Block, Cancel, Ignore, Remove};
assert_eq!(Add.leaves(Relation::None), Relation::Outgoing);
assert_eq!(Accept.leaves(Relation::Incoming), Relation::Friend);
assert_eq!(Ignore.leaves(Relation::Incoming), Relation::None);
assert_eq!(Cancel.leaves(Relation::Outgoing), Relation::None);
Expand Down
18 changes: 18 additions & 0 deletions crates/tinyclient/src/gui/people/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,24 @@ impl InfoPane {
if !lone && (actions.more || actions.message || actions.request || actions.call.is_some()) {
top = top.child(self.person_actions(person, &actions, &seen, more_in_row, cx).mt(px(16.)));
}
let is_self = self.scene.accounts.iter().any(|own| own.key == person.key.account() && own.me == person.key.id);
if let Some((action, label)) = facts::friendship_action(person).filter(|_| !is_self) {
let who = person.clone();
let friendship = button("info-friendship", ButtonKind::Secondary, label).w_full();
let friendship = if seen.online {
friendship.on_click(cx.listener(move |this, _, _, cx| {
let who = who.clone();
this.with_shell(cx, move |shell, cx| shell.relate(&who, action, cx));
}))
} else {
friendship.opacity(0.4).cursor_default()
.tooltip(tip(format!("{} is offline", person.key.network.label()), None))
};
if person.relation == protocol::Relation::Outgoing {
top = top.children(draw::soft_line("Request sent".to_owned()));
}
top = top.child(friendship.mt(px(12.)));
}
if actions.notifications {
if let Some(row) = self.row.clone() {
let on = self.notify_of(&row, None) != Notify::Nothing;
Expand Down
31 changes: 31 additions & 0 deletions crates/tinyclient/src/gui/profile/facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ pub fn actions(person: &Person, seen: &Seen, surface: Surface) -> Actions {
}
}

/// A profile offers a request only to active Discord people without a relationship.
pub fn friendship_action(person: &Person) -> Option<(protocol::RelateAction, &'static str)> {
if person.key.network != Network::Discord || person.bot || person.mark == Some(Mark::Deactivated) {
return None;
}
match person.relation {
Relation::None => Some((protocol::RelateAction::Add, "Add friend")),
Relation::Outgoing => Some((protocol::RelateAction::Cancel, "Cancel request")),
_ => None,
}
}

/// The one heads-up line, by severity.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeadsUp {
Expand Down Expand Up @@ -872,6 +884,25 @@ mod tests {

const DM: Seen = Seen { native_calls: false, in_their_dm: true, direct: Some((CallSupport::Native, false)), online: true };

#[test]
fn friendship_buttons_follow_discord_relationship_state() {
use protocol::RelateAction;
assert_eq!(friendship_action(&person(Network::Discord, Relation::None)), Some((RelateAction::Add, "Add friend")));
assert_eq!(friendship_action(&person(Network::Discord, Relation::Outgoing)), Some((RelateAction::Cancel, "Cancel request")));
for relation in [Relation::Friend, Relation::Incoming, Relation::Blocked, Relation::Contact] {
assert_eq!(friendship_action(&person(Network::Discord, relation)), None);
}
for network in [Network::Tiny, Network::Slack, Network::Teams, Network::Telegram, Network::Signal] {
assert_eq!(friendship_action(&person(network, Relation::None)), None);
}
let mut bot = person(Network::Discord, Relation::None);
bot.bot = true;
assert_eq!(friendship_action(&bot), None);
bot.bot = false;
bot.mark = Some(Mark::Deactivated);
assert_eq!(friendship_action(&bot), None);
}

#[test]
fn inside_their_dm_there_is_no_message_button_and_mute_is_the_switch() {
let juno = person(Network::Discord, Relation::Friend);
Expand Down
19 changes: 19 additions & 0 deletions e2e/scenarios/discord-friend-request-accepted/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Discord friend request acceptance

Provision isolated Discord test accounts with invented identities, including Casimir Thorne.
With the real release-profile app connected to the requester account, have Casimir accept its
request. Start the recording with the inbox visible and Casimir Thorne's DM in the sidebar;
the acceptance must be in the newest visible page, without other unsupported messages.
Run the platform driver, which opens that DM using screen input and checks the rendered
acceptance text while rejecting the unsupported-message fallback. Reset app data and refetch
history for each base/branch run, so cached decoded messages do not mask the change.

The base fails the acceptance assertion. The branch must display a system line with the name
and “accepted your friend request.” The decoder integration test separately covers history
and live gateway delivery, including the saved friend nickname.

The driver reuses the screen/OCR adapters and dependencies documented in
../discord-profile-add-friend/README.md (Tesseract, Pillow, PyAutoGUI on desktop, adb on Android,
the existing lab UI runner on iOS). E2E_OUT and E2E_T0 come from the lab. No live credentials
or test accounts are provisioned by this scenario: that setup and the full recorded platform
runs are still required. No lab proof is claimed by adding the driver.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pathlib, runpy, sys
sys.argv = [str(pathlib.Path(__file__).with_name('drive.py')), 'android']
runpy.run_path(sys.argv[0], run_name='__main__')
3 changes: 3 additions & 0 deletions e2e/scenarios/discord-friend-request-accepted/drive.ios.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
python3 "$(dirname "$0")/drive.py" ios
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
python3 "$(dirname "$0")/drive.py" linux
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
python3 "$(dirname "$0")/drive.py" macos
Loading
Loading