diff --git a/app/Http/Controllers/Chat/Channels/UsersController.php b/app/Http/Controllers/Chat/Channels/UsersController.php new file mode 100644 index 00000000000..6d54103bca8 --- /dev/null +++ b/app/Http/Controllers/Chat/Channels/UsersController.php @@ -0,0 +1,48 @@ +. Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +namespace App\Http\Controllers\Chat\Channels; + +use App\Http\Controllers\Chat\Controller; +use App\Models\Chat\Channel; +use App\Models\Chat\UserChannel; +use App\Models\User; +use App\Transformers\UserCompactTransformer; + +class UsersController extends Controller +{ + public function index($channelId) + { + $channel = Channel::findOrFail($channelId); + + if (!priv_check('ChatChannelListUsers', $channel)->can()) { + return [ + 'users' => [], + ...cursor_for_response(null), + ]; + } + + $channel = Channel::findOrFail($channelId); + $cursorHelper = UserChannel::makeDbCursorHelper(); + [$userChannels, $hasMore] = $channel + ->userChannels() + ->select('user_id') + ->limit(UserChannel::PER_PAGE) + ->cursorSort($cursorHelper, cursor_from_params(\Request::all())) + ->getWithHasMore(); + $users = User + ::with(UserCompactTransformer::CARD_INCLUDES_PRELOAD) + ->find($userChannels->pluck('user_id')); + + return [ + 'users' => json_collection( + $users, + new UserCompactTransformer(), + UserCompactTransformer::CARD_INCLUDES, + ), + ...cursor_for_response($cursorHelper->next($userChannels, $hasMore)), + ]; + } +} diff --git a/app/Models/Chat/UserChannel.php b/app/Models/Chat/UserChannel.php index ac16373952a..62631b78b24 100644 --- a/app/Models/Chat/UserChannel.php +++ b/app/Models/Chat/UserChannel.php @@ -6,6 +6,7 @@ namespace App\Models\Chat; use App\Libraries\Notification\BatchIdentities; +use App\Models\Traits\WithDbCursorHelper; use App\Models\User; use App\Models\UserNotification; use DB; @@ -20,6 +21,14 @@ */ class UserChannel extends Model { + use WithDbCursorHelper; + + const DEFAULT_SORT = 'user_id_asc'; + + const SORTS = [ + 'user_id_asc' => [['column' => 'user_id', 'order' => 'ASC']], + ]; + public $incrementing = false; public $timestamps = false; diff --git a/app/Singletons/OsuAuthorize.php b/app/Singletons/OsuAuthorize.php index 9607bdf5cd2..bc880403186 100644 --- a/app/Singletons/OsuAuthorize.php +++ b/app/Singletons/OsuAuthorize.php @@ -919,6 +919,15 @@ public function checkChatChannelCanMessage(?User $user, Channel $channel): strin return 'ok'; } + public function checkChatChannelListUsers(?User $user, Channel $channel): ?string + { + if ($channel->isAnnouncement() && $this->doCheckUser($user, 'ChatAnnounce', $channel)->can()) { + return 'ok'; + } + + return null; + } + /** * TODO: always use a channel for this check? * diff --git a/app/Transformers/Chat/ChannelTransformer.php b/app/Transformers/Chat/ChannelTransformer.php index c0e636ab7c2..c80b4e2d9fd 100644 --- a/app/Transformers/Chat/ChannelTransformer.php +++ b/app/Transformers/Chat/ChannelTransformer.php @@ -60,6 +60,7 @@ public function includeCurrentUserAttributes(Channel $channel) $result = $channel->checkCanMessage($this->user); return $this->primitive([ + 'can_list_users' => priv_check_user($this->user, 'ChatChannelListUsers', $channel)->can(), 'can_message' => $result->can(), 'can_message_error' => $result->message(), 'last_read_id' => $channel->lastReadIdFor($this->user), diff --git a/resources/css/bem/chat-conversation.less b/resources/css/bem/chat-conversation.less index 903276fed0d..dc4bf178616 100644 --- a/resources/css/bem/chat-conversation.less +++ b/resources/css/bem/chat-conversation.less @@ -66,6 +66,12 @@ margin-top: 10px; } + &__more-users { + width: 100%; + display: flex; + justify-content: center; + } + &__unread-marker { border-bottom: 1px solid @osu-colour-h1; color: @osu-colour-h1; @@ -106,10 +112,18 @@ gap: 2px; align-content: center; justify-content: center; - margin: 10px; &--loading { gap: 5px; } } + + &__users-container { + .default-border-radius(); + background-color: hsl(var(--hsl-b5)); + padding: 5px; + display: grid; + gap: 5px; + margin: 10px; + } } diff --git a/resources/js/chat/chat-api.ts b/resources/js/chat/chat-api.ts index f4d568db0fc..78974cd1297 100644 --- a/resources/js/chat/chat-api.ts +++ b/resources/js/chat/chat-api.ts @@ -16,6 +16,11 @@ interface GetChannelResponse { users: UserJson[]; } +interface GetChannelUsersResponse { + cursor_string: null | string; + users: UserJson[]; +} + interface GetMessagesResponse { messages: MessageJson[]; users: UserJson[]; @@ -56,6 +61,13 @@ export function getChannel(channelId: number) { })); } +export function getChannelUsers(channelId: number, cursor: string) { + return $.get(route('chat.channels.users.index', { + channel: channelId, + cursor_string: cursor, + })) as JQuery.jqXHR; +} + export function getMessages(channelId: number, params?: { until?: number }) { const request = $.get(route('chat.channels.messages.index', { channel: channelId, return_object: 1, ...params })) as JQuery.jqXHR; diff --git a/resources/js/chat/conversation-view.tsx b/resources/js/chat/conversation-view.tsx index 15c1dfaf53f..15fb5ba3290 100644 --- a/resources/js/chat/conversation-view.tsx +++ b/resources/js/chat/conversation-view.tsx @@ -259,16 +259,33 @@ export default class ConversationView extends React.Component { renderUsers() { if (this.currentChannel?.type !== 'ANNOUNCE') return null; + const users = this.currentChannel.users; + + if (users != null && users.length === 0) { + return null; + } + return ( -
- {this.currentChannel.announcementUsers == null ? ( - <> - {trans('chat.loading_users')} - - ) : ( - this.currentChannel.announcementUsers.map((user) => ( - - )) +
+
+ {users == null ? ( + <> + {trans('chat.loading_users')} + + ) : ( + users.map((user) => ( + + )) + )} +
+ {users != null && this.currentChannel.usersCursor != null && ( +
+ +
)}
); diff --git a/resources/js/interfaces/chat/channel-json.ts b/resources/js/interfaces/chat/channel-json.ts index 503744ddde3..4d352aed358 100644 --- a/resources/js/interfaces/chat/channel-json.ts +++ b/resources/js/interfaces/chat/channel-json.ts @@ -14,6 +14,7 @@ export function filterSupportedChannelTypes(json: ChannelJson[]) { export default interface ChannelJson { channel_id: number; current_user_attributes?: { + can_list_users: boolean; can_message: boolean; can_message_error: string | null; last_read_id: number | null; diff --git a/resources/js/models/chat/channel.ts b/resources/js/models/chat/channel.ts index 0d0972db99d..a6e307ed445 100644 --- a/resources/js/models/chat/channel.ts +++ b/resources/js/models/chat/channel.ts @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -import { markAsRead, getChannel, getMessages } from 'chat/chat-api'; +import { markAsRead, getChannel, getChannelUsers, getMessages } from 'chat/chat-api'; import ChannelJson, { ChannelType, SupportedChannelType, supportedTypeLookup } from 'interfaces/chat/channel-json'; import MessageJson from 'interfaces/chat/message-json'; +import UserJson from 'interfaces/user-json'; import { sortBy, throttle } from 'lodash'; import { action, computed, makeObservable, observable, runInAction } from 'mobx'; -import User, { usernameSortAscending } from 'models/user'; +import User from 'models/user'; import core from 'osu-core-singleton'; import Message from './message'; @@ -29,6 +30,7 @@ function getMinMessageIdFrom(messages: Message[]) { export default class Channel { private static readonly defaultIcon = '/images/layout/chat/channel-default.png'; // TODO: update with channel-specific icons? + @observable canListUsers: boolean = false; @observable canMessageError: string | null = null; @observable channelId: number; @observable description?: string; @@ -38,6 +40,7 @@ export default class Channel { @observable lastReadId?: number; @observable loadingEarlierMessages = false; @observable loadingMessages = false; + @observable loadUsersXhr: ReturnType | undefined; @observable messageLengthLimit = maxMessageLength; @observable name = ''; needsRefresh = true; @@ -49,21 +52,12 @@ export default class Channel { scrollY: 0, }; @observable userIds: number[] = []; + @observable users: null | UserJson[] = null; + @observable usersCursor: null | string = ''; private markAsReadLastSent = 0; @observable private readonly messagesMap = new Map(); private serverLastMessageId?: number; - @observable private usersLoaded = false; - - @computed - get announcementUsers() { - return this.usersLoaded - ? this.userIds - .map((userId) => core.dataStore.userStore.get(userId)) - .filter((u): u is User => u != null) - .sort(usernameSortAscending) - : null; - } @computed get canMessage() { @@ -204,10 +198,7 @@ export default class Channel { // nothing to load if (this.newPmChannel) return; - if (this.type === 'ANNOUNCE' && !this.usersLoaded) { - this.loadMetadata(); - } - + this.loadUsers(); this.loadRecentMessages(); } @@ -246,11 +237,31 @@ export default class Channel { getChannel(this.channelId).done((json) => { runInAction(() => { this.updateWithJson(json); - this.usersLoaded = true; }); }); } + @action + readonly loadUsers = () => { + if (!this.canListUsers) { + this.users = []; + this.usersCursor = null; + return; + } + + if (this.usersCursor == null || this.loadUsersXhr != null) { + return; + } + + this.loadUsersXhr = getChannelUsers(this.channelId, this.usersCursor) + .done((json) => runInAction(() => { + this.users = [...(this.users ?? []), ...json.users]; + this.usersCursor = json.cursor_string; + })).always(action(() => { + this.loadUsersXhr = undefined; + })); + }; + @action moveMarkAsReadMarker() { this.setLastReadId(this.lastMessageId); @@ -282,6 +293,7 @@ export default class Channel { this.serverLastMessageId = json.last_message_id; if (json.current_user_attributes != null) { + this.canListUsers = json.current_user_attributes.can_list_users; this.canMessageError = json.current_user_attributes.can_message_error; const lastReadId = json.current_user_attributes.last_read_id ?? 0; this.setLastReadId(lastReadId); diff --git a/routes/web.php b/routes/web.php index 06070decb2c..9d1f9cb1712 100644 --- a/routes/web.php +++ b/routes/web.php @@ -187,6 +187,7 @@ Route::get('updates', 'ChatController@updates')->name('updates'); Route::group(['as' => 'channels.', 'prefix' => 'channels'], function () { Route::apiResource('{channel}/messages', 'Channels\MessagesController', ['only' => ['index', 'store']]); + Route::apiResource('{channel}/users', 'Channels\UsersController', ['only' => ['index']]); Route::put('{channel}/users/{user}', 'ChannelsController@join')->name('join'); Route::delete('{channel}/users/{user}', 'ChannelsController@part')->name('part'); Route::put('{channel}/mark-as-read/{message}', 'ChannelsController@markAsRead')->name('mark-as-read');