From e754ab84b9a18263722e8361397925500529e531 Mon Sep 17 00:00:00 2001 From: nanaya Date: Tue, 18 Feb 2025 15:36:30 +0900 Subject: [PATCH 01/15] Add team chat channel --- .../Teams/ApplicationsController.php | 7 +- .../Controllers/Teams/MembersController.php | 9 +-- app/Http/Controllers/TeamsController.php | 2 + app/Models/Chat/Channel.php | 26 +++++++ app/Models/Team.php | 67 +++++++++++++++++-- app/Singletons/OsuAuthorize.php | 7 +- database/factories/TeamFactory.php | 2 + ...5_02_18_074224_add_channel_id_to_teams.php | 27 ++++++++ ...02_18_210000_add_team_type_to_channels.php | 39 +++++++++++ resources/js/chat/conversation-list-item.tsx | 12 +++- resources/js/chat/conversation-list.tsx | 1 + resources/js/interfaces/chat/channel-json.ts | 2 +- resources/js/stores/channel-store.ts | 1 + resources/lang/en/chat.php | 1 + resources/lang/en/teams.php | 1 + resources/views/teams/show.blade.php | 6 ++ tests/Controllers/TeamsControllerTest.php | 1 + tests/Models/Chat/ChannelTest.php | 14 ++++ 18 files changed, 202 insertions(+), 23 deletions(-) create mode 100644 database/migrations/2025_02_18_074224_add_channel_id_to_teams.php create mode 100644 database/migrations/2025_02_18_210000_add_team_type_to_channels.php diff --git a/app/Http/Controllers/Teams/ApplicationsController.php b/app/Http/Controllers/Teams/ApplicationsController.php index 75c843f3463..4de17b68695 100644 --- a/app/Http/Controllers/Teams/ApplicationsController.php +++ b/app/Http/Controllers/Teams/ApplicationsController.php @@ -8,7 +8,6 @@ namespace App\Http\Controllers\Teams; use App\Http\Controllers\Controller; -use App\Jobs\Notifications\TeamApplicationAccept; use App\Jobs\Notifications\TeamApplicationReject; use App\Models\Team; use App\Models\TeamApplication; @@ -30,12 +29,8 @@ public function accept(string $teamId, string $id): Response priv_check('TeamApplicationAccept', $application)->ensureCan(); - \DB::transaction(function () use ($application, $team) { - $application->delete(); - $team->members()->create(['user_id' => $application->getKey()]); - }); + $team->addMember($application); - (new TeamApplicationAccept($application, \Auth::user()))->dispatch(); \Session::flash('popup', osu_trans('teams.applications.accept.ok')); return response(null, 204); diff --git a/app/Http/Controllers/Teams/MembersController.php b/app/Http/Controllers/Teams/MembersController.php index 3c262ced4e1..b224a5eb223 100644 --- a/app/Http/Controllers/Teams/MembersController.php +++ b/app/Http/Controllers/Teams/MembersController.php @@ -9,7 +9,6 @@ use App\Http\Controllers\Controller; use App\Models\Team; -use App\Models\TeamMember; use Symfony\Component\HttpFoundation\Response; class MembersController extends Controller @@ -27,14 +26,12 @@ public function destroy(string $teamId, string $userId): Response 'team_id' => $teamId, 'user_id' => $userId, ])->firstOrFail(); + $team = $teamMember->team; - if ($teamMember->user_id === \Auth::user()->getKey()) { - abort(422, 'can not remove self from team'); - } + priv_check('TeamUpdate', $team)->ensureCan(); - priv_check('TeamUpdate', $teamMember->team)->ensureCan(); + $team->removeMember($teamMember); - $teamMember->delete(); \Session::flash('popup', osu_trans('teams.members.destroy.success')); return response(null, 204); diff --git a/app/Http/Controllers/TeamsController.php b/app/Http/Controllers/TeamsController.php index 3c240d4c91f..0577abceec1 100644 --- a/app/Http/Controllers/TeamsController.php +++ b/app/Http/Controllers/TeamsController.php @@ -145,8 +145,10 @@ public function store(): Response $team = (new Team([...$params, 'leader_id' => $user->getKey()])); try { \DB::transaction(function () use ($team, $user) { + $channel = $team->createChannel(); $team->saveOrExplode(); $team->members()->create(['user_id' => $user->getKey()]); + $channel->addUser($user); }); } catch (ModelNotSavedException) { return ext_view('teams.create', compact('team'), status: 422); diff --git a/app/Models/Chat/Channel.php b/app/Models/Chat/Channel.php index 44866f439d9..11122e0fcf2 100644 --- a/app/Models/Chat/Channel.php +++ b/app/Models/Chat/Channel.php @@ -82,6 +82,7 @@ class Channel extends Model 'temporary' => 'TEMPORARY', 'pm' => 'PM', 'group' => 'GROUP', + 'team' => 'TEAM', ]; public static function ack(int $channelId, int $userId, ?int $timestamp = null, ?Redis $redis = null): void @@ -267,6 +268,26 @@ public function userChannels() return $this->hasMany(UserChannel::class); } + public function delete() + { + return $this->getConnection()->transaction(function () { + $this->loadMissing('userChannels.user'); + + foreach ($this->userChannels as $userChannel) { + $user = $userChannel->user; + if ($user === null) { + $userChannel->delete(); + } else { + $this->removeUser($user); + } + } + + $this->messages()->delete(); + + return parent::delete(); + }); + } + public function userIds(): array { return $this->memoize(__FUNCTION__, function () { @@ -366,6 +387,11 @@ public function isGroup() return $this->type === static::TYPES['group']; } + public function isTeam(): bool + { + return $this->type === static::TYPES['team']; + } + public function isBanchoMultiplayerChat() { return $this->type === static::TYPES['temporary'] && starts_with($this->name, ['#mp_', '#spect_']); diff --git a/app/Models/Team.php b/app/Models/Team.php index 0a7e4d3e0c6..dfa7539b495 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -7,6 +7,8 @@ namespace App\Models; +use App\Exceptions\InvariantException; +use App\Jobs\Notifications\TeamApplicationAccept; use App\Libraries\BBCodeForDB; use App\Libraries\Uploader; use App\Libraries\UsernameValidation; @@ -35,6 +37,11 @@ public function applications(): HasMany return $this->hasMany(TeamApplication::class); } + public function channel(): BelongsTo + { + return $this->belongsTo(Chat\Channel::class, 'channel_id'); + } + public function leader(): BelongsTo { return $this->belongsTo(User::class, 'leader_id'); @@ -88,13 +95,26 @@ public function setUrlAttribute(?string $value): void ); } - public function descriptionHtml(): string + public function addMember(TeamApplication $application): void { - $description = presence($this->description); + $this->getConnection()->transaction(function () use ($application) { + $application->delete(); + $this->members()->create(['user_id' => $application->getKey()]); + $this->channel->addUser($application->user); + }); - return $description === null - ? '' - : bbcode((new BBCodeForDB($description))->generate()); + (new TeamApplicationAccept($application, $this->leader))->dispatch(); + } + + public function createChannel(): Chat\Channel + { + $channel = Chat\Channel::create([ + 'type' => Chat\Channel::TYPES['team'], + 'name' => $this->name, + ]); + $this->channel()->associate($channel); + + return $channel; } public function delete() @@ -107,12 +127,22 @@ public function delete() if ($ret) { $this->members()->delete(); + $this->channel?->delete(); } return $ret; }); } + public function descriptionHtml(): string + { + $description = presence($this->description); + + return $description === null + ? '' + : bbcode((new BBCodeForDB($description))->generate()); + } + public function emptySlots(): int { $max = $this->maxMembers(); @@ -188,6 +218,33 @@ public function maxMembers(): int return min(8 + (4 * $supporterCount), $GLOBALS['cfg']['osu']['team']['max_members']); } + public function removeMember(TeamMember $member): void + { + if ($member->user_id === $this->leader_id) { + throw new InvariantException('can not remove leader from the team'); + } + + $this->getConnection()->transaction(function () use ($member) { + $member->delete(); + $user = $member->user; + if ($user !== null) { + $this->channel->removeUser($user); + } + }); + } + + public function resetChannelUsers(): void + { + $channel = $this->channel; + $this->loadMissing('members.user'); + + foreach ($this->members->pluck('user') as $user) { + if ($user !== null) { + $channel->addUser($user); + } + } + } + public function save(array $options = []) { if (!$this->isValid()) { diff --git a/app/Singletons/OsuAuthorize.php b/app/Singletons/OsuAuthorize.php index 938805d9148..64d28244781 100644 --- a/app/Singletons/OsuAuthorize.php +++ b/app/Singletons/OsuAuthorize.php @@ -48,6 +48,7 @@ public static function alwaysCheck($ability) static $set; $set ??= new Ds\Set([ + 'ChannelPart', 'ContestJudge', 'IsNotOAuth', 'IsOwnClient', @@ -1046,7 +1047,8 @@ public function checkChatChannelJoin(?User $user, Channel $channel): ?string $this->ensureCleanRecord($user, $prefix); // joining multiplayer room is done through room endpoint - if ($channel->isMultiplayer()) { + // team channel handling is done through team model + if ($channel->isMultiplayer() || $channel->isTeam()) { return null; } @@ -1070,7 +1072,8 @@ public function checkChatChannelPart(?User $user, Channel $channel): string $this->ensureLoggedIn($user); - if ($channel->type !== Channel::TYPES['private']) { + // team channel handling is done through team model + if (!$channel->isTeam() && $channel->type !== Channel::TYPES['private']) { return 'ok'; } diff --git a/database/factories/TeamFactory.php b/database/factories/TeamFactory.php index 096af18cb51..d377a871841 100644 --- a/database/factories/TeamFactory.php +++ b/database/factories/TeamFactory.php @@ -7,6 +7,7 @@ namespace Database\Factories; +use App\Models\Chat\Channel; use App\Models\Team; use App\Models\User; @@ -27,6 +28,7 @@ public function definition(): array 'name' => fn () => strtr($this->faker->unique()->userName(), '.', ' '), 'short_name' => fn () => substr(strtr($this->faker->unique()->userName(), '.', ' '), 0, 4), 'leader_id' => User::factory(), + 'channel_id' => Channel::factory(), ]; } } diff --git a/database/migrations/2025_02_18_074224_add_channel_id_to_teams.php b/database/migrations/2025_02_18_074224_add_channel_id_to_teams.php new file mode 100644 index 00000000000..0f56c002145 --- /dev/null +++ b/database/migrations/2025_02_18_074224_add_channel_id_to_teams.php @@ -0,0 +1,27 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->unsignedInteger('channel_id')->nullable(false)->after('leader_id'); + }); + } + + public function down(): void + { + Schema::table('teams', function (Blueprint $table) { + $table->dropColumn('channel_id'); + }); + } +}; diff --git a/database/migrations/2025_02_18_210000_add_team_type_to_channels.php b/database/migrations/2025_02_18_210000_add_team_type_to_channels.php new file mode 100644 index 00000000000..d02c7a0b694 --- /dev/null +++ b/database/migrations/2025_02_18_210000_add_team_type_to_channels.php @@ -0,0 +1,39 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +use Illuminate\Database\Migrations\Migration; + +class AddTeamTypeToChannels extends Migration +{ + public function up(): void + { + DB::connection('mysql-chat')->statement("ALTER TABLE `channels` MODIFY `type` ENUM( + 'PUBLIC', + 'PRIVATE', + 'MULTIPLAYER', + 'SPECTATOR', + 'TEMPORARY', + 'PM', + 'GROUP', + 'ANNOUNCE', + 'TEAM' + ) NOT NULL DEFAULT 'TEMPORARY'"); + } + + public function down() + { + DB::connection('mysql-chat')->table('channels')->where('type', 'TEAM')->update(['type' => 'TEMPORARY']); + DB::connection('mysql-chat')->statement("ALTER TABLE `channels` MODIFY `type` ENUM( + 'PUBLIC', + 'PRIVATE', + 'MULTIPLAYER', + 'SPECTATOR', + 'TEMPORARY', + 'PM', + 'GROUP', + 'ANNOUNCE' + ) NOT NULL DEFAULT 'TEMPORARY'"); + } +} diff --git a/resources/js/chat/conversation-list-item.tsx b/resources/js/chat/conversation-list-item.tsx index f9e3d365347..c1d23192d15 100644 --- a/resources/js/chat/conversation-list-item.tsx +++ b/resources/js/chat/conversation-list-item.tsx @@ -18,6 +18,10 @@ interface Props { export default class ConversationListItem extends React.Component { private readonly ref = React.createRef(); + get canPart() { + return this.props.channel.type !== 'TEAM'; + } + @computed get selected() { return this.props.channel.channelId === core.dataStore.chatState.selectedChannel?.channelId; @@ -48,9 +52,11 @@ export default class ConversationListItem extends React.Component { ?
: null} - + {this.canPart && + + }