diff --git a/backend/app/Http/Controllers/Api/V1/PlatformChannelsController.php b/backend/app/Http/Controllers/Api/V1/PlatformChannelsController.php index bdd1561..93b2de5 100644 --- a/backend/app/Http/Controllers/Api/V1/PlatformChannelsController.php +++ b/backend/app/Http/Controllers/Api/V1/PlatformChannelsController.php @@ -5,11 +5,14 @@ use Domains\Platform\Resources\PlatformChannelResource; use Domains\Platform\Models\PlatformChannel; use Domains\Platform\Models\PlatformAccount; +use Domains\Platform\Models\PlatformInstance; use Domains\Platform\Services\ChannelLanguageDetectionService; +use Domains\Platform\Api\Lemmy\LemmyApiService; use Domains\Platform\Exceptions\ChannelException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; +use Illuminate\Support\Facades\Cache; class PlatformChannelsController extends BaseController { @@ -271,4 +274,108 @@ public function updateAccountRelation(PlatformChannel $channel, PlatformAccount return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500); } } + + /** + * Get available communities for a platform instance + */ + public function getCommunities(Request $request): JsonResponse + { + try { + $validated = $request->validate([ + 'platform_instance_id' => 'required|exists:platform_instances,id', + 'type' => 'sometimes|string|in:Local,All,Subscribed', + 'sort' => 'sometimes|string|in:Hot,Active,New,TopDay,TopWeek,TopMonth,TopYear,TopAll', + 'limit' => 'sometimes|integer|min:1|max:100', + 'page' => 'sometimes|integer|min:1', + 'show_nsfw' => 'sometimes|boolean', + ]); + + $platformInstance = PlatformInstance::findOrFail($validated['platform_instance_id']); + + // Check if there are active platform accounts for this instance to get auth token + $activeAccount = PlatformAccount::where('instance_url', $platformInstance->url) + ->where('is_active', true) + ->first(); + + if (!$activeAccount) { + return $this->sendError( + 'Cannot fetch communities: No active platform accounts found for this instance. Please create a platform account first.', + [], + 422 + ); + } + + // Create cache key based on instance and parameters + $cacheKey = sprintf( + 'communities:%s:%s:%s:%d:%d:%s', + $platformInstance->id, + $validated['type'] ?? 'Local', + $validated['sort'] ?? 'Active', + $validated['limit'] ?? 50, + $validated['page'] ?? 1, + $validated['show_nsfw'] ?? false ? '1' : '0' + ); + + // Try to get communities from cache first (cache for 10 minutes) + $communities = Cache::remember($cacheKey, 600, function () use ($platformInstance, $activeAccount, $validated) { + $apiService = app(LemmyApiService::class, ['instance' => $platformInstance->url]); + + return $apiService->listCommunities( + $activeAccount->settings['api_token'] ?? null, + $validated['type'] ?? 'Local', + $validated['sort'] ?? 'Active', + $validated['limit'] ?? 50, + $validated['page'] ?? 1, + $validated['show_nsfw'] ?? false + ); + }); + + // Transform the response to include only relevant data and add helpful fields + $transformedCommunities = collect($communities['communities'] ?? [])->map(function ($item) { + $community = $item['community'] ?? []; + return [ + 'id' => $community['id'] ?? null, + 'name' => $community['name'] ?? null, + 'title' => $community['title'] ?? null, + 'description' => $community['description'] ?? null, + 'nsfw' => $community['nsfw'] ?? false, + 'local' => $community['local'] ?? false, + 'subscribers' => $item['counts']['subscribers'] ?? 0, + 'posts' => $item['counts']['posts'] ?? 0, + 'display_text' => sprintf( + '%s (%s subscribers)', + $community['title'] ?? $community['name'] ?? 'Unknown', + number_format($item['counts']['subscribers'] ?? 0) + ), + ]; + }); + + return $this->sendResponse([ + 'communities' => $transformedCommunities, + 'total' => $transformedCommunities->count(), + 'platform_instance' => [ + 'id' => $platformInstance->id, + 'name' => $platformInstance->name, + 'url' => $platformInstance->url, + ], + 'parameters' => [ + 'type' => $validated['type'] ?? 'Local', + 'sort' => $validated['sort'] ?? 'Active', + 'limit' => $validated['limit'] ?? 50, + 'page' => $validated['page'] ?? 1, + 'show_nsfw' => $validated['show_nsfw'] ?? false, + ] + ], 'Communities retrieved successfully.'); + + } catch (ValidationException $e) { + return $this->sendValidationError($e->errors()); + } catch (\Exception $e) { + // Clear cache on error to prevent serving stale data + if (isset($cacheKey)) { + Cache::forget($cacheKey); + } + + return $this->sendError('Failed to fetch communities: ' . $e->getMessage(), [], 500); + } + } } \ No newline at end of file diff --git a/backend/routes/api.php b/backend/routes/api.php index 659ba04..bd622aa 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -57,6 +57,10 @@ ->name('api.platform-accounts.set-active'); // Platform Channels + // NOTE: Specific routes must come before resource routes to avoid parameter conflicts + Route::get('/platform-channels/communities', [PlatformChannelsController::class, 'getCommunities']) + ->name('api.platform-channels.get-communities'); + Route::apiResource('platform-channels', PlatformChannelsController::class)->names([ 'index' => 'api.platform-channels.index', 'store' => 'api.platform-channels.store', diff --git a/backend/src/Domains/Platform/Api/Lemmy/LemmyApiService.php b/backend/src/Domains/Platform/Api/Lemmy/LemmyApiService.php index ab2a617..ad8ce4a 100644 --- a/backend/src/Domains/Platform/Api/Lemmy/LemmyApiService.php +++ b/backend/src/Domains/Platform/Api/Lemmy/LemmyApiService.php @@ -246,4 +246,70 @@ public function getLanguages(): array return []; } } + + /** + * List communities on the instance with optional filtering + * + * @param string|null $token + * @param string $type Local, All, or Subscribed + * @param string $sort Hot, Active, New, TopDay, TopWeek, TopMonth, TopYear, TopAll + * @param int $limit Maximum number of communities to return (default: 50) + * @param int $page Page number for pagination (default: 1) + * @param bool $showNsfw Whether to include NSFW communities (default: false) + * @return array + * @throws Exception + */ + public function listCommunities( + ?string $token = null, + string $type = 'Local', + string $sort = 'Active', + int $limit = 50, + int $page = 1, + bool $showNsfw = false + ): array { + try { + $request = new LemmyRequest($this->instance, $token); + + $params = [ + 'type_' => $type, + 'sort' => $sort, + 'limit' => $limit, + 'page' => $page, + 'show_nsfw' => $showNsfw, + ]; + + $response = $request->get('community/list', $params); + + if (!$response->successful()) { + $statusCode = $response->status(); + $responseBody = $response->body(); + + logger()->warning('Failed to fetch communities list', [ + 'status' => $statusCode, + 'response' => $responseBody, + 'params' => $params + ]); + + throw new Exception("Failed to fetch communities list: {$statusCode} - {$responseBody}"); + } + + $data = $response->json(); + + if (!isset($data['communities'])) { + logger()->warning('Invalid communities list response format', ['response' => $data]); + return ['communities' => []]; + } + + return $data; + } catch (Exception $e) { + logger()->error('Exception while fetching communities list', [ + 'error' => $e->getMessage(), + 'type' => $type, + 'sort' => $sort, + 'limit' => $limit, + 'page' => $page + ]); + throw $e; + } + } } diff --git a/backend/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php b/backend/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php index befd09f..c06704b 100644 --- a/backend/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php +++ b/backend/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php @@ -6,8 +6,10 @@ use Domains\Platform\Models\PlatformChannel; use Domains\Platform\Models\PlatformInstance; use Domains\Platform\Services\ChannelLanguageDetectionService; +use Domains\Platform\Api\Lemmy\LemmyApiService; use Domains\Settings\Models\Language; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use Tests\TestCase; use Mockery; @@ -270,4 +272,390 @@ public function test_toggle_deactivates_active_channel(): void 'is_active' => false ]); } + + public function test_get_communities_returns_successful_response(): void + { + // Arrange + $instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']); + $account = PlatformAccount::factory()->create([ + 'instance_url' => $instance->url, + 'is_active' => true, + 'settings' => ['api_token' => 'test-token'] + ]); + + // Mock LemmyApiService + $mockApiService = Mockery::mock(LemmyApiService::class); + $mockApiService->shouldReceive('listCommunities') + ->with('test-token', 'Local', 'Active', 50, 1, false) + ->once() + ->andReturn([ + 'communities' => [ + [ + 'community' => [ + 'id' => 1, + 'name' => 'technology', + 'title' => 'Technology', + 'description' => 'Tech discussions', + 'nsfw' => false, + 'local' => true + ], + 'counts' => [ + 'subscribers' => 1500, + 'posts' => 250 + ] + ], + [ + 'community' => [ + 'id' => 2, + 'name' => 'news', + 'title' => 'News', + 'description' => 'Latest news', + 'nsfw' => false, + 'local' => true + ], + 'counts' => [ + 'subscribers' => 2300, + 'posts' => 450 + ] + ] + ] + ]); + + $this->app->bind(LemmyApiService::class, function () use ($mockApiService) { + return $mockApiService; + }); + + // Act + $response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id); + + // Assert + $response->assertStatus(200) + ->assertJsonStructure([ + 'success', + 'message', + 'data' => [ + 'communities' => [ + '*' => [ + 'id', + 'name', + 'title', + 'description', + 'nsfw', + 'local', + 'subscribers', + 'posts', + 'display_text' + ] + ], + 'total', + 'platform_instance' => [ + 'id', + 'name', + 'url' + ], + 'parameters' => [ + 'type', + 'sort', + 'limit', + 'page', + 'show_nsfw' + ] + ] + ]) + ->assertJson([ + 'success' => true, + 'message' => 'Communities retrieved successfully.', + 'data' => [ + 'communities' => [ + [ + 'id' => 1, + 'name' => 'technology', + 'title' => 'Technology', + 'subscribers' => 1500, + 'display_text' => 'Technology (1,500 subscribers)' + ], + [ + 'id' => 2, + 'name' => 'news', + 'title' => 'News', + 'subscribers' => 2300, + 'display_text' => 'News (2,300 subscribers)' + ] + ], + 'total' => 2, + 'parameters' => [ + 'type' => 'Local', + 'sort' => 'Active', + 'limit' => 50, + 'page' => 1, + 'show_nsfw' => false + ] + ] + ]); + } + + public function test_get_communities_with_custom_parameters(): void + { + // Arrange + $instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']); + $account = PlatformAccount::factory()->create([ + 'instance_url' => $instance->url, + 'is_active' => true, + 'settings' => ['api_token' => 'test-token'] + ]); + + // Mock LemmyApiService with custom parameters + $mockApiService = Mockery::mock(LemmyApiService::class); + $mockApiService->shouldReceive('listCommunities') + ->with('test-token', 'All', 'TopMonth', 25, 2, true) + ->once() + ->andReturn(['communities' => []]); + + $this->app->bind(LemmyApiService::class, function () use ($mockApiService) { + return $mockApiService; + }); + + // Act + $response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([ + 'platform_instance_id' => $instance->id, + 'type' => 'All', + 'sort' => 'TopMonth', + 'limit' => 25, + 'page' => 2, + 'show_nsfw' => true + ])); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'parameters' => [ + 'type' => 'All', + 'sort' => 'TopMonth', + 'limit' => 25, + 'page' => 2, + 'show_nsfw' => true + ] + ] + ]); + } + + public function test_get_communities_validates_required_platform_instance_id(): void + { + $response = $this->getJson('/api/v1/platform-channels/communities'); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['platform_instance_id']); + } + + public function test_get_communities_validates_platform_instance_exists(): void + { + $response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=999'); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['platform_instance_id']); + } + + public function test_get_communities_validates_type_parameter(): void + { + $instance = PlatformInstance::factory()->create(); + + $response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([ + 'platform_instance_id' => $instance->id, + 'type' => 'InvalidType' + ])); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['type']); + } + + public function test_get_communities_validates_sort_parameter(): void + { + $instance = PlatformInstance::factory()->create(); + + $response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([ + 'platform_instance_id' => $instance->id, + 'sort' => 'InvalidSort' + ])); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['sort']); + } + + public function test_get_communities_validates_limit_parameter(): void + { + $instance = PlatformInstance::factory()->create(); + + $response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([ + 'platform_instance_id' => $instance->id, + 'limit' => 0 // Invalid: below minimum + ])); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['limit']); + + $response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([ + 'platform_instance_id' => $instance->id, + 'limit' => 101 // Invalid: above maximum + ])); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['limit']); + } + + public function test_get_communities_validates_page_parameter(): void + { + $instance = PlatformInstance::factory()->create(); + + $response = $this->getJson('/api/v1/platform-channels/communities?' . http_build_query([ + 'platform_instance_id' => $instance->id, + 'page' => 0 // Invalid: below minimum + ])); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['page']); + } + + public function test_get_communities_fails_when_no_active_account_exists(): void + { + $instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']); + + // Create an inactive account + PlatformAccount::factory()->create([ + 'instance_url' => $instance->url, + 'is_active' => false + ]); + + $response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id); + + $response->assertStatus(422) + ->assertJson([ + 'success' => false, + 'message' => 'Cannot fetch communities: No active platform accounts found for this instance. Please create a platform account first.' + ]); + } + + public function test_get_communities_uses_cache(): void + { + // Arrange + $instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']); + $account = PlatformAccount::factory()->create([ + 'instance_url' => $instance->url, + 'is_active' => true, + 'settings' => ['api_token' => 'test-token'] + ]); + + $mockData = ['communities' => [['community' => ['id' => 1, 'name' => 'test']]]]; + $cacheKey = "communities:{$instance->id}:Local:Active:50:1:0"; + + // Set cache data + Cache::put($cacheKey, $mockData, 600); + + // Mock should not be called since cache hit + $mockApiService = Mockery::mock(LemmyApiService::class); + $mockApiService->shouldNotReceive('listCommunities'); + + $this->app->bind(LemmyApiService::class, function () use ($mockApiService) { + return $mockApiService; + }); + + // Act + $response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id); + + // Assert + $response->assertStatus(200) + ->assertJson(['success' => true]); + } + + public function test_get_communities_clears_cache_on_error(): void + { + // Arrange + $instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']); + $account = PlatformAccount::factory()->create([ + 'instance_url' => $instance->url, + 'is_active' => true, + 'settings' => ['api_token' => 'test-token'] + ]); + + $cacheKey = "communities:{$instance->id}:Local:Active:50:1:0"; + + // Mock LemmyApiService to throw exception + $mockApiService = Mockery::mock(LemmyApiService::class); + $mockApiService->shouldReceive('listCommunities') + ->once() + ->andThrow(new \Exception('API Error')); + + $this->app->bind(LemmyApiService::class, function () use ($mockApiService) { + return $mockApiService; + }); + + // Ensure cache is empty initially so API service gets called + Cache::forget($cacheKey); + $this->assertFalse(Cache::has($cacheKey)); + + // Act + $response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id); + + // Assert + $response->assertStatus(500); + // Cache should still not exist since the error prevented caching + $this->assertFalse(Cache::has($cacheKey)); + } + + public function test_get_communities_handles_missing_community_data_gracefully(): void + { + // Arrange + $instance = PlatformInstance::factory()->create(['url' => 'lemmy.world']); + $account = PlatformAccount::factory()->create([ + 'instance_url' => $instance->url, + 'is_active' => true, + 'settings' => ['api_token' => 'test-token'] + ]); + + // Mock LemmyApiService with incomplete community data + $mockApiService = Mockery::mock(LemmyApiService::class); + $mockApiService->shouldReceive('listCommunities') + ->once() + ->andReturn([ + 'communities' => [ + [ + 'community' => [ + 'id' => 1, + 'name' => 'minimal', + // Missing title, description, etc. + ], + // Missing counts + ] + ] + ]); + + $this->app->bind(LemmyApiService::class, function () use ($mockApiService) { + return $mockApiService; + }); + + // Act + $response = $this->getJson('/api/v1/platform-channels/communities?platform_instance_id=' . $instance->id); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'communities' => [ + [ + 'id' => 1, + 'name' => 'minimal', + 'title' => null, + 'description' => null, + 'nsfw' => false, + 'local' => false, + 'subscribers' => 0, + 'posts' => 0, + 'display_text' => 'minimal (0 subscribers)' + ] + ] + ] + ]); + } } \ No newline at end of file diff --git a/backend/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php b/backend/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php index 5cf513f..38de38a 100644 --- a/backend/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php +++ b/backend/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php @@ -426,4 +426,260 @@ public function test_get_languages_returns_empty_when_all_languages_missing(): v $this->assertEquals([], $languages); } + + public function test_list_communities_success_with_default_parameters(): void + { + Http::fake([ + '*' => Http::response([ + 'communities' => [ + [ + 'community' => [ + 'id' => 1, + 'name' => 'technology', + 'title' => 'Technology Community', + 'description' => 'All about tech', + 'nsfw' => false + ] + ], + [ + 'community' => [ + 'id' => 2, + 'name' => 'news', + 'title' => 'News Community', + 'description' => 'Latest news', + 'nsfw' => false + ] + ] + ] + ], 200) + ]); + + $service = new LemmyApiService('lemmy.world'); + $result = $service->listCommunities(); + + $this->assertArrayHasKey('communities', $result); + $this->assertCount(2, $result['communities']); + $this->assertEquals('technology', $result['communities'][0]['community']['name']); + $this->assertEquals('news', $result['communities'][1]['community']['name']); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/api/v3/community/list') + && str_contains($request->url(), 'type_=Local') + && str_contains($request->url(), 'sort=Active') + && str_contains($request->url(), 'limit=50') + && str_contains($request->url(), 'page=1') + && str_contains($request->url(), 'show_nsfw='); + }); + } + + public function test_list_communities_success_with_custom_parameters(): void + { + Http::fake([ + '*' => Http::response([ + 'communities' => [ + [ + 'community' => [ + 'id' => 3, + 'name' => 'gaming', + 'title' => 'Gaming Community', + 'description' => 'Gaming discussions', + 'nsfw' => false + ] + ] + ] + ], 200) + ]); + + $service = new LemmyApiService('lemmy.world'); + $result = $service->listCommunities( + 'test-token', + 'All', + 'TopMonth', + 25, + 2, + true + ); + + $this->assertArrayHasKey('communities', $result); + $this->assertCount(1, $result['communities']); + $this->assertEquals('gaming', $result['communities'][0]['community']['name']); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/api/v3/community/list') + && str_contains($request->url(), 'type_=All') + && str_contains($request->url(), 'sort=TopMonth') + && str_contains($request->url(), 'limit=25') + && str_contains($request->url(), 'page=2') + && str_contains($request->url(), 'show_nsfw=1') + && $request->header('Authorization')[0] === 'Bearer test-token'; + }); + } + + public function test_list_communities_success_without_token(): void + { + Http::fake([ + '*' => Http::response([ + 'communities' => [] + ], 200) + ]); + + $service = new LemmyApiService('lemmy.world'); + $result = $service->listCommunities(); + + $this->assertArrayHasKey('communities', $result); + $this->assertEmpty($result['communities']); + + Http::assertSent(function ($request) { + return !$request->hasHeader('Authorization'); + }); + } + + public function test_list_communities_throws_on_unsuccessful_response(): void + { + Http::fake([ + '*' => Http::response('Server Error', 500) + ]); + + Log::shouldReceive('warning')->once()->with('Failed to fetch communities list', Mockery::any()); + Log::shouldReceive('error')->once()->with('Exception while fetching communities list', Mockery::any()); + + $service = new LemmyApiService('lemmy.world'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to fetch communities list: 500 - Server Error'); + + $service->listCommunities('token'); + } + + public function test_list_communities_throws_on_404_response(): void + { + Http::fake([ + '*' => Http::response('Not Found', 404) + ]); + + Log::shouldReceive('warning')->once(); + Log::shouldReceive('error')->once(); + + $service = new LemmyApiService('lemmy.world'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to fetch communities list: 404'); + + $service->listCommunities('token'); + } + + public function test_list_communities_returns_empty_communities_on_invalid_response_format(): void + { + Http::fake([ + '*' => Http::response([ + 'invalid_format' => 'data' + ], 200) + ]); + + Log::shouldReceive('warning')->once()->with('Invalid communities list response format', Mockery::any()); + + $service = new LemmyApiService('lemmy.world'); + $result = $service->listCommunities(); + + $this->assertArrayHasKey('communities', $result); + $this->assertEmpty($result['communities']); + } + + public function test_list_communities_handles_exception(): void + { + Http::fake(function () { + throw new Exception('Network error'); + }); + + Log::shouldReceive('error')->once()->with('Exception while fetching communities list', Mockery::any()); + + $service = new LemmyApiService('lemmy.world'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Network error'); + + $service->listCommunities('token'); + } + + public function test_list_communities_with_all_sort_options(): void + { + $sortOptions = ['Hot', 'Active', 'New', 'TopDay', 'TopWeek', 'TopMonth', 'TopYear', 'TopAll']; + + foreach ($sortOptions as $sort) { + Http::fake([ + '*' => Http::response(['communities' => []], 200) + ]); + + $service = new LemmyApiService('lemmy.world'); + $result = $service->listCommunities(null, 'Local', $sort); + + $this->assertArrayHasKey('communities', $result); + + Http::assertSent(function ($request) use ($sort) { + return str_contains($request->url(), "sort={$sort}"); + }); + } + } + + public function test_list_communities_with_all_type_options(): void + { + $typeOptions = ['Local', 'All', 'Subscribed']; + + foreach ($typeOptions as $type) { + Http::fake([ + '*' => Http::response(['communities' => []], 200) + ]); + + $service = new LemmyApiService('lemmy.world'); + $result = $service->listCommunities(null, $type); + + $this->assertArrayHasKey('communities', $result); + + Http::assertSent(function ($request) use ($type) { + return str_contains($request->url(), "type_={$type}"); + }); + } + } + + public function test_list_communities_pagination_parameters(): void + { + Http::fake([ + '*' => Http::response(['communities' => []], 200) + ]); + + $service = new LemmyApiService('lemmy.world'); + $result = $service->listCommunities(null, 'Local', 'Active', 100, 3); + + $this->assertArrayHasKey('communities', $result); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'limit=100') + && str_contains($request->url(), 'page=3'); + }); + } + + public function test_list_communities_nsfw_parameter(): void + { + Http::fake([ + '*' => Http::response(['communities' => []], 200) + ]); + + $service = new LemmyApiService('lemmy.world'); + + // Test with NSFW enabled + $result = $service->listCommunities(null, 'Local', 'Active', 50, 1, true); + $this->assertArrayHasKey('communities', $result); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'show_nsfw=1'); + }); + + // Test with NSFW disabled + $result = $service->listCommunities(null, 'Local', 'Active', 50, 1, false); + $this->assertArrayHasKey('communities', $result); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'show_nsfw='); + }); + } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e35b6d9..dce4c20 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -248,6 +248,44 @@ export interface KeywordRequest { is_active?: boolean; } +export interface Community { + id: number; + name: string; + title: string | null; + description: string | null; + nsfw: boolean; + local: boolean; + subscribers: number; + posts: number; + display_text: string; +} + +export interface CommunitiesResponse { + communities: Community[]; + total: number; + platform_instance: { + id: number; + name: string; + url: string; + }; + parameters: { + type: string; + sort: string; + limit: number; + page: number; + show_nsfw: boolean; + }; +} + +export interface CommunitiesRequest { + platform_instance_id: number; + type?: 'Local' | 'All' | 'Subscribed'; + sort?: 'Hot' | 'Active' | 'New' | 'TopDay' | 'TopWeek' | 'TopMonth' | 'TopYear' | 'TopAll'; + limit?: number; + page?: number; + show_nsfw?: boolean; +} + // API Client class class ApiClient { constructor() { @@ -475,6 +513,14 @@ class ApiClient { async deletePlatformAccount(id: number): Promise { await axios.delete(`/platform-accounts/${id}`); } + + // Platform Communities endpoints + async getPlatformCommunities(params: CommunitiesRequest): Promise { + const response = await axios.get>('/platform-channels/communities', { + params + }); + return response.data.data; + } } export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/frontend/src/pages/onboarding/steps/ChannelStep.tsx b/frontend/src/pages/onboarding/steps/ChannelStep.tsx index cd69426..fec1513 100644 --- a/frontend/src/pages/onboarding/steps/ChannelStep.tsx +++ b/frontend/src/pages/onboarding/steps/ChannelStep.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { apiClient, type ChannelRequest, type Language, type PlatformInstance } from '../../../lib/api'; +import { apiClient, type ChannelRequest, type Language, type PlatformInstance, type Community } from '../../../lib/api'; const ChannelStep: React.FC = () => { const navigate = useNavigate(); @@ -13,6 +13,10 @@ const ChannelStep: React.FC = () => { description: '' }); const [errors, setErrors] = useState>({}); + const [searchTerm, setSearchTerm] = useState(''); + const [showDropdown, setShowDropdown] = useState(false); + const [, setSelectedCommunity] = useState(null); + const [manualInput, setManualInput] = useState(false); // Get onboarding options (languages, platform instances) const { data: options, isLoading: optionsLoading } = useQuery({ @@ -27,6 +31,31 @@ const ChannelStep: React.FC = () => { retry: false, }); + // Fetch communities when platform instance is selected + const { data: communitiesData, isLoading: communitiesLoading } = useQuery({ + queryKey: ['communities', formData.platform_instance_id], + queryFn: () => apiClient.getPlatformCommunities({ + platform_instance_id: formData.platform_instance_id, + type: 'Local', + sort: 'Active', + limit: 100 + }), + enabled: formData.platform_instance_id > 0 && !manualInput, + retry: false, + }); + + // Filter communities based on search term + const filteredCommunities = useMemo(() => { + if (!communitiesData?.communities) return []; + if (!searchTerm) return communitiesData.communities; + + return communitiesData.communities.filter(community => + community.name.toLowerCase().includes(searchTerm.toLowerCase()) || + community.title?.toLowerCase().includes(searchTerm.toLowerCase()) || + community.description?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [communitiesData?.communities, searchTerm]); + // Pre-fill form with existing data useEffect(() => { if (channels && channels.length > 0) { @@ -37,9 +66,34 @@ const ChannelStep: React.FC = () => { language_id: firstChannel.language_id || 0, description: firstChannel.description || '' }); + setSearchTerm(firstChannel.name || ''); } }, [channels]); + // Reset community selection when platform instance changes + useEffect(() => { + setSelectedCommunity(null); + setSearchTerm(''); + if (formData.platform_instance_id === 0) { + setManualInput(false); + } + }, [formData.platform_instance_id]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!target.closest('[data-dropdown]')) { + setShowDropdown(false); + } + }; + + if (showDropdown) { + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + } + }, [showDropdown]); + const createChannelMutation = useMutation({ mutationFn: (data: ChannelRequest) => apiClient.createChannelForOnboarding(data), onSuccess: () => { @@ -47,11 +101,12 @@ const ChannelStep: React.FC = () => { queryClient.invalidateQueries({ queryKey: ['onboarding-status'] }); navigate('/onboarding/route'); }, - onError: (error: any) => { - if (error.response?.data?.errors) { - setErrors(error.response.data.errors); + onError: (error: unknown) => { + const errorData = error as { response?: { data?: { errors?: Record; message?: string } } }; + if (errorData.response?.data?.errors) { + setErrors(errorData.response.data.errors); } else { - setErrors({ general: [error.response?.data?.message || 'An error occurred'] }); + setErrors({ general: [errorData.response?.data?.message || 'An error occurred'] }); } } }); @@ -70,6 +125,34 @@ const ChannelStep: React.FC = () => { } }; + const handleCommunitySelect = (community: Community) => { + setSelectedCommunity(community); + setFormData(prev => ({ ...prev, name: community.name })); + setSearchTerm(community.name); + setShowDropdown(false); + if (errors.name) { + setErrors(prev => ({ ...prev, name: [] })); + } + }; + + const handleSearchChange = (value: string) => { + setSearchTerm(value); + setFormData(prev => ({ ...prev, name: value })); + setShowDropdown(true); + setSelectedCommunity(null); + if (errors.name) { + setErrors(prev => ({ ...prev, name: [] })); + } + }; + + const toggleManualInput = () => { + setManualInput(prev => !prev); + setSelectedCommunity(null); + setSearchTerm(''); + setFormData(prev => ({ ...prev, name: '' })); + setShowDropdown(false); + }; + if (optionsLoading) { return
Loading...
; } @@ -97,19 +180,97 @@ const ChannelStep: React.FC = () => { )}
- - handleChange('name', e.target.value)} - placeholder="technology" - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - required - /> -

Enter the community name (without the @ or instance)

+
+ + {formData.platform_instance_id > 0 && ( + + )} +
+ + {manualInput || formData.platform_instance_id === 0 ? ( + handleChange('name', e.target.value)} + placeholder="technology" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + /> + ) : ( +
+ handleSearchChange(e.target.value)} + onFocus={() => setShowDropdown(true)} + placeholder="Search communities..." + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + /> + + {communitiesLoading && ( +
+
+
+ )} + + {showDropdown && !communitiesLoading && filteredCommunities.length > 0 && ( +
+ {filteredCommunities.slice(0, 10).map((community) => ( + + ))} + {filteredCommunities.length > 10 && ( +
+ And {filteredCommunities.length - 10} more communities... +
+ )} +
+ )} + + {showDropdown && !communitiesLoading && searchTerm && filteredCommunities.length === 0 && ( +
+
No communities found matching "{searchTerm}"
+ +
+ )} +
+ )} + +

+ {manualInput || formData.platform_instance_id === 0 + ? 'Enter the community name (without the @ or instance)' + : 'Search and select from available communities, or enter manually' + } +

{errors.name && (

{errors.name[0]}

)}