@@ -49,30 +49,74 @@ const { height: viewportHeight } = useVisualViewport();
4949 >
5050 <div class =" flex-1 overflow-y-auto min-h-0 p-4 flex flex-col" >
5151 <div class =" flex-1" />
52- <div
53- v-if =" !players?.length"
54- class =" p-4 text-center text-muted-foreground"
55- >
56- {{ $t("player.search.no_players_found") }}
57- </div >
5852
59- <div v-else class =" divide-y" >
53+ <!-- Grouped: Friends / Others -->
54+ <template v-if =" groupByFriends " >
6055 <div
61- v-for =" player in players"
62- :key =" `player-${player.steam_id}}`"
63- class =" px-3 py-2 hover:bg-accent cursor-pointer"
64- @click =" select(player)"
56+ v-if =" !hasGroupResults"
57+ class =" p-4 text-center text-muted-foreground"
6558 >
66- < PlayerDisplay : player = " player " />
59+ {{ $t( "player.search.no_players_found") }}
6760 </div >
68- </div >
61+ <template v-for =" group in playerGroups " :key =" group .key " >
62+ <div v-if =" group.players.length" >
63+ <div
64+ class =" sticky top-0 z-20 flex items-center gap-2 border-b border-border bg-background px-3 py-2 font-mono text-[0.6rem] font-bold uppercase tracking-[0.18em] text-muted-foreground"
65+ >
66+ <span class =" h-[2px] w-2 bg-[hsl(var(--tac-amber))]" />
67+ {{ group.label }}
68+ <span
69+ class =" ml-auto tabular-nums text-[hsl(var(--tac-amber))]"
70+ >
71+ {{ group.players.length }}
72+ </span >
73+ </div >
74+ <div class =" divide-y" >
75+ <div
76+ v-for =" player in group.players"
77+ :key =" `g-${group.key}-${player.steam_id}`"
78+ class =" px-3 py-2 hover:bg-accent cursor-pointer"
79+ @click =" select(player)"
80+ >
81+ <PlayerDisplay :player =" player " />
82+ </div >
83+ </div >
84+ </div >
85+ </template >
86+ </template >
87+
88+ <template v-else >
89+ <div
90+ v-if =" !displayPlayers.length"
91+ class =" p-4 text-center text-muted-foreground"
92+ >
93+ {{ $t("player.search.no_players_found") }}
94+ </div >
95+
96+ <div v-else class =" divide-y" >
97+ <div
98+ v-for =" player in displayPlayers"
99+ :key =" `player-${player.steam_id}}`"
100+ class =" px-3 py-2 hover:bg-accent cursor-pointer"
101+ @click =" select(player)"
102+ >
103+ <PlayerDisplay :player =" player " />
104+ </div >
105+ </div >
106+ </template >
69107 </div >
70108
71109 <div
72- v-if =" players? .length"
110+ v-if =" groupByFriends ? hasGroupResults : displayPlayers .length"
73111 class =" px-4 py-2 text-xs text-muted-foreground border-t"
74112 >
75- {{ players.length }} {{ $t("player.search.found_players") }}
113+ <template v-if =" groupByFriends " >
114+ {{ playerGroups[0].players.length + playerGroups[1].players.length }}
115+ {{ $t("player.search.found_players") }}
116+ </template >
117+ <template v-else >
118+ {{ displayPlayers.length }} {{ $t("player.search.found_players") }}
119+ </template >
76120 </div >
77121
78122 <div class =" flex items-center justify-between p-4 border-t" >
@@ -158,29 +202,67 @@ const { height: viewportHeight } = useVisualViewport();
158202 </div >
159203
160204 <div class =" max-h-[300px] overflow-y-auto" >
161- <div
162- v-if =" !players?.length"
163- class =" p-4 text-center text-muted-foreground"
164- >
165- {{ $t("player.search.no_players_found") }}
166- </div >
205+ <!-- Grouped: Friends / Others -->
206+ <template v-if =" groupByFriends " >
207+ <div
208+ v-if =" !hasGroupResults"
209+ class =" p-4 text-center text-muted-foreground"
210+ >
211+ {{ $t("player.search.no_players_found") }}
212+ </div >
213+ <template v-for =" group in playerGroups " :key =" group .key " >
214+ <div v-if =" group.players.length" >
215+ <div
216+ class =" sticky top-0 z-20 flex items-center gap-2 border-b border-border bg-popover px-3 py-2 font-mono text-[0.6rem] font-bold uppercase tracking-[0.18em] text-muted-foreground"
217+ >
218+ <span class =" h-[2px] w-2 bg-[hsl(var(--tac-amber))]" />
219+ {{ group.label }}
220+ <span
221+ class =" ml-auto tabular-nums text-[hsl(var(--tac-amber))]"
222+ >
223+ {{ group.players.length }}
224+ </span >
225+ </div >
226+ <div class =" divide-y" >
227+ <div
228+ v-for =" player in group.players"
229+ :key =" `g-${group.key}-${player.steam_id}`"
230+ class =" px-3 py-2 hover:bg-accent cursor-pointer"
231+ @click =" select(player)"
232+ >
233+ <PlayerDisplay :player =" player " />
234+ </div >
235+ </div >
236+ </div >
237+ </template >
238+ </template >
167239
168- <div v-else >
169- <div class =" px-3 py-2 text-sm text-muted-foreground" >
170- {{ players.length }} {{ $t("player.search.found_players") }}
240+ <template v-else >
241+ <div
242+ v-if =" !displayPlayers.length"
243+ class =" p-4 text-center text-muted-foreground"
244+ >
245+ {{ $t("player.search.no_players_found") }}
171246 </div >
172247
173- <div class =" divide-y" >
174- <div
175- v-for =" player in players"
176- :key =" `player-${player.steam_id}}`"
177- class =" px-3 py-2 hover:bg-accent cursor-pointer"
178- @click =" select(player)"
179- >
180- <PlayerDisplay :player =" player " />
248+ <div v-else >
249+ <div class =" px-3 py-2 text-sm text-muted-foreground" >
250+ {{ displayPlayers.length }}
251+ {{ $t("player.search.found_players") }}
252+ </div >
253+
254+ <div class =" divide-y" >
255+ <div
256+ v-for =" player in displayPlayers"
257+ :key =" `player-${player.steam_id}}`"
258+ class =" px-3 py-2 hover:bg-accent cursor-pointer"
259+ @click =" select(player)"
260+ >
261+ <PlayerDisplay :player =" player " />
262+ </div >
181263 </div >
182264 </div >
183- </div >
265+ </template >
184266 </div >
185267 </div >
186268 </PopoverContent >
@@ -245,6 +327,11 @@ export default {
245327 required: false ,
246328 default: false ,
247329 },
330+ groupByFriends: {
331+ type: Boolean ,
332+ required: false ,
333+ default: false ,
334+ },
248335 },
249336 data() {
250337 return {
@@ -263,6 +350,44 @@ export default {
263350 canSelectSelf() {
264351 return this .self && this .me && ! this .exclude .includes (this .me .steam_id );
265352 },
353+ // The current user, surfaced as a selectable entry (the online presence
354+ // list never contains yourself). Hidden once you're in `exclude`, i.e.
355+ // already in a lineup, and filtered by the active query.
356+ selfPlayer(): Player | null {
357+ if (! this .canSelectSelf || ! this .me ) return null ;
358+ const me = this .me as any ;
359+ const q = this .query .toLowerCase ();
360+ if (
361+ q &&
362+ ! (
363+ me .name ?.toLowerCase ().includes (q ) ||
364+ String (me .steam_id ).includes (this .query )
365+ )
366+ ) {
367+ return null ;
368+ }
369+ return {
370+ steam_id: me .steam_id ,
371+ name: me .name ,
372+ avatar_url: me .avatar_url ,
373+ country: me .country ,
374+ role: me .role ,
375+ is_banned: me .is_banned ,
376+ is_muted: me .is_muted ,
377+ is_gagged: me .is_gagged ,
378+ elo: me .elo ,
379+ } as Player ;
380+ },
381+ // Non-grouped results with `me` pinned to the top when selectable.
382+ displayPlayers(): Player [] {
383+ const base = this .players ?? [];
384+ if (! this .selfPlayer ) return base as Player [];
385+ const meId = String (this .me ?.steam_id );
386+ return [
387+ this .selfPlayer ,
388+ ... base .filter ((p : Player ) => String (p .steam_id ) !== meId ),
389+ ];
390+ },
266391 onlineOnly: {
267392 get() {
268393 return useSearchStore ().onlineOnly ;
@@ -272,6 +397,72 @@ export default {
272397 useSearchStore ().onlineOnly = value ;
273398 },
274399 },
400+ friendIds(): Set <string > {
401+ return new Set (
402+ (useMatchmakingStore ().friends as any [])
403+ .filter ((f : any ) => f .status !== " Pending" )
404+ .map ((f : any ) => String (f .steam_id )),
405+ );
406+ },
407+ // Friends list, filtered by query/exclude/self and sorted online-first.
408+ // The online toggle applies here too: when on, only online friends show;
409+ // when off, all friends (online + offline). Built from the full friends
410+ // list so offline friends reliably appear when the toggle is off.
411+ friendsForSearch(): Player [] {
412+ if (! this .groupByFriends ) return [];
413+ const store = useMatchmakingStore ();
414+ const onlineIds = new Set (
415+ (store .onlinePlayerSteamIds as string []).map (String ),
416+ );
417+ const q = this .query .toLowerCase ();
418+ const excluded = new Set ((this .exclude as string []).map (String ));
419+ const meId = String (this .me ?.steam_id ?? " " );
420+
421+ return (store .friends as any [])
422+ .filter ((f : any ) => {
423+ if (f .status === " Pending" ) return false ;
424+ const id = String (f .steam_id );
425+ if (excluded .has (id )) return false ;
426+ if (! this .canSelectSelf && id === meId ) return false ;
427+ // Strictly respect the toggle: online-only -> only online friends,
428+ // otherwise -> only offline friends.
429+ const online = onlineIds .has (id );
430+ if (this .onlineOnly !== online ) return false ;
431+ if (! q ) return true ;
432+ return f .name ?.toLowerCase ().includes (q ) || id .includes (this .query );
433+ })
434+ .sort ((a : any , b : any ) =>
435+ (a .name || " " ).localeCompare (b .name || " " ),
436+ );
437+ },
438+ // Normal search results, minus anyone already shown in the Friends section.
439+ otherPlayers(): Player [] {
440+ const meId = this .selfPlayer ? String (this .me ?.steam_id ) : null ;
441+ return (this .players ?? []).filter (
442+ (p : Player ) =>
443+ ! this .friendIds .has (String (p .steam_id )) &&
444+ (meId === null || String (p .steam_id ) !== meId ),
445+ );
446+ },
447+ playerGroups(): Array <{ key: string ; label: string ; players: Player [] }> {
448+ return [
449+ {
450+ key: " friends" ,
451+ label: this .$t (" matchmaking.friends.title" ),
452+ players: this .selfPlayer
453+ ? [this .selfPlayer , ... this .friendsForSearch ]
454+ : this .friendsForSearch ,
455+ },
456+ {
457+ key: " others" ,
458+ label: this .$t (" matchmaking.others.title" ),
459+ players: this .otherPlayers ,
460+ },
461+ ];
462+ },
463+ hasGroupResults(): boolean {
464+ return this .playerGroups .some ((g ) => g .players .length > 0 );
465+ },
275466 },
276467 methods: {
277468 toggleOnlineOnly() {
0 commit comments