// Community data models for Coves // // These models match the backend API structure from: // GET /xrpc/social.coves.community.list // POST /xrpc/social.coves.community.post.create import 'package:flutter/foundation.dart'; import '../constants/embed_types.dart'; import '../utils/url_policy.dart'; /// Response from GET /xrpc/social.coves.community.list class CommunitiesResponse { CommunitiesResponse({required this.communities, this.cursor}); factory CommunitiesResponse.fromJson(Map json) { // Handle a null or non-list communities array from the backend final communitiesData = json['communities']; final communitiesList = []; // Parse community items, skipping any that fail to parse so one // malformed community never kills the whole list. `on Object` because // a bad cast raises a TypeError, which escapes `on Exception`. if (communitiesData is List) { for (final item in communitiesData) { if (item is! Map) { continue; } try { communitiesList.add(CommunityView.fromJson(item)); } on Object catch (e) { if (kDebugMode) { debugPrint('⚠️ Skipping malformed community: $e'); } } } } final cursor = json['cursor']; return CommunitiesResponse( communities: communitiesList, cursor: cursor is String ? cursor : null, ); } final List communities; final String? cursor; } /// Full community view data class CommunityView { CommunityView({ required this.did, required this.name, this.origin, this.handle, this.displayName, this.description, this.avatar, this.visibility, this.subscriberCount, this.memberCount, this.postCount, this.viewer, }); factory CommunityView.fromJson(Map json) { return CommunityView( did: json['did'] as String, name: json['name'] as String, // Cosmetic field: a wrong-typed value must not sink the whole item. origin: json['origin'] is String ? json['origin'] as String : null, handle: json['handle'] as String?, displayName: json['displayName'] as String?, description: json['description'] as String?, avatar: json['avatar'] as String?, visibility: json['visibility'] as String?, subscriberCount: json['subscriberCount'] as int?, memberCount: json['memberCount'] as int?, postCount: json['postCount'] as int?, viewer: json['viewer'] != null ? CommunityViewerState.fromJson( json['viewer'] as Map, ) : null, ); } /// Community DID (decentralized identifier) final String did; /// Community name (unique identifier) final String name; /// Origin instance domain (e.g. `coves.social`, `lemmy.world`). /// Optional until the appview ships it; display falls back to [handle]. final String? origin; /// Community handle final String? handle; /// Display name for UI final String? displayName; /// Community description final String? description; /// Avatar URL final String? avatar; /// Visibility setting (e.g., "public", "private") final String? visibility; /// Number of subscribers final int? subscriberCount; /// Number of members final int? memberCount; /// Number of posts final int? postCount; /// Current user's relationship with this community final CommunityViewerState? viewer; } /// Current user's relationship with a community class CommunityViewerState { CommunityViewerState({this.subscribed, this.member}); factory CommunityViewerState.fromJson(Map json) { return CommunityViewerState( subscribed: json['subscribed'] as bool?, member: json['member'] as bool?, ); } /// Whether the user is subscribed to this community final bool? subscribed; /// Whether the user is a member of this community final bool? member; } /// Request body for POST /xrpc/social.coves.community.post.create class CreatePostRequest { CreatePostRequest({ required this.community, this.title, this.content, this.embed, this.langs, this.labels, }); Map toJson() { final json = {'community': community}; if (title != null) { json['title'] = title; } if (content != null) { json['content'] = content; } if (embed != null) { json['embed'] = embed!.toJson(); } if (langs != null && langs!.isNotEmpty) { json['langs'] = langs; } if (labels != null) { json['labels'] = labels!.toJson(); } return json; } /// Community DID or handle final String community; /// Post title final String? title; /// Post content/text final String? content; /// External link embed final ExternalEmbedInput? embed; /// Language codes (e.g., ["en", "es"]) final List? langs; /// Self-applied content labels final SelfLabels? labels; } /// Response from POST /xrpc/social.coves.community.post.create class CreatePostResponse { const CreatePostResponse({required this.uri, required this.cid}); factory CreatePostResponse.fromJson(Map json) { return CreatePostResponse( uri: json['uri'] as String, cid: json['cid'] as String, ); } /// AT-URI of the created post final String uri; /// Content identifier (CID) of the created post final String cid; } /// External link embed input for creating posts class ExternalEmbedInput { /// Creates an [ExternalEmbedInput] with URI validation. /// /// Throws [ArgumentError] if [uri] is empty or not a valid URL. factory ExternalEmbedInput({ required String uri, String? title, String? description, }) { // Validate URI is not empty if (uri.isEmpty) { throw ArgumentError.value(uri, 'uri', 'URI cannot be empty'); } // Validate URI is a well-formed URL if (!isAllowedWebUrl(uri)) { throw ArgumentError.value( uri, 'uri', 'URI must be a valid HTTP or HTTPS URL', ); } return ExternalEmbedInput._( uri: uri, title: title, description: description, ); } const ExternalEmbedInput._({required this.uri, this.title, this.description}); Map toJson() { final external = {'uri': uri}; if (title != null) { external['title'] = title; } if (description != null) { external['description'] = description; } // Return proper embed structure expected by backend return {r'$type': EmbedTypes.external, 'external': external}; } /// URL of the external link final String uri; /// Title of the linked content final String? title; /// Description of the linked content final String? description; @override bool operator ==(Object other) => identical(this, other) || other is ExternalEmbedInput && runtimeType == other.runtimeType && uri == other.uri && title == other.title && description == other.description; @override int get hashCode => Object.hash(uri, title, description); @override String toString() => 'ExternalEmbedInput(uri: $uri, title: $title, description: $description)'; } /// Self-applied content labels class SelfLabels { const SelfLabels({required this.values}); Map toJson() { return {'values': values.map((label) => label.toJson()).toList()}; } /// List of self-applied labels final List values; } /// Individual self-applied label class SelfLabel { const SelfLabel({required this.val}); Map toJson() { return {'val': val}; } /// Label value (e.g., "nsfw", "spoiler") final String val; } /// Response from POST /xrpc/social.coves.community.create class CreateCommunityResponse { const CreateCommunityResponse({ required this.uri, required this.cid, required this.did, required this.handle, }); /// Parse response from JSON with defensive validation. /// /// Throws [FormatException] if required fields are missing or invalid. factory CreateCommunityResponse.fromJson(Map json) { final uri = json['uri']; final cid = json['cid']; final did = json['did']; final handle = json['handle']; if (uri == null || uri is! String) { throw const FormatException('Missing or invalid "uri" in response'); } if (cid == null || cid is! String) { throw const FormatException('Missing or invalid "cid" in response'); } if (did == null || did is! String) { throw const FormatException('Missing or invalid "did" in response'); } if (handle == null || handle is! String) { throw const FormatException('Missing or invalid "handle" in response'); } return CreateCommunityResponse( uri: uri, cid: cid, did: did, handle: handle, ); } /// AT-URI of the created community profile final String uri; /// Content identifier (CID) of the created community profile final String cid; /// DID of the created community final String did; /// Scoped handle of the created community final String handle; @override bool operator ==(Object other) { if (identical(this, other)) { return true; } return other is CreateCommunityResponse && other.uri == uri && other.cid == cid && other.did == did && other.handle == handle; } @override int get hashCode => Object.hash(uri, cid, did, handle); @override String toString() { return 'CreateCommunityResponse(uri: $uri, cid: $cid, did: $did, ' 'handle: $handle)'; } }