@@ -8,7 +8,7 @@ use super::{
88use crate :: {
99 charts_view:: NEED_UPDATE ,
1010 client:: {
11- basic_client_builder, recv_raw, Chart , ChartRef , ChartRefChartInfo , Client , Collection , CollectionUpdate , Permissions , Ptr , Record ,
11+ basic_client_builder, recv_raw, Chart , ChartRef , ChartRefChartInfo , Client , Collection , CollectionUpdate , Permissions , Ptr , Record , User ,
1212 UserManager , CLIENT_TOKEN ,
1313 } ,
1414 data:: { BriefChartInfo , LocalChart } ,
@@ -31,6 +31,7 @@ use core::f32;
3131use futures_util:: StreamExt ;
3232use inputbox:: { InputBox , InputMode } ;
3333use macroquad:: prelude:: * ;
34+ use once_cell:: sync:: Lazy ;
3435use phira_mp_common:: { ClientCommand , CompactPos , JudgeEvent , TouchFrame } ;
3536use prpr:: {
3637 config:: Mods ,
@@ -50,6 +51,7 @@ use prpr::{
5051 time:: TimeManager ,
5152 ui:: { button_hit, render_chart_info, ChartInfoEdit , DRectButton , Dialog , LoadingParams , LongTouchState , RectButton , Scroll , Ui , UI_AUDIO } ,
5253} ;
54+ use regex:: Regex ;
5355use reqwest:: Method ;
5456use sanitize_filename:: sanitize;
5557use sasa:: { AudioClip , Frame , Music , MusicParams } ;
@@ -59,7 +61,7 @@ use sha2::{Digest, Sha256};
5961use std:: {
6062 any:: Any ,
6163 borrow:: Cow ,
62- collections:: { hash_map, HashMap , VecDeque } ,
64+ collections:: { hash_map, BTreeMap , HashMap , VecDeque } ,
6365 fs:: File ,
6466 io:: { BufWriter , Cursor , Seek , Write } ,
6567 path:: Path ,
@@ -83,8 +85,57 @@ static CONFIRM_CKSUM: AtomicBool = AtomicBool::new(false);
8385static UPLOAD_NOT_SAVED : AtomicBool = AtomicBool :: new ( false ) ;
8486static CONFIRM_OVERWRITE : AtomicBool = AtomicBool :: new ( false ) ;
8587static CONFIRM_UPLOAD : AtomicBool = AtomicBool :: new ( false ) ;
88+ static CONFIRM_AUTOCOMPLETE : AtomicBool = AtomicBool :: new ( false ) ;
89+ static SKIP_AUTOCOMPLETE : AtomicBool = AtomicBool :: new ( false ) ;
8690pub static RECORD_ID : AtomicI32 = AtomicI32 :: new ( -1 ) ;
8791
92+ /// Matches any `@name#id (role)` or `@name#id` or `@name (role)` or `@name`.
93+ /// Parentheses may be ASCII `()` or fullwidth `()`; whitespace before `(` is optional.
94+ /// Groups: 1=name, 2=id (optional), 3=role (optional)
95+ static MENTION_RE : Lazy < Regex > = Lazy :: new ( || Regex :: new ( r"@([^\s#@((]+)(?:#(\d+))?(?:\s*[((]([^))]+)[))])?" ) . unwrap ( ) ) ;
96+
97+ /// Parse all `@name#id` resolved collaborator mentions and return `(id, role)` pairs.
98+ fn parse_collaborators ( intro : & str ) -> BTreeMap < i32 , Option < String > > {
99+ use std:: collections:: btree_map:: Entry ;
100+
101+ let mut result = BTreeMap :: new ( ) ;
102+ for ( id, role) in MENTION_RE . captures_iter ( intro) . filter_map ( |cap| {
103+ let id: i32 = cap. get ( 2 ) ?. as_str ( ) . parse ( ) . ok ( ) ?;
104+ let role = cap. get ( 3 ) . map ( |m| m. as_str ( ) . to_owned ( ) ) ;
105+ Some ( ( id, role) )
106+ } ) {
107+ match result. entry ( id) {
108+ Entry :: Vacant ( e) => {
109+ e. insert ( role) ;
110+ }
111+ Entry :: Occupied ( mut e) => {
112+ if e. get ( ) . is_none ( ) && role. is_some ( ) {
113+ e. insert ( role) ;
114+ }
115+ }
116+ }
117+ }
118+ result
119+ }
120+
121+ /// Find all unresolved `@name` or `@name (role)` mentions (those missing `#id`).
122+ /// Returns `(start, end, name)` byte-offset pairs into `intro`.
123+ /// Processing right-to-left preserves earlier offsets during replacement.
124+ fn find_unresolved_mentions ( intro : & str ) -> Vec < ( usize , usize , String ) > {
125+ MENTION_RE
126+ . captures_iter ( intro)
127+ . filter_map ( |cap| {
128+ if cap. get ( 2 ) . is_some ( ) {
129+ // Already has #id — resolved
130+ return None ;
131+ }
132+ let m = cap. get ( 0 ) ?;
133+ let name = cap. get ( 1 ) ?. as_str ( ) . to_owned ( ) ;
134+ Some ( ( m. start ( ) , m. end ( ) , name) )
135+ } )
136+ . collect ( )
137+ }
138+
88139fn fade_in_time ( ) -> Option < f32 > {
89140 if get_data ( ) . prefer_reduced_motion {
90141 None
@@ -357,6 +408,9 @@ pub struct SongScene {
357408
358409 confirm_cancel_edit : Arc < AtomicBool > ,
359410
411+ collaborators : BTreeMap < i32 , Option < String > > ,
412+ autocomplete_task : Option < Task < Result < String > > > ,
413+
360414 export_task : Option < mpsc:: Receiver < Result < ( ) > > > ,
361415}
362416
@@ -544,6 +598,9 @@ impl SongScene {
544598
545599 confirm_cancel_edit : Arc :: default ( ) ,
546600
601+ collaborators : BTreeMap :: new ( ) ,
602+ autocomplete_task : None ,
603+
547604 export_task : None ,
548605 }
549606 }
@@ -1099,7 +1156,7 @@ impl SongScene {
10991156 }
11001157 r. x += dx;
11011158 if ui. button ( "save" , r, tl ! ( "edit-save" ) ) {
1102- self . save_edit ( ) ;
1159+ self . try_save_with_autocomplete ( ) ;
11031160 }
11041161
11051162 ui. ensure_touches ( )
@@ -1204,6 +1261,34 @@ impl SongScene {
12041261 }
12051262 dy ! ( 0.14 ) ;
12061263 }
1264+ if !self . collaborators . is_empty ( ) {
1265+ dy ! ( ui. text( tl!( "info-collaborators" ) ) . size( 0.4 ) . color( semi_white( 0.7 ) ) . draw( ) . h + 0.02 ) ;
1266+ for ( collab_id, role) in & self . collaborators {
1267+ let c = 0.06 ;
1268+ let s = 0.05 ;
1269+ let r = ui. avatar ( c, c, s, rt, UserManager :: opt_avatar ( * collab_id, & self . icons . user ) ) ;
1270+ if let Some ( ( name, color) ) = UserManager :: name_and_color ( * collab_id) {
1271+ let name_r = ui
1272+ . text ( name)
1273+ . pos ( r. right ( ) + 0.02 , r. center ( ) . y - if role. is_some ( ) { 0.01 } else { 0. } )
1274+ . anchor ( 0. , 0.5 )
1275+ . no_baseline ( )
1276+ . max_width ( width - 0.15 )
1277+ . size ( 0.5 )
1278+ . color ( color)
1279+ . draw ( ) ;
1280+ if let Some ( role_text) = role {
1281+ ui. text ( role_text. as_str ( ) )
1282+ . pos ( r. right ( ) + 0.02 , name_r. bottom ( ) + 0.005 )
1283+ . size ( 0.35 )
1284+ . color ( semi_white ( 0.6 ) )
1285+ . draw ( ) ;
1286+ }
1287+ }
1288+ dy ! ( 0.14 ) ;
1289+ }
1290+ }
1291+
12071292 let mut item = |title : Cow < ' _ , str > , content : Cow < ' _ , str > | {
12081293 dy ! ( ui. text( title) . size( 0.4 ) . color( semi_white( 0.7 ) ) . draw( ) . h + 0.02 ) ;
12091294 dy ! ( ui. text( content) . pos( pad, 0. ) . size( 0.6 ) . multiline( ) . max_width( mw) . draw( ) . h + 0.03 ) ;
@@ -1347,6 +1432,61 @@ impl SongScene {
13471432 } ) ) ;
13481433 }
13491434
1435+ fn try_save_with_autocomplete ( & mut self ) {
1436+ let intro = & self . info_edit . as_ref ( ) . unwrap ( ) . info . intro ;
1437+ let unresolved = find_unresolved_mentions ( intro) ;
1438+ if unresolved. is_empty ( ) {
1439+ self . save_edit ( ) ;
1440+ } else {
1441+ let mentions_list = unresolved
1442+ . iter ( )
1443+ . map ( |( start, end, _) | & intro[ * start..* end] )
1444+ . collect :: < Vec < _ > > ( )
1445+ . join ( ", " ) ;
1446+ let content = tl ! ( "collab-autocomplete-content" , "mentions" => mentions_list) ;
1447+ Dialog :: plain ( tl ! ( "collab-autocomplete-title" ) , content)
1448+ . buttons ( vec ! [ ttl!( "cancel" ) . into_owned( ) , ttl!( "confirm" ) . into_owned( ) ] )
1449+ . listener ( |_dialog, pos| {
1450+ if pos == 1 {
1451+ CONFIRM_AUTOCOMPLETE . store ( true , Ordering :: SeqCst ) ;
1452+ } else if pos == 0 {
1453+ SKIP_AUTOCOMPLETE . store ( true , Ordering :: SeqCst ) ;
1454+ }
1455+ false
1456+ } )
1457+ . show ( ) ;
1458+ }
1459+ }
1460+
1461+ fn start_autocomplete ( & mut self ) {
1462+ let intro = self . info_edit . as_ref ( ) . unwrap ( ) . info . intro . clone ( ) ;
1463+ self . autocomplete_task = Some ( Task :: new ( async move {
1464+ let unresolved = find_unresolved_mentions ( & intro) ;
1465+ // Resolve each mention (bail on first failure), then apply right-to-left
1466+ // so earlier byte offsets stay valid.
1467+ let mut resolved: Vec < ( usize , usize , String ) > = Vec :: new ( ) ;
1468+ for ( start, end, name) in unresolved {
1469+ let name_owned = name. clone ( ) ;
1470+ let ( users, _) = Client :: query :: < User > ( ) . search ( name_owned) . send ( ) . await ?;
1471+ let matched = users. into_iter ( ) . find ( |u| u. name == name) ;
1472+ let Some ( user) = matched else {
1473+ bail ! ( tl!( "collab-autocomplete-failed" , "name" => name) ) ;
1474+ } ;
1475+ // Replacement: insert `#id` right after `@name`, keep the rest of
1476+ // the original match (bracket/role if any).
1477+ let suffix = & intro[ start + 1 + name. len ( ) ..end] ;
1478+ let new_text = format ! ( "@{}#{}{}" , name, user. id, suffix) ;
1479+ resolved. push ( ( start, end, new_text) ) ;
1480+ }
1481+ // Apply right-to-left so replacement lengths don't shift pending offsets.
1482+ let mut result = intro. into_bytes ( ) ;
1483+ for ( start, end, new_text) in resolved. into_iter ( ) . rev ( ) {
1484+ result. splice ( start..end, new_text. into_bytes ( ) ) ;
1485+ }
1486+ Ok ( String :: from_utf8 ( result) . unwrap ( ) )
1487+ } ) ) ;
1488+ }
1489+
13501490 fn update_chart_info ( & self ) -> Result < ( ) > {
13511491 Self :: global_update_chart_info ( self . local_path . as_ref ( ) . unwrap ( ) , self . info . clone ( ) )
13521492 }
@@ -1524,6 +1664,7 @@ impl Scene for SongScene {
15241664 || self . update_cksum_task . is_some ( )
15251665 || self . toggle_fav_task . is_some ( )
15261666 || self . export_task . is_some ( )
1667+ || self . autocomplete_task . is_some ( )
15271668 {
15281669 return Ok ( true ) ;
15291670 }
@@ -1692,6 +1833,10 @@ impl Scene for SongScene {
16921833 if let Some ( uploader) = & self . info . uploader {
16931834 UserManager :: request ( uploader. id ) ;
16941835 }
1836+ self . collaborators = parse_collaborators ( & self . info . intro ) ;
1837+ for id in self . collaborators . keys ( ) {
1838+ UserManager :: request ( * id) ;
1839+ }
16951840 self . side_content = SideContent :: Info ;
16961841 self . side_enter_time = tm. real_time ( ) as _ ;
16971842 return Ok ( true ) ;
@@ -1748,6 +1893,30 @@ impl Scene for SongScene {
17481893 if self . side_enter_time < 0. && -tm. real_time ( ) as f32 + edit_transit ( ) . unwrap_or_default ( ) < self . side_enter_time {
17491894 self . side_enter_time = f32:: INFINITY ;
17501895 }
1896+ if CONFIRM_AUTOCOMPLETE . fetch_and ( false , Ordering :: SeqCst ) {
1897+ self . start_autocomplete ( ) ;
1898+ }
1899+ if SKIP_AUTOCOMPLETE . fetch_and ( false , Ordering :: SeqCst ) {
1900+ self . save_edit ( ) ;
1901+ }
1902+ if let Some ( task) = & mut self . autocomplete_task {
1903+ if let Some ( res) = task. take ( ) {
1904+ self . autocomplete_task = None ;
1905+ match res {
1906+ Err ( err) => {
1907+ show_error ( err) ;
1908+ }
1909+ Ok ( new_intro) => {
1910+ if let Some ( edit) = self . info_edit . as_mut ( ) {
1911+ edit. info . intro = new_intro;
1912+ edit. updated = true ;
1913+ }
1914+ show_message ( tl ! ( "collab-autocomplete-done" ) ) . duration ( 1. ) . ok ( ) ;
1915+ self . save_edit ( ) ;
1916+ }
1917+ }
1918+ }
1919+ }
17511920 if let Some ( task) = & mut self . load_task {
17521921 if let Some ( res) = task. take ( ) {
17531922 match res {
@@ -2644,6 +2813,7 @@ impl Scene for SongScene {
26442813 || self . overwrite_task . is_some ( )
26452814 || self . update_cksum_task . is_some ( )
26462815 || self . toggle_fav_task . is_some ( )
2816+ || self . autocomplete_task . is_some ( )
26472817 {
26482818 ui. full_loading ( "" , t) ;
26492819 }
0 commit comments