From a997824316358f203e6d35e3d809f0fdb5c55ce9 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 13 Nov 2026 16:19:51 +0200 Subject: [PATCH 1/3] Fix missing Add friend action on Discord profiles --- crates/chatd/src/adapters/discord.rs | 9 +- crates/chatd/src/demo/adapter.rs | 1 + crates/chatd/tests/discord.rs | 31 ++++- crates/protocol/src/lib.rs | 2 + crates/tinyclient/src/gui/people.rs | 1 + crates/tinyclient/src/gui/people/card.rs | 18 +++ crates/tinyclient/src/gui/people/hub.rs | 4 +- crates/tinyclient/src/gui/people/info.rs | 18 +++ crates/tinyclient/src/gui/profile/facts.rs | 31 +++++ .../discord-profile-add-friend/README.md | 17 +++ .../drive.android.py | 3 + .../discord-profile-add-friend/drive.ios.sh | 3 + .../discord-profile-add-friend/drive.linux.sh | 3 + .../discord-profile-add-friend/drive.macos.sh | 3 + .../discord-profile-add-friend/drive.py | 106 ++++++++++++++++++ .../drive.windows.ps1 | 2 + .../discord-profile-add-friend/scenario.json | 14 +++ 17 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 e2e/scenarios/discord-profile-add-friend/README.md create mode 100644 e2e/scenarios/discord-profile-add-friend/drive.android.py create mode 100755 e2e/scenarios/discord-profile-add-friend/drive.ios.sh create mode 100755 e2e/scenarios/discord-profile-add-friend/drive.linux.sh create mode 100755 e2e/scenarios/discord-profile-add-friend/drive.macos.sh create mode 100755 e2e/scenarios/discord-profile-add-friend/drive.py create mode 100644 e2e/scenarios/discord-profile-add-friend/drive.windows.ps1 create mode 100644 e2e/scenarios/discord-profile-add-friend/scenario.json diff --git a/crates/chatd/src/adapters/discord.rs b/crates/chatd/src/adapters/discord.rs index b02cd560..6d9e52b1 100644 --- a/crates/chatd/src/adapters/discord.rs +++ b/crates/chatd/src/adapters/discord.rs @@ -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), diff --git a/crates/chatd/src/demo/adapter.rs b/crates/chatd/src/demo/adapter.rs index a9914c0e..81c3994b 100644 --- a/crates/chatd/src/demo/adapter.rs +++ b/crates/chatd/src/demo/adapter.rs @@ -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, diff --git a/crates/chatd/tests/discord.rs b/crates/chatd/tests/discord.rs index b5589c3b..f24760cd 100644 --- a/crates/chatd/tests/discord.rs +++ b/crates/chatd/tests/discord.rs @@ -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::(&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) @@ -2241,6 +2247,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(); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 6d63c45a..5e600d8b 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -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. diff --git a/crates/tinyclient/src/gui/people.rs b/crates/tinyclient/src/gui/people.rs index 3e1e202e..6f4ead1a 100644 --- a/crates/tinyclient/src/gui/people.rs +++ b/crates/tinyclient/src/gui/people.rs @@ -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}"), diff --git a/crates/tinyclient/src/gui/people/card.rs b/crates/tinyclient/src/gui/people/card.rs index 565ecf14..da161437 100644 --- a/crates/tinyclient/src/gui/people/card.rs +++ b/crates/tinyclient/src/gui/people/card.rs @@ -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.))); diff --git a/crates/tinyclient/src/gui/people/hub.rs b/crates/tinyclient/src/gui/people/hub.rs index cf255dfd..8b12c923 100644 --- a/crates/tinyclient/src/gui/people/hub.rs +++ b/crates/tinyclient/src/gui/people/hub.rs @@ -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, @@ -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); diff --git a/crates/tinyclient/src/gui/people/info.rs b/crates/tinyclient/src/gui/people/info.rs index b5f26cdf..094700ea 100644 --- a/crates/tinyclient/src/gui/people/info.rs +++ b/crates/tinyclient/src/gui/people/info.rs @@ -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; diff --git a/crates/tinyclient/src/gui/profile/facts.rs b/crates/tinyclient/src/gui/profile/facts.rs index f47d9f24..191d375f 100644 --- a/crates/tinyclient/src/gui/profile/facts.rs +++ b/crates/tinyclient/src/gui/profile/facts.rs @@ -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 { @@ -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); diff --git a/e2e/scenarios/discord-profile-add-friend/README.md b/e2e/scenarios/discord-profile-add-friend/README.md new file mode 100644 index 00000000..6ecff6b5 --- /dev/null +++ b/e2e/scenarios/discord-profile-add-friend/README.md @@ -0,0 +1,17 @@ +Run against the real release-profile app with the standard invented Discord demo data, in an +isolated data directory. Before starting the driver, open Casimir Thorne's profile through +People search (or a server member list). Keep the entire profile visible. Desktop uses +`chatd --seed-demo`; phones use the lab's demo app, as the smoke scenario does. Reset the +fixture between base and branch. No real Discord account is used. + +The lab records the run and supplies E2E_OUT and E2E_T0. Each platform wrapper runs drive.py. +Install Python, Pillow and Tesseract on the driver host; desktop also needs PyAutoGUI and +screen/input permission. Android uses adb; iOS uses E2E_UDID and the lab's existing +E2E_LAB/ios-ui test runner. These are screen input and OCR checks, with no store or RPC reads. + +The driver verifies the invented stranger's name and Add friend, clicks it, verifies Request +sent and Cancel request while Add friend is absent, cancels, then verifies Add friend returns. +On the base it fails at the first Add friend assertion. Repeat with the floating card on +desktop. API request shape, refusal, and duplicate-request coverage live in the Discord +adapter integration test; this UI scenario exercises the demo adapter and does not certify +Discord's live API. Lab provisioning and recorded runs are still required before claiming proof. diff --git a/e2e/scenarios/discord-profile-add-friend/drive.android.py b/e2e/scenarios/discord-profile-add-friend/drive.android.py new file mode 100644 index 00000000..bfafe437 --- /dev/null +++ b/e2e/scenarios/discord-profile-add-friend/drive.android.py @@ -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__') diff --git a/e2e/scenarios/discord-profile-add-friend/drive.ios.sh b/e2e/scenarios/discord-profile-add-friend/drive.ios.sh new file mode 100755 index 00000000..30ffbae8 --- /dev/null +++ b/e2e/scenarios/discord-profile-add-friend/drive.ios.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" ios diff --git a/e2e/scenarios/discord-profile-add-friend/drive.linux.sh b/e2e/scenarios/discord-profile-add-friend/drive.linux.sh new file mode 100755 index 00000000..63dc78a7 --- /dev/null +++ b/e2e/scenarios/discord-profile-add-friend/drive.linux.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" linux diff --git a/e2e/scenarios/discord-profile-add-friend/drive.macos.sh b/e2e/scenarios/discord-profile-add-friend/drive.macos.sh new file mode 100755 index 00000000..ff2e3e54 --- /dev/null +++ b/e2e/scenarios/discord-profile-add-friend/drive.macos.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" macos diff --git a/e2e/scenarios/discord-profile-add-friend/drive.py b/e2e/scenarios/discord-profile-add-friend/drive.py new file mode 100755 index 00000000..c17c8371 --- /dev/null +++ b/e2e/scenarios/discord-profile-add-friend/drive.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Drive the visible profile in the real app; the lab owns launch and recording.""" +import csv +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +platform = sys.argv[1] +out = Path(os.environ['E2E_OUT']) +out.mkdir(parents=True, exist_ok=True) +t0 = float(os.environ['E2E_T0']) + + +def run(*args, **kwargs): + return subprocess.run(args, check=True, **kwargs) + + +def capture(path): + if platform == 'android': + with path.open('wb') as image: + run('adb', 'exec-out', 'screencap', '-p', stdout=image) + elif platform == 'ios': + run('xcrun', 'simctl', 'io', os.environ['E2E_UDID'], 'screenshot', str(path)) + else: + import pyautogui + pyautogui.screenshot().save(path) + + +def read_screen(name): + path = out / (name + '.png') + capture(path) + result = run('tesseract', str(path), 'stdout', 'tsv', capture_output=True, text=True) + rows = list(csv.DictReader(io.StringIO(result.stdout), delimiter='\t')) + lines = {} + for row in rows: + if row['text'].strip(): + key = tuple(row[k] for k in ('page_num', 'block_num', 'par_num', 'line_num')) + lines.setdefault(key, []).append(row) + return path, list(lines.values()) + + +def find(lines, words): + for line in lines: + if words.lower() in ' '.join(word['text'] for word in line).lower(): + left = min(int(w['left']) for w in line) + top = min(int(w['top']) for w in line) + right = max(int(w['left']) + int(w['width']) for w in line) + bottom = max(int(w['top']) + int(w['height']) for w in line) + return ((left + right) // 2, (top + bottom) // 2) + return None + + +def step(name, ok, path): + with (out / 'steps.jsonl').open('a') as steps: + steps.write(json.dumps(dict(t=round(time.time()-t0, 2), step=name, + ok=ok, shot=path.name)) + '\n') + if not ok: + raise RuntimeError(name) + + +def expect(name, present, absent=()): + deadline = time.monotonic() + 15 + while True: + path, lines = read_screen(name) + ok = all(find(lines, text) for text in present) and not any(find(lines, text) for text in absent) + if ok or time.monotonic() >= deadline: + step(name, bool(ok), path) + return path, lines + time.sleep(0.5) + + +def tap(point, path): + x, y = point + if platform == 'android': + run('adb', 'shell', 'input', 'tap', str(x), str(y)) + elif platform == 'ios': + from PIL import Image + with Image.open(path) as image: + width, height = image.size + lab = Path(os.environ['E2E_LAB']) + env = dict(os.environ, TEST_RUNNER_LAB_ACTIONS=f'tap {x/width} {y/height}; wait 1', + TEST_RUNNER_LAB_LOG=str(out / 'actions.log'), TEST_RUNNER_LAB_BUNDLE='dev.tinyclient.app') + with (out / 'runner.log').open('a') as log: + run('xcodebuild', 'test-without-building', '-project', str(lab / 'ios-ui/TinyLabUI.xcodeproj'), + '-scheme', 'TinyLabUI', '-destination', 'platform=iOS Simulator,id=' + os.environ['E2E_UDID'], + '-derivedDataPath', str(lab / 'ios-ui-dd'), + '-only-testing:TinyLabUITests/LabActions/testActions', env=env, stdout=log, stderr=subprocess.STDOUT) + else: + import pyautogui + pyautogui.click(x, y) + + +try: + # Fail closed unless the fixture's invented stranger is the visible profile. + path, lines = expect('01-stranger-profile', ['Casimir Thorne', 'Add friend'], ['Cancel request']) + tap(find(lines, 'Add friend'), path) + path, lines = expect('02-request-sent', ['Request sent', 'Cancel request'], ['Add friend']) + tap(find(lines, 'Cancel request'), path) + expect('03-request-cancelled', ['Add friend'], ['Cancel request', 'Request sent']) +except Exception as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/e2e/scenarios/discord-profile-add-friend/drive.windows.ps1 b/e2e/scenarios/discord-profile-add-friend/drive.windows.ps1 new file mode 100644 index 00000000..fd69cd4b --- /dev/null +++ b/e2e/scenarios/discord-profile-add-friend/drive.windows.ps1 @@ -0,0 +1,2 @@ +python "$PSScriptRoot/drive.py" windows +exit $LASTEXITCODE diff --git a/e2e/scenarios/discord-profile-add-friend/scenario.json b/e2e/scenarios/discord-profile-add-friend/scenario.json new file mode 100644 index 00000000..cb9e123f --- /dev/null +++ b/e2e/scenarios/discord-profile-add-friend/scenario.json @@ -0,0 +1,14 @@ +{ + "id": "discord-profile-add-friend", + "title": "Send and cancel a friend request from a Discord profile", + "platforms": [ + "windows", + "macos", + "linux", + "android", + "ios" + ], + "needs": [], + "release": false, + "max_still_seconds": 60 +} From 453308228b160a23aee45fe39ee8954d43d33313 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 13 Nov 2026 16:27:21 +0200 Subject: [PATCH 2/3] Fix Discord friend request acceptance system messages Decode Discord message type 67 as a readable acceptance notice instead of the unsupported-message fallback, preserving the author's display name or saved nickname. Cover history and live gateway delivery with regression tests and add a UI scenario that reuses the profile scenario's screen and input helpers. Recorded lab proof remains pending. --- crates/chatd/src/adapters/discord/content.rs | 21 +++++++++++++++++++ crates/chatd/tests/discord.rs | 21 +++++++++++++++++++ .../discord-friend-request-accepted/README.md | 19 +++++++++++++++++ .../drive.android.py | 3 +++ .../drive.ios.sh | 3 +++ .../drive.linux.sh | 3 +++ .../drive.macos.sh | 3 +++ .../discord-friend-request-accepted/drive.py | 18 ++++++++++++++++ .../drive.windows.ps1 | 2 ++ .../scenario.json | 14 +++++++++++++ .../discord-profile-add-friend/drive.py | 21 ++++++++++--------- 11 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 e2e/scenarios/discord-friend-request-accepted/README.md create mode 100644 e2e/scenarios/discord-friend-request-accepted/drive.android.py create mode 100755 e2e/scenarios/discord-friend-request-accepted/drive.ios.sh create mode 100755 e2e/scenarios/discord-friend-request-accepted/drive.linux.sh create mode 100755 e2e/scenarios/discord-friend-request-accepted/drive.macos.sh create mode 100755 e2e/scenarios/discord-friend-request-accepted/drive.py create mode 100644 e2e/scenarios/discord-friend-request-accepted/drive.windows.ps1 create mode 100644 e2e/scenarios/discord-friend-request-accepted/scenario.json diff --git a/crates/chatd/src/adapters/discord/content.rs b/crates/chatd/src/adapters/discord/content.rs index 7ee9ba5c..d245b775 100644 --- a/crates/chatd/src/adapters/discord/content.rs +++ b/crates/chatd/src/adapters/discord/content.rs @@ -691,6 +691,7 @@ fn system_line(kind: u8, who: &str, content: &str, mentioned: &str) -> Option 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()), }) } @@ -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"); diff --git a/crates/chatd/tests/discord.rs b/crates/chatd/tests/discord.rs index f24760cd..00229d4b 100644 --- a/crates/chatd/tests/discord.rs +++ b/crates/chatd/tests/discord.rs @@ -1060,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(); diff --git a/e2e/scenarios/discord-friend-request-accepted/README.md b/e2e/scenarios/discord-friend-request-accepted/README.md new file mode 100644 index 00000000..66960db4 --- /dev/null +++ b/e2e/scenarios/discord-friend-request-accepted/README.md @@ -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. diff --git a/e2e/scenarios/discord-friend-request-accepted/drive.android.py b/e2e/scenarios/discord-friend-request-accepted/drive.android.py new file mode 100644 index 00000000..bfafe437 --- /dev/null +++ b/e2e/scenarios/discord-friend-request-accepted/drive.android.py @@ -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__') diff --git a/e2e/scenarios/discord-friend-request-accepted/drive.ios.sh b/e2e/scenarios/discord-friend-request-accepted/drive.ios.sh new file mode 100755 index 00000000..30ffbae8 --- /dev/null +++ b/e2e/scenarios/discord-friend-request-accepted/drive.ios.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" ios diff --git a/e2e/scenarios/discord-friend-request-accepted/drive.linux.sh b/e2e/scenarios/discord-friend-request-accepted/drive.linux.sh new file mode 100755 index 00000000..63dc78a7 --- /dev/null +++ b/e2e/scenarios/discord-friend-request-accepted/drive.linux.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" linux diff --git a/e2e/scenarios/discord-friend-request-accepted/drive.macos.sh b/e2e/scenarios/discord-friend-request-accepted/drive.macos.sh new file mode 100755 index 00000000..ff2e3e54 --- /dev/null +++ b/e2e/scenarios/discord-friend-request-accepted/drive.macos.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" macos diff --git a/e2e/scenarios/discord-friend-request-accepted/drive.py b/e2e/scenarios/discord-friend-request-accepted/drive.py new file mode 100755 index 00000000..1a4f33b4 --- /dev/null +++ b/e2e/scenarios/discord-friend-request-accepted/drive.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Open the fixture DM and inspect its actual rendered acceptance message.""" +import pathlib +import runpy +import sys + +# Reuse the screen/OCR and native input adapters from the profile scenario. +helpers = runpy.run_path(str(pathlib.Path(__file__).resolve().parent.parent / + 'discord-profile-add-friend' / 'drive.py')) +expect, find, tap = (helpers[name] for name in ('expect', 'find', 'tap')) +try: + path, lines = expect('01-inbox', ['Casimir Thorne']) + tap(find(lines, 'Casimir Thorne'), path) + expect('02-friend-acceptance', ['Casimir Thorne', 'accepted your friend request'], + ['A message tinyclient cannot show']) +except Exception as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/e2e/scenarios/discord-friend-request-accepted/drive.windows.ps1 b/e2e/scenarios/discord-friend-request-accepted/drive.windows.ps1 new file mode 100644 index 00000000..fd69cd4b --- /dev/null +++ b/e2e/scenarios/discord-friend-request-accepted/drive.windows.ps1 @@ -0,0 +1,2 @@ +python "$PSScriptRoot/drive.py" windows +exit $LASTEXITCODE diff --git a/e2e/scenarios/discord-friend-request-accepted/scenario.json b/e2e/scenarios/discord-friend-request-accepted/scenario.json new file mode 100644 index 00000000..cad58ece --- /dev/null +++ b/e2e/scenarios/discord-friend-request-accepted/scenario.json @@ -0,0 +1,14 @@ +{ + "id": "discord-friend-request-accepted", + "title": "Discord friend request acceptance displays a readable system message", + "platforms": [ + "windows", + "macos", + "linux", + "android", + "ios" + ], + "needs": [], + "release": false, + "max_still_seconds": 60 +} diff --git a/e2e/scenarios/discord-profile-add-friend/drive.py b/e2e/scenarios/discord-profile-add-friend/drive.py index c17c8371..023c15e3 100755 --- a/e2e/scenarios/discord-profile-add-friend/drive.py +++ b/e2e/scenarios/discord-profile-add-friend/drive.py @@ -94,13 +94,14 @@ def tap(point, path): pyautogui.click(x, y) -try: - # Fail closed unless the fixture's invented stranger is the visible profile. - path, lines = expect('01-stranger-profile', ['Casimir Thorne', 'Add friend'], ['Cancel request']) - tap(find(lines, 'Add friend'), path) - path, lines = expect('02-request-sent', ['Request sent', 'Cancel request'], ['Add friend']) - tap(find(lines, 'Cancel request'), path) - expect('03-request-cancelled', ['Add friend'], ['Cancel request', 'Request sent']) -except Exception as error: - print(error, file=sys.stderr) - sys.exit(1) +if __name__ == '__main__': + try: + # Fail closed unless the fixture's invented stranger is the visible profile. + path, lines = expect('01-stranger-profile', ['Casimir Thorne', 'Add friend'], ['Cancel request']) + tap(find(lines, 'Add friend'), path) + path, lines = expect('02-request-sent', ['Request sent', 'Cancel request'], ['Add friend']) + tap(find(lines, 'Cancel request'), path) + expect('03-request-cancelled', ['Add friend'], ['Cancel request', 'Request sent']) + except Exception as error: + print(error, file=sys.stderr) + sys.exit(1) From fe43b2bd9c8f5397021b3e973f4bb7f16ce1028e Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 13 Nov 2026 16:52:15 +0200 Subject: [PATCH 3/3] Make Discord message sender nicknames open profiles Open the same profile as user mentions when a Discord sender name is clicked. Underline clickable names on hover and keep the click target separate from timestamps and message status marks. Add a UI scenario for opening a profile from a sender nickname. GUI compilation passes; recorded platform proof remains pending. --- .../tinyclient/src/gui/conversation/draw.rs | 23 +++++++------ .../discord-sender-profile/README.md | 19 +++++++++++ .../discord-sender-profile/drive.android.py | 3 ++ .../discord-sender-profile/drive.ios.sh | 3 ++ .../discord-sender-profile/drive.linux.sh | 3 ++ .../discord-sender-profile/drive.macos.sh | 3 ++ e2e/scenarios/discord-sender-profile/drive.py | 34 +++++++++++++++++++ .../discord-sender-profile/drive.windows.ps1 | 2 ++ .../discord-sender-profile/scenario.json | 14 ++++++++ 9 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 e2e/scenarios/discord-sender-profile/README.md create mode 100644 e2e/scenarios/discord-sender-profile/drive.android.py create mode 100755 e2e/scenarios/discord-sender-profile/drive.ios.sh create mode 100755 e2e/scenarios/discord-sender-profile/drive.linux.sh create mode 100755 e2e/scenarios/discord-sender-profile/drive.macos.sh create mode 100755 e2e/scenarios/discord-sender-profile/drive.py create mode 100644 e2e/scenarios/discord-sender-profile/drive.windows.ps1 create mode 100644 e2e/scenarios/discord-sender-profile/scenario.json diff --git a/crates/tinyclient/src/gui/conversation/draw.rs b/crates/tinyclient/src/gui/conversation/draw.rs index 87ba8e24..9a774b72 100644 --- a/crates/tinyclient/src/gui/conversation/draw.rs +++ b/crates/tinyclient/src/gui/conversation/draw.rs @@ -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)) @@ -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) -> 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)); })) @@ -784,7 +784,7 @@ impl Pane { } /// The head line: who, when, and the marks a message carries. - fn header(&self, message: &Message, receipt: Option) -> Div { + fn header(&self, message: &Message, receipt: Option, cx: &mut Context) -> Div { let name = message.shown_sender().to_owned(); let phone = self.metrics == Metrics::PHONE; let tag = |words: &'static str| { @@ -812,7 +812,7 @@ impl Pane { .flex() .items_baseline() .gap(px(8.)) - .child( + .child(self.author_tap( div() .relative() .min_w_0() @@ -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() diff --git a/e2e/scenarios/discord-sender-profile/README.md b/e2e/scenarios/discord-sender-profile/README.md new file mode 100644 index 00000000..af2b807c --- /dev/null +++ b/e2e/scenarios/discord-sender-profile/README.md @@ -0,0 +1,19 @@ +# Discord sender nickname opens a profile + +Start the real release-profile app in an isolated test account with invented identities. +Open a Discord server channel containing a visible message from Casimir Thorne (@casimir), +a non-friend. The visible author header must use that nickname. Keep the member list and +profile closed, and keep this person's name out of the sidebar and message body so the +sender header is the only matching screen label. The lab starts its recorder before the driver. + +The driver locates only the sender name's text bounds (excluding its adjacent timestamp), +clicks it using native input, and requires @casimir and Add friend in the opened profile. +Desktop shows the floating profile card; phones show the Profile page. On desktop the base +fails at the profile assertion, while the branch must pass. Phones already supported author +taps: they are regression runs and are not expected to fail on the base. The lab must report +this distinction rather than claim a base-failing phone run. + +The screen/input adapters come from ../discord-profile-add-friend/drive.py; its README lists +Tesseract, Pillow, PyAutoGUI and platform input requirements. E2E_OUT/E2E_T0 come from the lab. +Provision the fictional Discord account/channel fixture before each run. Recorded platform +proof is still pending; adding this scenario does not certify a UI run. diff --git a/e2e/scenarios/discord-sender-profile/drive.android.py b/e2e/scenarios/discord-sender-profile/drive.android.py new file mode 100644 index 00000000..bfafe437 --- /dev/null +++ b/e2e/scenarios/discord-sender-profile/drive.android.py @@ -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__') diff --git a/e2e/scenarios/discord-sender-profile/drive.ios.sh b/e2e/scenarios/discord-sender-profile/drive.ios.sh new file mode 100755 index 00000000..30ffbae8 --- /dev/null +++ b/e2e/scenarios/discord-sender-profile/drive.ios.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" ios diff --git a/e2e/scenarios/discord-sender-profile/drive.linux.sh b/e2e/scenarios/discord-sender-profile/drive.linux.sh new file mode 100755 index 00000000..63dc78a7 --- /dev/null +++ b/e2e/scenarios/discord-sender-profile/drive.linux.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" linux diff --git a/e2e/scenarios/discord-sender-profile/drive.macos.sh b/e2e/scenarios/discord-sender-profile/drive.macos.sh new file mode 100755 index 00000000..ff2e3e54 --- /dev/null +++ b/e2e/scenarios/discord-sender-profile/drive.macos.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 "$(dirname "$0")/drive.py" macos diff --git a/e2e/scenarios/discord-sender-profile/drive.py b/e2e/scenarios/discord-sender-profile/drive.py new file mode 100755 index 00000000..0731209b --- /dev/null +++ b/e2e/scenarios/discord-sender-profile/drive.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Click the visible message author and verify the same person's profile opens.""" +import pathlib +import runpy +import sys + +helpers = runpy.run_path(str(pathlib.Path(__file__).resolve().parent.parent / + 'discord-profile-add-friend' / 'drive.py')) +expect, tap = (helpers[name] for name in ('expect', 'tap')) + + +def sender_position(lines): + # The timestamp shares the sender's OCR line. Click only the name's bounds. + wanted = ['Casimir', 'Thorne'] + for line in lines: + for start in range(len(line) - len(wanted) + 1): + words = line[start:start + len(wanted)] + if [word['text'] for word in words] == wanted: + left = min(int(word['left']) for word in words) + top = min(int(word['top']) for word in words) + right = max(int(word['left']) + int(word['width']) for word in words) + bottom = max(int(word['top']) + int(word['height']) for word in words) + return ((left + right) // 2, (top + bottom) // 2) + raise RuntimeError('The fixture message sender is not visible') + + +try: + path, lines = expect('01-message-author', ['Casimir Thorne'], ['View profile', 'Add friend']) + tap(sender_position(lines), path) + # Card on desktop, Profile page on a phone; both show the same handle and action. + expect('02-sender-profile', ['@casimir', 'Add friend']) +except Exception as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/e2e/scenarios/discord-sender-profile/drive.windows.ps1 b/e2e/scenarios/discord-sender-profile/drive.windows.ps1 new file mode 100644 index 00000000..fd69cd4b --- /dev/null +++ b/e2e/scenarios/discord-sender-profile/drive.windows.ps1 @@ -0,0 +1,2 @@ +python "$PSScriptRoot/drive.py" windows +exit $LASTEXITCODE diff --git a/e2e/scenarios/discord-sender-profile/scenario.json b/e2e/scenarios/discord-sender-profile/scenario.json new file mode 100644 index 00000000..8ba3b00f --- /dev/null +++ b/e2e/scenarios/discord-sender-profile/scenario.json @@ -0,0 +1,14 @@ +{ + "id": "discord-sender-profile", + "title": "Click a Discord message sender nickname to open their profile", + "platforms": [ + "windows", + "macos", + "linux", + "android", + "ios" + ], + "needs": [], + "release": false, + "max_still_seconds": 60 +}