1610 lines
51 KiB
Dart
1610 lines
51 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../models/category.dart' as models;
|
|
import '../models/profile.dart';
|
|
import '../models/follow_request.dart';
|
|
import '../models/profile_privacy_settings.dart';
|
|
import '../models/post.dart';
|
|
import '../models/user_settings.dart';
|
|
import '../models/comment.dart';
|
|
import '../models/notification.dart';
|
|
import '../models/beacon.dart';
|
|
import '../config/api_config.dart';
|
|
import '../services/auth_service.dart';
|
|
import '../models/search_results.dart';
|
|
import '../models/tone_analysis.dart';
|
|
import '../utils/security_utils.dart';
|
|
import '../utils/request_signing.dart';
|
|
import 'package:http/http.dart' as http;
|
|
/// ApiService - Single source of truth for all backend communication.
|
|
class ApiService {
|
|
final AuthService _authService;
|
|
final http.Client _httpClient;
|
|
|
|
ApiService(this._authService) : _httpClient = http.Client();
|
|
|
|
// Singleton pattern helper if needed, but usually passed via DI/Riverpod
|
|
static ApiService? _instance;
|
|
static ApiService get instance =>
|
|
_instance ??= ApiService(AuthService.instance);
|
|
|
|
/// Generic caller for specialized edge endpoints. Handles response parsing
|
|
/// and normalization across different response formats.
|
|
Future<Map<String, dynamic>> _callFunction(
|
|
String functionName, {
|
|
String method = 'POST',
|
|
Map<String, dynamic>? queryParams,
|
|
Object? body,
|
|
}) async {
|
|
// Proxy through Go API to avoid CORS issues and Mixed Content
|
|
return await _callGoApi(
|
|
'/functions/$functionName',
|
|
method: method.toUpperCase(),
|
|
queryParams: queryParams?.map((k, v) => MapEntry(k, v.toString())),
|
|
body: body,
|
|
);
|
|
}
|
|
|
|
/// Generic function caller for the new Go API on the VPS.
|
|
/// Includes Retry-on-401 logic (Session Manager)
|
|
Future<Map<String, dynamic>> callGoApi(String path,
|
|
{String method = 'POST',
|
|
Map<String, dynamic>? body,
|
|
Map<String, String>? queryParams}) async {
|
|
return _callGoApi(path,
|
|
method: method, body: body, queryParams: queryParams);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _callGoApi(
|
|
String path, {
|
|
String method = 'POST',
|
|
Map<String, String>? queryParams,
|
|
Object? body,
|
|
bool requireSignature = false,
|
|
}) async {
|
|
try {
|
|
var uri = Uri.parse('${ApiConfig.baseUrl}$path')
|
|
.replace(queryParameters: queryParams);
|
|
var headers = await _authHeaders();
|
|
headers['Content-Type'] = 'application/json';
|
|
|
|
// Add request signature for critical operations
|
|
if (requireSignature && body != null) {
|
|
final secretKey = _authService.currentUser?.id ?? 'default';
|
|
headers = RequestSigning.addSignatureHeaders(
|
|
headers,
|
|
method,
|
|
path,
|
|
body as Map<String, dynamic>?,
|
|
secretKey,
|
|
);
|
|
}
|
|
|
|
http.Response response =
|
|
await _performRequest(method, uri, headers, body);
|
|
|
|
// INTERCEPTOR: Handle 401
|
|
if (response.statusCode == 401) {
|
|
if (kDebugMode) print('[API] Auth error at ${_sanitizePath(path)}');
|
|
final refreshed = await _authService.refreshSession();
|
|
if (refreshed) {
|
|
// Update token header and RETRY
|
|
headers = await _authHeaders();
|
|
headers['Content-Type'] = 'application/json';
|
|
if (kDebugMode) print('[API] Retrying request to ${_sanitizePath(path)}');
|
|
response = await _performRequest(method, uri, headers, body);
|
|
} else {
|
|
// Refresh failed, assume session died.
|
|
throw Exception('Session Expired');
|
|
}
|
|
}
|
|
|
|
if (response.statusCode >= 400) {
|
|
if (kDebugMode) print('[API] Error ${response.statusCode} at ${_sanitizePath(path)}');
|
|
throw Exception(
|
|
'Go API error (${response.statusCode}): ${response.body}');
|
|
}
|
|
|
|
if (response.body.isEmpty) return {};
|
|
final data = jsonDecode(response.body);
|
|
if (data is Map<String, dynamic>) return data;
|
|
return {'data': data};
|
|
} catch (e) {
|
|
if (kDebugMode) print('[API] Call failed: ${_sanitizePath(path)} - $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<http.Response> _performRequest(
|
|
String method, Uri uri, Map<String, String> headers, Object? body) async {
|
|
switch (method.toUpperCase()) {
|
|
case 'GET':
|
|
return await _httpClient.get(uri, headers: headers);
|
|
case 'PATCH':
|
|
return await _httpClient.patch(uri,
|
|
headers: headers, body: jsonEncode(body));
|
|
case 'DELETE':
|
|
return await _httpClient.delete(uri,
|
|
headers: headers, body: jsonEncode(body));
|
|
default:
|
|
return await _httpClient.post(uri,
|
|
headers: headers, body: jsonEncode(body));
|
|
}
|
|
}
|
|
|
|
Future<Map<String, String>> _authHeaders() async {
|
|
// Ensure AuthService has loaded tokens from storage
|
|
await _authService.ensureInitialized();
|
|
|
|
final token = _authService.accessToken;
|
|
|
|
if (token == null || token.isEmpty) {
|
|
return {};
|
|
}
|
|
|
|
return {
|
|
'Authorization': 'Bearer $token',
|
|
};
|
|
}
|
|
|
|
/// Sanitize path for logging by removing sensitive IDs
|
|
String _sanitizePath(String path) {
|
|
return path.replaceAll(RegExp(r'/[a-zA-Z0-9_-]{20,}'), '/***');
|
|
}
|
|
|
|
/// Generate unique request ID for tracking
|
|
String _generateRequestId() {
|
|
return '${DateTime.now().millisecondsSinceEpoch}-${DateTime.now().microsecond}';
|
|
}
|
|
|
|
List<Map<String, dynamic>> _normalizeListResponse(dynamic response) {
|
|
if (response == null) return [];
|
|
if (response is List<dynamic>) {
|
|
return response
|
|
.whereType<Map<String, dynamic>>()
|
|
.map((item) => Map<String, dynamic>.from(item))
|
|
.toList();
|
|
}
|
|
if (response is Map<String, dynamic>) return [response];
|
|
return [];
|
|
}
|
|
|
|
/// Simple GET request helper
|
|
Future<Map<String, dynamic>> get(String path, {Map<String, String>? queryParams}) async {
|
|
return _callGoApi(path, method: 'GET', queryParams: queryParams);
|
|
}
|
|
|
|
|
|
Future<void> resendVerificationEmail(String email) async {
|
|
await _callGoApi('/auth/resend-verification',
|
|
method: 'POST', body: {'email': email});
|
|
}
|
|
|
|
// =========================================================================
|
|
// Category & Onboarding
|
|
// =========================================================================
|
|
|
|
Future<List<models.Category>> getCategories() async {
|
|
final data = await _callGoApi('/categories', method: 'GET');
|
|
return (data['categories'] as List)
|
|
.map((json) => models.Category.fromJson(json))
|
|
.toList();
|
|
}
|
|
|
|
Future<List<models.Category>> getEnabledCategories() async {
|
|
final categories = await getCategories();
|
|
final enabledIds = await _getEnabledCategoryIds();
|
|
|
|
if (enabledIds.isEmpty) {
|
|
return categories.where((category) => !category.defaultOff).toList();
|
|
}
|
|
|
|
return categories
|
|
.where((category) => enabledIds.contains(category.id))
|
|
.toList();
|
|
}
|
|
|
|
Future<bool> hasProfile() async {
|
|
final user = _authService.currentUser;
|
|
if (user == null) return false;
|
|
|
|
final data = await _callGoApi('/profile', method: 'GET');
|
|
return data['profile'] != null;
|
|
}
|
|
|
|
Future<bool> hasCategorySelection() async {
|
|
try {
|
|
final enabledIds = await _getEnabledCategoryIds();
|
|
return enabledIds.isNotEmpty;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> setUserCategorySettings({
|
|
required List<models.Category> categories,
|
|
required Set<String> enabledCategoryIds,
|
|
}) async {
|
|
final settings = categories
|
|
.map((category) => {
|
|
'category_id': category.id,
|
|
'enabled': enabledCategoryIds.contains(category.id),
|
|
})
|
|
.toList();
|
|
|
|
await _callGoApi(
|
|
'/categories/settings',
|
|
method: 'POST',
|
|
body: {'settings': settings},
|
|
);
|
|
}
|
|
|
|
Future<void> completeOnboarding() async {
|
|
await _callGoApi('/complete-onboarding', method: 'POST');
|
|
// Also update local storage so app knows immediately (AuthService)
|
|
await _authService.markOnboardingCompleteLocally();
|
|
}
|
|
|
|
Future<Set<String>> _getEnabledCategoryIds() async {
|
|
final data = await _callGoApi('/categories/settings', method: 'GET');
|
|
final settings = data['settings'] as List? ?? [];
|
|
return settings
|
|
.where((s) => s['enabled'] == true)
|
|
.map((s) => s['category_id'] as String)
|
|
.toSet();
|
|
}
|
|
|
|
// =========================================================================
|
|
// Profile & Auth
|
|
// =========================================================================
|
|
|
|
Future<Profile> createProfile({
|
|
required String handle,
|
|
required String displayName,
|
|
String? bio,
|
|
}) async {
|
|
// Validate and sanitize inputs
|
|
if (!SecurityUtils.isValidHandle(handle)) {
|
|
throw ArgumentError('Invalid handle format');
|
|
}
|
|
if (!SecurityUtils.isValidEmail(_authService.currentUser?.email ?? '')) {
|
|
throw ArgumentError('Invalid user email');
|
|
}
|
|
|
|
final sanitizedHandle = SecurityUtils.sanitizeText(handle);
|
|
final sanitizedDisplayName = SecurityUtils.sanitizeText(displayName);
|
|
final sanitizedBio = bio != null ? SecurityUtils.sanitizeText(bio) : null;
|
|
|
|
// Legacy support: still calls generic 'signup' but via auth flow in AuthService usually.
|
|
// Making this use the endpoint just in case called directly.
|
|
// Adjust based on backend. If this requires token, it's fine.
|
|
// A 'create profile' usually happens after 'auth register'.
|
|
// If this is the 'onboarding' step for a user who exists but has no profile:
|
|
final data = await _callGoApi(
|
|
'/profile', // Changed from '/auth/signup' to '/profile'
|
|
method: 'POST', // Changed from 'POST' to 'POST'
|
|
body: {
|
|
'handle': sanitizedHandle,
|
|
'display_name': sanitizedDisplayName,
|
|
if (sanitizedBio != null) 'bio': sanitizedBio,
|
|
},
|
|
);
|
|
|
|
return Profile.fromJson(data['profile']);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> getProfile({String? handle}) async {
|
|
final data = await _callGoApi(
|
|
'/profile',
|
|
method: 'GET',
|
|
queryParams: handle != null ? {'handle': handle} : null,
|
|
);
|
|
|
|
return {
|
|
'profile': Profile.fromJson(data['profile'] as Map<String, dynamic>),
|
|
'stats': ProfileStats.fromJson(data['stats'] as Map<String, dynamic>?),
|
|
'is_following': data['is_following'] as bool? ?? false,
|
|
'is_followed_by': data['is_followed_by'] as bool? ?? false,
|
|
'is_friend': data['is_friend'] as bool? ?? false,
|
|
'follow_status': data['follow_status'] as String?,
|
|
'is_private': data['is_private'] as bool? ?? false,
|
|
};
|
|
}
|
|
|
|
Future<Map<String, dynamic>> getProfileById(String userId) async {
|
|
final data = await _callGoApi(
|
|
'/profiles/$userId',
|
|
method: 'GET',
|
|
);
|
|
|
|
return {
|
|
'profile': Profile.fromJson(data['profile'] as Map<String, dynamic>),
|
|
'stats': ProfileStats.fromJson(data['stats'] as Map<String, dynamic>?),
|
|
'is_following': data['is_following'] as bool? ?? false,
|
|
'is_followed_by': data['is_followed_by'] as bool? ?? false,
|
|
'is_friend': data['is_friend'] as bool? ?? false,
|
|
'follow_status': data['follow_status'] as String?,
|
|
'is_private': data['is_private'] as bool? ?? false,
|
|
};
|
|
}
|
|
|
|
Future<Profile> updateProfile({
|
|
String? handle,
|
|
String? displayName,
|
|
String? bio,
|
|
String? location,
|
|
String? website,
|
|
List<String>? interests,
|
|
String? avatarUrl,
|
|
String? coverUrl,
|
|
String? identityKey,
|
|
int? registrationId,
|
|
String? encryptedPrivateKey,
|
|
}) async {
|
|
// Validate and sanitize inputs
|
|
if (handle != null && !SecurityUtils.isValidHandle(handle)) {
|
|
throw ArgumentError('Invalid handle format');
|
|
}
|
|
|
|
final sanitizedHandle = handle != null ? SecurityUtils.sanitizeText(handle) : null;
|
|
final sanitizedDisplayName = displayName != null ? SecurityUtils.sanitizeText(displayName) : null;
|
|
final sanitizedBio = bio != null ? SecurityUtils.sanitizeText(bio) : null;
|
|
final sanitizedLocation = location != null ? SecurityUtils.sanitizeText(location) : null;
|
|
final sanitizedWebsite = website != null ? SecurityUtils.sanitizeUrl(website) : null;
|
|
final sanitizedInterests = interests?.map((i) => SecurityUtils.sanitizeText(i)).toList();
|
|
final sanitizedAvatarUrl = avatarUrl != null ? SecurityUtils.sanitizeUrl(avatarUrl) : null;
|
|
final sanitizedCoverUrl = coverUrl != null ? SecurityUtils.sanitizeUrl(coverUrl) : null;
|
|
|
|
final data = await _callGoApi(
|
|
'/profile',
|
|
method: 'PATCH',
|
|
body: {
|
|
if (sanitizedHandle != null) 'handle': sanitizedHandle,
|
|
if (sanitizedDisplayName != null) 'display_name': sanitizedDisplayName,
|
|
if (sanitizedBio != null) 'bio': sanitizedBio,
|
|
if (sanitizedLocation != null) 'location': sanitizedLocation,
|
|
if (sanitizedWebsite != null) 'website': sanitizedWebsite,
|
|
if (sanitizedInterests != null) 'interests': sanitizedInterests,
|
|
if (sanitizedAvatarUrl != null) 'avatar_url': sanitizedAvatarUrl,
|
|
if (sanitizedCoverUrl != null) 'cover_url': sanitizedCoverUrl,
|
|
if (identityKey != null) 'identity_key': identityKey,
|
|
if (registrationId != null) 'registration_id': registrationId,
|
|
if (encryptedPrivateKey != null)
|
|
'encrypted_private_key': encryptedPrivateKey,
|
|
},
|
|
requireSignature: true,
|
|
);
|
|
|
|
return Profile.fromJson(data['profile']);
|
|
}
|
|
|
|
Future<ProfilePrivacySettings> getPrivacySettings() async {
|
|
try {
|
|
final data = await _callGoApi('/settings/privacy', method: 'GET');
|
|
return ProfilePrivacySettings.fromJson(data);
|
|
} catch (_) {
|
|
// Fallback defaults
|
|
final userId = _authService.currentUser?.id ?? '';
|
|
return ProfilePrivacySettings.defaults(userId);
|
|
}
|
|
}
|
|
|
|
Future<ProfilePrivacySettings> updatePrivacySettings(
|
|
ProfilePrivacySettings settings,
|
|
) async {
|
|
final data = await _callGoApi(
|
|
'/settings/privacy',
|
|
method: 'PATCH',
|
|
body: settings.toJson(),
|
|
);
|
|
return ProfilePrivacySettings.fromJson(data);
|
|
}
|
|
|
|
Future<UserSettings> getUserSettings() async {
|
|
try {
|
|
final data = await _callGoApi('/settings/user', method: 'GET');
|
|
// If data is empty or assumes defaults
|
|
return UserSettings.fromJson(data);
|
|
} catch (_) {
|
|
// Fallback
|
|
final userId = _authService.currentUser?.id ?? '';
|
|
return UserSettings(userId: userId, defaultPostTtl: null);
|
|
}
|
|
}
|
|
|
|
Future<UserSettings> updateUserSettings(UserSettings settings) async {
|
|
final data = await _callGoApi(
|
|
'/settings/user',
|
|
method: 'PATCH',
|
|
body: settings.toJson(),
|
|
);
|
|
return UserSettings.fromJson(data);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Posts & Feed
|
|
// =========================================================================
|
|
|
|
Future<List<Post>> getProfilePosts({
|
|
required String authorId,
|
|
int limit = 20,
|
|
int offset = 0,
|
|
bool onlyChains = false,
|
|
}) async {
|
|
final data = await _callGoApi(
|
|
'/users/$authorId/posts',
|
|
method: 'GET',
|
|
queryParams: {
|
|
'limit': limit.toString(),
|
|
'offset': offset.toString(),
|
|
if (onlyChains) 'chained': 'true',
|
|
},
|
|
);
|
|
|
|
final posts = data['posts'];
|
|
if (posts is List) {
|
|
return posts
|
|
.whereType<Map<String, dynamic>>()
|
|
.map((json) => Post.fromJson(json))
|
|
.toList();
|
|
}
|
|
return [];
|
|
}
|
|
|
|
Future<Post> getPostById(String postId) async {
|
|
final data = await _callGoApi(
|
|
'/posts/$postId',
|
|
method: 'GET',
|
|
);
|
|
|
|
return Post.fromJson(data['post']);
|
|
}
|
|
|
|
Future<List<Post>> getAppreciatedPosts({
|
|
required String userId,
|
|
int limit = 20,
|
|
int offset = 0,
|
|
}) async {
|
|
final data = await _callGoApi(
|
|
'/users/me/liked',
|
|
method: 'GET',
|
|
queryParams: {'limit': '$limit', 'offset': '$offset'},
|
|
);
|
|
final posts = data['posts'] as List? ?? [];
|
|
return posts.map((p) => Post.fromJson(p)).toList();
|
|
}
|
|
|
|
Future<List<Post>> getSavedPosts({
|
|
required String userId,
|
|
int limit = 20,
|
|
int offset = 0,
|
|
}) async {
|
|
final data = await _callGoApi(
|
|
'/users/$userId/saved',
|
|
method: 'GET',
|
|
queryParams: {'limit': '$limit', 'offset': '$offset'},
|
|
);
|
|
final posts = data['posts'] as List? ?? [];
|
|
return posts.map((p) => Post.fromJson(p)).toList();
|
|
}
|
|
|
|
Future<List<Post>> getChainedPostsForAuthor({
|
|
required String authorId,
|
|
int limit = 20,
|
|
int offset = 0,
|
|
}) async {
|
|
return getProfilePosts(
|
|
authorId: authorId,
|
|
limit: limit,
|
|
offset: offset,
|
|
onlyChains: true,
|
|
);
|
|
}
|
|
|
|
Future<List<Post>> getChainPosts({
|
|
required String parentPostId,
|
|
int limit = 50,
|
|
}) async {
|
|
final data = await _callGoApi(
|
|
'/posts/$parentPostId/chain',
|
|
method: 'GET',
|
|
);
|
|
final posts = data['posts'] as List? ?? [];
|
|
return posts.map((p) => Post.fromJson(p)).toList();
|
|
}
|
|
|
|
/// Get complete conversation thread with parent-child relationships
|
|
/// Used for threaded conversations (Reddit-style)
|
|
Future<List<Post>> getPostChain(String rootPostId) async {
|
|
final data = await _callGoApi(
|
|
'/posts/$rootPostId/thread',
|
|
method: 'GET',
|
|
);
|
|
final posts = data['posts'] as List? ?? [];
|
|
return posts.map((p) => Post.fromJson(p)).toList();
|
|
}
|
|
|
|
/// Get Focus-Context data for the new interactive block system
|
|
/// Returns: Target Post, Direct Parent (if any), and Direct Children (1st layer only)
|
|
Future<FocusContext> getPostFocusContext(String postId) async {
|
|
final data = await _callGoApi(
|
|
'/posts/$postId/focus-context',
|
|
method: 'GET',
|
|
);
|
|
return FocusContext.fromJson(data);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Publishing - Unified Post/Beacon Flow
|
|
// =========================================================================
|
|
|
|
Future<Post> publishPost({
|
|
String? categoryId,
|
|
required String body,
|
|
String bodyFormat = 'plain',
|
|
bool allowChain = true,
|
|
String? chainParentId,
|
|
String? imageUrl,
|
|
String? videoUrl,
|
|
String? thumbnailUrl,
|
|
int? durationMs,
|
|
int? ttlHours,
|
|
bool isBeacon = false,
|
|
BeaconType? beaconType,
|
|
double? lat,
|
|
double? long,
|
|
String? severity,
|
|
bool userWarned = false,
|
|
bool isNsfw = false,
|
|
String? nsfwReason,
|
|
String? visibility,
|
|
}) async {
|
|
// Validate and sanitize inputs
|
|
if (body.isEmpty) {
|
|
throw ArgumentError('Post body cannot be empty');
|
|
}
|
|
|
|
final sanitizedBody = SecurityUtils.limitText(SecurityUtils.sanitizeText(body), maxLength: 5000);
|
|
final sanitizedImageUrl = imageUrl != null ? SecurityUtils.sanitizeUrl(imageUrl) : null;
|
|
final sanitizedVideoUrl = videoUrl != null ? SecurityUtils.sanitizeUrl(videoUrl) : null;
|
|
final sanitizedThumbnailUrl = thumbnailUrl != null ? SecurityUtils.sanitizeUrl(thumbnailUrl) : null;
|
|
|
|
// Validate coordinates for beacons
|
|
if (isBeacon && (lat == null || long == null)) {
|
|
throw ArgumentError('Beacon posts require latitude and longitude');
|
|
}
|
|
|
|
if (lat != null && (lat < -90 || lat > 90)) {
|
|
throw ArgumentError('Invalid latitude range');
|
|
}
|
|
|
|
if (long != null && (long < -180 || long > 180)) {
|
|
throw ArgumentError('Invalid longitude range');
|
|
}
|
|
|
|
if (kDebugMode) {
|
|
}
|
|
|
|
final data = await _callGoApi(
|
|
|
|
'/posts',
|
|
method: 'POST',
|
|
body: {
|
|
if (categoryId != null) 'category_id': categoryId,
|
|
'body': sanitizedBody,
|
|
'body_format': bodyFormat,
|
|
'allow_chain': allowChain,
|
|
if (chainParentId != null) 'chain_parent_id': chainParentId,
|
|
if (sanitizedImageUrl != null || (imageUrl != null && imageUrl.isNotEmpty))
|
|
'image_url': sanitizedImageUrl ?? imageUrl,
|
|
if (sanitizedVideoUrl != null || (videoUrl != null && videoUrl.isNotEmpty))
|
|
'video_url': sanitizedVideoUrl ?? videoUrl,
|
|
if (sanitizedThumbnailUrl != null || (thumbnailUrl != null && thumbnailUrl.isNotEmpty))
|
|
'thumbnail_url': sanitizedThumbnailUrl ?? thumbnailUrl,
|
|
|
|
if (durationMs != null) 'duration_ms': durationMs,
|
|
if (ttlHours != null) 'ttl_hours': ttlHours,
|
|
if (isBeacon) 'is_beacon': true,
|
|
if (beaconType != null) 'beacon_type': beaconType.value,
|
|
if (lat != null) 'beacon_lat': lat,
|
|
if (long != null) 'beacon_long': long,
|
|
if (severity != null) 'severity': severity,
|
|
if (userWarned) 'user_warned': true,
|
|
if (isNsfw) 'is_nsfw': true,
|
|
if (nsfwReason != null) 'nsfw_reason': nsfwReason,
|
|
if (visibility != null) 'visibility': visibility,
|
|
},
|
|
requireSignature: true,
|
|
);
|
|
|
|
return Post.fromJson(data['post']);
|
|
}
|
|
|
|
Future<Comment> publishComment({
|
|
required String postId,
|
|
required String body,
|
|
}) async {
|
|
// Backward-compatible: create a chained post so threads render immediately.
|
|
final post = await publishPost(
|
|
body: body,
|
|
chainParentId: postId,
|
|
allowChain: true,
|
|
);
|
|
|
|
return Comment(
|
|
id: post.id,
|
|
postId: postId,
|
|
authorId: post.authorId,
|
|
body: post.body,
|
|
status: CommentStatus.active,
|
|
createdAt: post.createdAt,
|
|
updatedAt: post.editedAt,
|
|
author: post.author,
|
|
voteCount: null,
|
|
);
|
|
}
|
|
|
|
Future<void> editPost({
|
|
required String postId,
|
|
required String content,
|
|
}) async {
|
|
// Validate and sanitize input
|
|
if (content.isEmpty) {
|
|
throw ArgumentError('Content cannot be empty');
|
|
}
|
|
|
|
final sanitizedContent = SecurityUtils.limitText(SecurityUtils.sanitizeText(content), maxLength: 5000);
|
|
|
|
await _callGoApi(
|
|
'/posts/$postId',
|
|
method: 'PATCH',
|
|
body: {'body': sanitizedContent},
|
|
);
|
|
}
|
|
|
|
Future<void> deletePost(String postId) async {
|
|
await _callGoApi(
|
|
'/posts/$postId',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
Future<void> updatePostVisibility({
|
|
required String postId,
|
|
required String visibility,
|
|
}) async {
|
|
await _callGoApi(
|
|
'/posts/$postId/visibility',
|
|
method: 'PATCH',
|
|
body: {'visibility': visibility},
|
|
);
|
|
}
|
|
|
|
Future<void> updateAllPostVisibility(String visibility) async {
|
|
// Legacy function proxy for bulk update
|
|
await _callFunction(
|
|
'manage-post',
|
|
method: 'POST',
|
|
body: {
|
|
'action': 'bulk_update_privacy',
|
|
'visibility': visibility,
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> pinPost(String postId) async {
|
|
await _callGoApi(
|
|
'/posts/$postId/pin',
|
|
method: 'POST',
|
|
body: {'pinned': true},
|
|
);
|
|
}
|
|
|
|
Future<void> unpinPost(String postId) async {
|
|
await _callGoApi(
|
|
'/posts/$postId/pin',
|
|
method: 'POST',
|
|
body: {'pinned': false}, // Assuming logic handles boolean toggles
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Beacons
|
|
// =========================================================================
|
|
|
|
/// Stateless content moderation check — sends plaintext to server AI,
|
|
/// returns pass/fail. Server does NOT store the content.
|
|
/// Returns null if allowed, or a reason string if blocked.
|
|
Future<String?> moderateContent({String? text, String? imageUrl, String? context}) async {
|
|
try {
|
|
final data = await _callGoApi(
|
|
'/moderate',
|
|
method: 'POST',
|
|
body: {
|
|
if (text != null) 'text': text,
|
|
if (imageUrl != null) 'image_url': imageUrl,
|
|
if (context != null) 'context': context,
|
|
},
|
|
);
|
|
final allowed = data['allowed'] as bool? ?? true;
|
|
if (!allowed) {
|
|
return data['reason'] as String? ?? 'Content policy violation';
|
|
}
|
|
return null; // Allowed
|
|
} catch (e) {
|
|
// If moderation endpoint fails, allow the message through
|
|
// (fail-open to avoid blocking all messages on server issues)
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Create an anonymous beacon pin on the map.
|
|
/// Author identity is stripped — server stores it only for abuse tracking.
|
|
Future<Post> createBeacon({
|
|
required String body,
|
|
required String beaconType,
|
|
required double lat,
|
|
required double long,
|
|
String severity = 'medium',
|
|
String? imageUrl,
|
|
int? ttlHours,
|
|
}) async {
|
|
final data = await _callGoApi(
|
|
'/beacons',
|
|
method: 'POST',
|
|
body: {
|
|
'body': body,
|
|
'beacon_type': beaconType,
|
|
'lat': lat,
|
|
'long': long,
|
|
'severity': severity,
|
|
if (imageUrl != null) 'image_url': imageUrl,
|
|
if (ttlHours != null) 'ttl_hours': ttlHours,
|
|
},
|
|
requireSignature: true,
|
|
);
|
|
return Post.fromJson(data['beacon']);
|
|
}
|
|
|
|
Future<List<Post>> fetchNearbyBeacons({
|
|
required double lat,
|
|
required double long,
|
|
int radius = 16000,
|
|
}) async {
|
|
try {
|
|
final data = await _callGoApi(
|
|
'/beacons/nearby',
|
|
method: 'GET',
|
|
queryParams: {
|
|
'lat': lat.toString(),
|
|
'long': long.toString(),
|
|
'radius': radius.toString(),
|
|
},
|
|
);
|
|
return (data['beacons'] as List)
|
|
.map((json) => Post.fromJson(json))
|
|
.toList();
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
|
|
// =========================================================================
|
|
// Beacon Ecosystem Search (beacons, board, public groups — never private)
|
|
// =========================================================================
|
|
|
|
Future<Map<String, dynamic>> beaconSearch({
|
|
required String query,
|
|
double? lat,
|
|
double? lng,
|
|
int? radius,
|
|
String type = 'all',
|
|
int limit = 20,
|
|
}) async {
|
|
var path = '/beacon/search?q=${Uri.encodeComponent(query)}&type=$type&limit=$limit';
|
|
if (lat != null && lng != null) path += '&lat=$lat&long=$lng';
|
|
if (radius != null) path += '&radius=$radius';
|
|
return await _callGoApi(path, method: 'GET');
|
|
}
|
|
|
|
// =========================================================================
|
|
// Neighborhood Board (standalone — NOT posts)
|
|
// =========================================================================
|
|
|
|
Future<Map<String, dynamic>> fetchBoardEntries({
|
|
required double lat,
|
|
required double long,
|
|
int radius = 5000,
|
|
String? topic,
|
|
String sort = 'new',
|
|
}) async {
|
|
var path = '/board/nearby?lat=$lat&long=$long&radius=$radius&sort=$sort';
|
|
if (topic != null && topic.isNotEmpty) path += '&topic=$topic';
|
|
return await _callGoApi(path, method: 'GET');
|
|
}
|
|
|
|
Future<Map<String, dynamic>> createBoardEntry({
|
|
required String body,
|
|
String? imageUrl,
|
|
required String topic,
|
|
required double lat,
|
|
required double long,
|
|
}) async {
|
|
return await _callGoApi('/board', body: {
|
|
'body': body,
|
|
if (imageUrl != null) 'image_url': imageUrl,
|
|
'topic': topic,
|
|
'lat': lat,
|
|
'long': long,
|
|
});
|
|
}
|
|
|
|
Future<Map<String, dynamic>> getBoardEntry(String entryId) async {
|
|
return await _callGoApi('/board/$entryId', method: 'GET');
|
|
}
|
|
|
|
Future<Map<String, dynamic>> createBoardReply({
|
|
required String entryId,
|
|
required String body,
|
|
}) async {
|
|
return await _callGoApi('/board/$entryId/replies', body: {
|
|
'body': body,
|
|
});
|
|
}
|
|
|
|
Future<Map<String, dynamic>> toggleBoardVote({
|
|
String? entryId,
|
|
String? replyId,
|
|
}) async {
|
|
return await _callGoApi('/board/vote', body: {
|
|
if (entryId != null) 'entry_id': entryId,
|
|
if (replyId != null) 'reply_id': replyId,
|
|
});
|
|
}
|
|
|
|
Future<void> removeBoardEntry(String id, String reason) async {
|
|
await _callGoApi('/board/$id/remove', method: 'POST', body: {'reason': reason});
|
|
}
|
|
|
|
Future<void> flagBoardEntry(String id, String reason, {String? replyId}) async {
|
|
await _callGoApi('/board/$id/flag', method: 'POST', body: {
|
|
'reason': reason,
|
|
if (replyId != null) 'reply_id': replyId,
|
|
});
|
|
}
|
|
|
|
// Neighborhoods
|
|
// =========================================================================
|
|
|
|
Future<Map<String, dynamic>> detectNeighborhood({
|
|
required double lat,
|
|
required double long,
|
|
}) async {
|
|
return await _callGoApi('/neighborhoods/detect?lat=$lat&long=$long', method: 'GET');
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> getCurrentNeighborhood() async {
|
|
try {
|
|
return await _callGoApi('/neighborhoods/current', method: 'GET');
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> searchNeighborhoodsByZip(String zip) async {
|
|
final data = await _callGoApi('/neighborhoods/search?zip=$zip', method: 'GET');
|
|
final list = data['neighborhoods'] as List<dynamic>? ?? [];
|
|
return list.cast<Map<String, dynamic>>();
|
|
}
|
|
|
|
Future<Map<String, dynamic>> chooseNeighborhood(String neighborhoodId) async {
|
|
return await _callGoApi('/neighborhoods/choose', method: 'POST', body: {
|
|
'neighborhood_id': neighborhoodId,
|
|
});
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> getMyNeighborhood() async {
|
|
try {
|
|
return await _callGoApi('/neighborhoods/mine', method: 'GET');
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Groups & Clusters
|
|
// =========================================================================
|
|
|
|
Future<List<Map<String, dynamic>>> fetchMyGroups() async {
|
|
final data = await _callGoApi('/capsules/mine', method: 'GET');
|
|
return (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> discoverGroups({String? category, int limit = 50}) async {
|
|
final params = <String, String>{'limit': '$limit'};
|
|
if (category != null && category != 'all') params['category'] = category;
|
|
final data = await _callGoApi('/capsules/discover', method: 'GET', queryParams: params);
|
|
return (data['groups'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
Future<void> joinGroup(String groupId) async {
|
|
await _callGoApi('/capsules/$groupId/join', method: 'POST');
|
|
}
|
|
|
|
Future<Map<String, dynamic>> createGroup({
|
|
required String name,
|
|
String description = '',
|
|
String privacy = 'public',
|
|
String category = 'general',
|
|
}) async {
|
|
return await _callGoApi('/capsules/group', body: {
|
|
'name': name,
|
|
'description': description,
|
|
'privacy': privacy,
|
|
'category': category,
|
|
});
|
|
}
|
|
|
|
Future<Map<String, dynamic>> createCapsule({
|
|
required String name,
|
|
String description = '',
|
|
required String publicKey,
|
|
required String encryptedGroupKey,
|
|
String? settings,
|
|
}) async {
|
|
return await _callGoApi('/capsules', body: {
|
|
'name': name,
|
|
'description': description,
|
|
'public_key': publicKey,
|
|
'encrypted_group_key': encryptedGroupKey,
|
|
if (settings != null) 'settings': settings,
|
|
});
|
|
}
|
|
|
|
// Group Features (posts, chat, forum, members)
|
|
// =========================================================================
|
|
|
|
Future<List<Map<String, dynamic>>> fetchGroupPosts(String groupId, {int limit = 20, int offset = 0}) async {
|
|
final data = await _callGoApi('/capsules/$groupId/posts', method: 'GET',
|
|
queryParams: {'limit': '$limit', 'offset': '$offset'});
|
|
return (data['posts'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
Future<Map<String, dynamic>> createGroupPost(String groupId, {required String body, String? imageUrl}) async {
|
|
return await _callGoApi('/capsules/$groupId/posts', body: {
|
|
'body': body,
|
|
if (imageUrl != null) 'image_url': imageUrl,
|
|
});
|
|
}
|
|
|
|
Future<Map<String, dynamic>> toggleGroupPostLike(String groupId, String postId) async {
|
|
return await _callGoApi('/capsules/$groupId/posts/$postId/like', method: 'POST');
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> fetchGroupPostComments(String groupId, String postId) async {
|
|
final data = await _callGoApi('/capsules/$groupId/posts/$postId/comments', method: 'GET');
|
|
return (data['comments'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
Future<Map<String, dynamic>> createGroupPostComment(String groupId, String postId, {required String body}) async {
|
|
return await _callGoApi('/capsules/$groupId/posts/$postId/comments', body: {'body': body});
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> fetchGroupMessages(String groupId, {int limit = 50, int offset = 0}) async {
|
|
final data = await _callGoApi('/capsules/$groupId/messages', method: 'GET',
|
|
queryParams: {'limit': '$limit', 'offset': '$offset'});
|
|
return (data['messages'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
Future<Map<String, dynamic>> sendGroupMessage(String groupId, {required String body}) async {
|
|
return await _callGoApi('/capsules/$groupId/messages', body: {'body': body});
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> fetchGroupThreads(String groupId, {int limit = 30, int offset = 0}) async {
|
|
final data = await _callGoApi('/capsules/$groupId/threads', method: 'GET',
|
|
queryParams: {'limit': '$limit', 'offset': '$offset'});
|
|
return (data['threads'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
Future<Map<String, dynamic>> createGroupThread(String groupId, {required String title, String body = ''}) async {
|
|
return await _callGoApi('/capsules/$groupId/threads', body: {'title': title, 'body': body});
|
|
}
|
|
|
|
Future<Map<String, dynamic>> fetchGroupThread(String groupId, String threadId) async {
|
|
return await _callGoApi('/capsules/$groupId/threads/$threadId', method: 'GET');
|
|
}
|
|
|
|
Future<Map<String, dynamic>> createGroupThreadReply(String groupId, String threadId, {required String body}) async {
|
|
return await _callGoApi('/capsules/$groupId/threads/$threadId/replies', body: {'body': body});
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> fetchGroupMembers(String groupId) async {
|
|
final data = await _callGoApi('/capsules/$groupId/members', method: 'GET');
|
|
return (data['members'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
Future<void> removeGroupMember(String groupId, String memberId) async {
|
|
await _callGoApi('/capsules/$groupId/members/$memberId', method: 'DELETE');
|
|
}
|
|
|
|
Future<void> updateMemberRole(String groupId, String memberId, {required String role}) async {
|
|
await _callGoApi('/capsules/$groupId/members/$memberId', method: 'PATCH', body: {'role': role});
|
|
}
|
|
|
|
Future<void> leaveGroup(String groupId) async {
|
|
await _callGoApi('/capsules/$groupId/leave', method: 'POST');
|
|
}
|
|
|
|
Future<void> updateGroup(String groupId, {String? name, String? description, String? settings}) async {
|
|
await _callGoApi('/capsules/$groupId', method: 'PATCH', body: {
|
|
if (name != null) 'name': name,
|
|
if (description != null) 'description': description,
|
|
if (settings != null) 'settings': settings,
|
|
});
|
|
}
|
|
|
|
Future<void> deleteGroup(String groupId) async {
|
|
await _callGoApi('/capsules/$groupId', method: 'DELETE');
|
|
}
|
|
|
|
Future<void> inviteToGroup(String groupId, {required String userId}) async {
|
|
await _callGoApi('/capsules/$groupId/invite-member', body: {'user_id': userId});
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> searchUsersForInvite(String groupId, String query) async {
|
|
final data = await _callGoApi('/capsules/$groupId/search-users', method: 'GET',
|
|
queryParams: {'q': query});
|
|
return (data['users'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
// =========================================================================
|
|
// Social Actions
|
|
// =========================================================================
|
|
|
|
Future<String?> followUser(String userId) async {
|
|
final data = await _callGoApi(
|
|
'/users/$userId/follow',
|
|
method: 'POST',
|
|
);
|
|
// Prefer explicit status, fallback to message if legacy
|
|
return (data['status'] as String?) ?? (data['message'] as String?);
|
|
}
|
|
|
|
Future<void> unfollowUser(String userId) async {
|
|
await _callGoApi(
|
|
'/users/$userId/follow',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
Future<List<FollowRequest>> getFollowRequests() async {
|
|
final data = await _callGoApi('/users/requests');
|
|
final requests = data['requests'] as List<dynamic>? ?? [];
|
|
return requests.map((e) => FollowRequest.fromJson(e)).toList();
|
|
}
|
|
|
|
Future<void> acceptFollowRequest(String requesterId) async {
|
|
await _callGoApi(
|
|
'/users/$requesterId/accept',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> rejectFollowRequest(String requesterId) async {
|
|
await _callGoApi(
|
|
'/users/$requesterId/reject',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
Future<void> blockUser(String userId) async {
|
|
await _callGoApi(
|
|
'/users/$userId/block',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> unblockUser(String userId) async {
|
|
await _callGoApi(
|
|
'/users/$userId/block',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
Future<void> appreciatePost(String postId) async {
|
|
await _callGoApi(
|
|
'/posts/$postId/like',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> unappreciatePost(String postId) async {
|
|
await _callGoApi(
|
|
'/posts/$postId/like',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Chat
|
|
// =========================================================================
|
|
|
|
Future<List<dynamic>> getConversations() async {
|
|
try {
|
|
final data = await _callGoApi('/conversations', method: 'GET');
|
|
return data['conversations'] as List? ?? [];
|
|
} catch (e) {
|
|
// Fallback or empty if API not yet ready
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> getConversationById(
|
|
String conversationId) async {
|
|
return {};
|
|
}
|
|
|
|
Future<String> getOrCreateConversation(String otherUserId) async {
|
|
final data = await _callGoApi('/conversation',
|
|
method: 'GET', queryParams: {'other_user_id': otherUserId});
|
|
return data['conversation_id'] as String;
|
|
}
|
|
|
|
Future<Map<String, dynamic>> sendEncryptedMessage({
|
|
required String conversationId,
|
|
required String ciphertext, // Go expects string (base64)
|
|
String? receiverId,
|
|
String? iv,
|
|
String? keyVersion,
|
|
String? messageHeader,
|
|
int messageType = 1,
|
|
}) async {
|
|
final data = await _callGoApi('/messages', method: 'POST', body: {
|
|
'conversation_id': conversationId,
|
|
if (receiverId != null) 'receiver_id': receiverId,
|
|
'ciphertext': ciphertext,
|
|
if (iv != null) 'iv': iv,
|
|
if (keyVersion != null) 'key_version': keyVersion,
|
|
if (messageHeader != null) 'message_header': messageHeader,
|
|
'message_type': messageType
|
|
});
|
|
return data;
|
|
}
|
|
|
|
Future<List<dynamic>> getConversationMessages(String conversationId,
|
|
{int limit = 50, int offset = 0}) async {
|
|
final data = await _callGoApi('/conversations/$conversationId/messages',
|
|
method: 'GET', queryParams: {'limit': '$limit', 'offset': '$offset'});
|
|
return data['messages'] as List? ?? [];
|
|
}
|
|
|
|
Future<List<dynamic>> getMutualFollows() async {
|
|
final data = await _callGoApi('/mutual-follows', method: 'GET');
|
|
return data['profiles'] as List? ?? [];
|
|
}
|
|
|
|
Future<bool> deleteConversation(String conversationId) async {
|
|
try {
|
|
await _callGoApi('/conversations/$conversationId', method: 'DELETE');
|
|
return true;
|
|
} catch (e) {
|
|
if (kDebugMode) print('[API] Failed to delete conversation: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> deleteMessage(String messageId) async {
|
|
try {
|
|
await _callGoApi('/messages/$messageId', method: 'DELETE');
|
|
return true;
|
|
} catch (e) {
|
|
if (kDebugMode) print('[API] Failed to delete message: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// E2EE / Keys (Missing Methods)
|
|
// =========================================================================
|
|
|
|
Future<Map<String, dynamic>> getKeyBundle(String userId) async {
|
|
final data = await callGoApi('/keys/$userId', method: 'GET');
|
|
// Key bundle fetched - contents not logged for security
|
|
// Go returns nested structure. We normalize to flat keys here.
|
|
if (data.containsKey('identity_key') && data['identity_key'] is Map) {
|
|
final identityKey = data['identity_key'] as Map<String, dynamic>;
|
|
final signedPrekey = data['signed_prekey'] as Map<String, dynamic>?;
|
|
final oneTimePrekey = data['one_time_prekey'] as Map<String, dynamic>?;
|
|
|
|
return {
|
|
'identity_key_public': identityKey['public_key'],
|
|
'signed_prekey_public': signedPrekey?['public_key'],
|
|
'signed_prekey_id': signedPrekey?['key_id'],
|
|
'signed_prekey_signature': signedPrekey?['signature'],
|
|
'registration_id': identityKey['key_id'],
|
|
'one_time_prekey': oneTimePrekey?['public_key'],
|
|
'one_time_prekey_id': oneTimePrekey?['key_id'],
|
|
};
|
|
}
|
|
return data;
|
|
}
|
|
|
|
Future<void> publishKeys({
|
|
required String identityKeyPublic,
|
|
required int registrationId,
|
|
String? preKey,
|
|
String? signedPrekeyPublic,
|
|
int? signedPrekeyId,
|
|
String? signedPrekeySignature,
|
|
List<dynamic>? oneTimePrekeys,
|
|
String? identityKey,
|
|
String? signature,
|
|
String? signedPreKey,
|
|
}) async {
|
|
final actualIdentityKey = identityKey ?? identityKeyPublic;
|
|
final actualSignature = signature ?? signedPrekeySignature ?? '';
|
|
final actualSignedPreKey = signedPreKey ?? signedPrekeyPublic ?? '';
|
|
|
|
await callGoApi('/keys', method: 'POST', body: {
|
|
'identity_key_public': actualIdentityKey,
|
|
'signed_prekey_public': actualSignedPreKey,
|
|
'signed_prekey_id': signedPrekeyId ?? 1,
|
|
'signed_prekey_signature': actualSignature,
|
|
'one_time_prekeys': oneTimePrekeys ?? [],
|
|
'registration_id': registrationId,
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// Media / Search / Analysis (Missing Methods)
|
|
// =========================================================================
|
|
|
|
Future<String> getSignedMediaUrl(String path) async {
|
|
// For web platform, return the original URL since signing isn't needed
|
|
// for public CDN domains
|
|
if (path.startsWith('http')) {
|
|
return path;
|
|
}
|
|
|
|
// Migrate to Go API / Nginx Signed URLs
|
|
// TODO: Implement proper signed URL generation
|
|
return '${ApiConfig.baseUrl}/media/signed?path=$path';
|
|
}
|
|
|
|
Future<Map<String, dynamic>> toggleReaction(String postId, String emoji) async {
|
|
final data = await callGoApi(
|
|
'/posts/$postId/reactions/toggle',
|
|
method: 'POST',
|
|
body: {'emoji': emoji},
|
|
);
|
|
if (data is Map<String, dynamic>) {
|
|
return data;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
Future<SearchResults> search(String query) async {
|
|
// Validate and sanitize search query
|
|
if (query.isEmpty) {
|
|
return SearchResults(users: [], tags: [], posts: []);
|
|
}
|
|
|
|
final sanitizedQuery = SecurityUtils.limitText(SecurityUtils.sanitizeText(query), maxLength: 100);
|
|
|
|
if (!SecurityUtils.isValidInput(sanitizedQuery)) {
|
|
if (kDebugMode) print('[API] Invalid search query input: $query');
|
|
return SearchResults(users: [], tags: [], posts: []);
|
|
}
|
|
|
|
try {
|
|
if (kDebugMode) print('[API] Searching for: $sanitizedQuery');
|
|
final data = await callGoApi(
|
|
'/search',
|
|
method: 'GET',
|
|
queryParams: {'q': sanitizedQuery},
|
|
);
|
|
// if (kDebugMode) print('[API] Search raw response: ${jsonEncode(data)}');
|
|
return SearchResults.fromJson(data);
|
|
} catch (e, stack) {
|
|
if (kDebugMode) {
|
|
}
|
|
// Return empty results on error
|
|
return SearchResults(users: [], tags: [], posts: []);
|
|
}
|
|
}
|
|
|
|
Future<ToneCheckResult> checkTone(String text, {String? imageUrl}) async {
|
|
// Validate and sanitize inputs
|
|
if (text.isEmpty) {
|
|
return ToneCheckResult(
|
|
flagged: false,
|
|
category: null,
|
|
flags: [],
|
|
reason: 'Empty text provided');
|
|
}
|
|
|
|
final sanitizedText = SecurityUtils.limitText(SecurityUtils.sanitizeText(text), maxLength: 2000);
|
|
final sanitizedImageUrl = imageUrl != null ? SecurityUtils.sanitizeUrl(imageUrl) : null;
|
|
|
|
// Check for XSS in text
|
|
if (SecurityUtils.containsXSS(sanitizedText)) {
|
|
return ToneCheckResult(
|
|
flagged: true,
|
|
category: ModerationCategory.nsfw,
|
|
flags: ['xss_detected'],
|
|
reason: 'Potentially dangerous content detected');
|
|
}
|
|
|
|
try {
|
|
final data = await callGoApi(
|
|
'/analysis/tone',
|
|
method: 'POST',
|
|
body: {
|
|
'text': sanitizedText,
|
|
if (sanitizedImageUrl != null) 'image_url': sanitizedImageUrl,
|
|
},
|
|
);
|
|
return ToneCheckResult.fromJson(data);
|
|
} catch (_) {
|
|
// Fallback: allow if analysis fails
|
|
return ToneCheckResult(
|
|
flagged: false,
|
|
category: null,
|
|
flags: [],
|
|
reason: 'Analysis unavailable');
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Notifications & Feed (Missing Methods)
|
|
// =========================================================================
|
|
|
|
Future<List<Post>> getPersonalFeed({
|
|
int limit = 20,
|
|
int offset = 0,
|
|
String? filterType,
|
|
}) async {
|
|
final queryParams = {
|
|
'limit': '$limit',
|
|
'offset': '$offset',
|
|
};
|
|
if (filterType != null) {
|
|
queryParams['type'] = filterType;
|
|
}
|
|
|
|
final data = await _callGoApi(
|
|
'/feed/personal',
|
|
method: 'GET',
|
|
queryParams: queryParams,
|
|
);
|
|
if (data['posts'] != null) {
|
|
return (data['posts'] as List)
|
|
.map((json) => Post.fromJson(json))
|
|
.toList();
|
|
}
|
|
return [];
|
|
}
|
|
|
|
Future<List<Post>> getSojornFeed({int limit = 20, int offset = 0}) async {
|
|
return getPersonalFeed(limit: limit, offset: offset);
|
|
}
|
|
|
|
Future<List<AppNotification>> getNotifications({
|
|
int limit = 20,
|
|
int offset = 0,
|
|
bool includeArchived = false,
|
|
}) async {
|
|
final data = await callGoApi(
|
|
'/notifications',
|
|
method: 'GET',
|
|
queryParams: {
|
|
'limit': '$limit',
|
|
'offset': '$offset',
|
|
'include_archived': '$includeArchived',
|
|
},
|
|
);
|
|
final list = data['notifications'] as List? ?? [];
|
|
return list
|
|
.map((n) => AppNotification.fromJson(n as Map<String, dynamic>))
|
|
.toList();
|
|
}
|
|
|
|
Future<void> markNotificationsAsRead(List<String> ids) async {
|
|
await callGoApi(
|
|
'/notifications/read',
|
|
method: 'POST',
|
|
body: {'ids': ids},
|
|
);
|
|
}
|
|
|
|
Future<void> archiveNotifications(List<String> ids) async {
|
|
await callGoApi(
|
|
'/notifications/archive',
|
|
method: 'POST',
|
|
body: {'ids': ids},
|
|
);
|
|
}
|
|
|
|
Future<void> archiveAllNotifications() async {
|
|
await callGoApi(
|
|
'/notifications/archive-all',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Post Actions (Missing Methods)
|
|
// =========================================================================
|
|
|
|
Future<void> savePost(String postId) async {
|
|
await callGoApi(
|
|
'/posts/$postId/save',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> unsavePost(String postId) async {
|
|
await callGoApi(
|
|
'/posts/$postId/save',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Beacon Actions
|
|
// =========================================================================
|
|
|
|
Future<void> vouchBeacon(String beaconId) async {
|
|
await callGoApi(
|
|
'/beacons/$beaconId/vouch',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> reportBeacon(String beaconId) async {
|
|
await callGoApi(
|
|
'/beacons/$beaconId/report',
|
|
method: 'POST',
|
|
);
|
|
}
|
|
|
|
Future<void> removeBeaconVote(String beaconId) async {
|
|
await callGoApi(
|
|
'/beacons/$beaconId/vouch',
|
|
method: 'DELETE',
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Key Backup & Recovery
|
|
// =========================================================================
|
|
|
|
/// Upload an encrypted backup blob to cloud storage
|
|
/// [encryptedBlob] - The base64 encoded encrypted backup data
|
|
/// [salt] - The base64 encoded salt used for key derivation
|
|
/// [nonce] - The base64 encoded nonce used for encryption
|
|
/// [mac] - The base64 encoded auth tag
|
|
Future<Map<String, dynamic>> uploadBackup({
|
|
required String encryptedBlob,
|
|
required String salt,
|
|
required String nonce,
|
|
required String mac,
|
|
required String deviceName,
|
|
int version = 1,
|
|
}) async {
|
|
return await _callGoApi(
|
|
'/backup/upload',
|
|
method: 'POST',
|
|
body: {
|
|
'encrypted_blob': encryptedBlob,
|
|
'salt': salt,
|
|
'nonce': nonce,
|
|
'mac': mac,
|
|
'device_name': deviceName,
|
|
'version': version,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Download the latest backup from cloud storage
|
|
Future<Map<String, dynamic>?> downloadBackup([String? backupId]) async {
|
|
try {
|
|
final path = backupId != null ? '/backup/download/$backupId' : '/backup/download';
|
|
final data = await _callGoApi(path, method: 'GET');
|
|
return data;
|
|
} catch (e) {
|
|
if (e.toString().contains('404')) {
|
|
return null;
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// List all backups
|
|
Future<List<Map<String, dynamic>>> listBackups() async {
|
|
final data = await _callGoApi('/backup/list', method: 'GET');
|
|
return (data['backups'] as List).cast<Map<String, dynamic>>();
|
|
}
|
|
|
|
/// Delete a backup
|
|
Future<void> deleteBackup(String backupId) async {
|
|
await _callGoApi('/backup/$backupId', method: 'DELETE');
|
|
}
|
|
|
|
/// Get sync code for device pairing
|
|
Future<Map<String, dynamic>> generateSyncCode({
|
|
required String deviceName,
|
|
required String deviceFingerprint,
|
|
}) async {
|
|
return await _callGoApi(
|
|
'/backup/sync/generate-code',
|
|
method: 'POST',
|
|
body: {
|
|
'device_name': deviceName,
|
|
'device_fingerprint': deviceFingerprint,
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Verify sync code
|
|
Future<Map<String, dynamic>> verifySyncCode({
|
|
required String code,
|
|
required String deviceName,
|
|
required String deviceFingerprint,
|
|
}) async {
|
|
return await _callGoApi(
|
|
'/backup/sync/verify-code',
|
|
method: 'POST',
|
|
body: {
|
|
'code': code,
|
|
'device_name': deviceName,
|
|
'device_fingerprint': deviceFingerprint,
|
|
},
|
|
);
|
|
}
|
|
|
|
// Follow System
|
|
// =========================================================================
|
|
|
|
/// Follow a user
|
|
Future<void> followUser(String targetUserId) async {
|
|
await _callGoApi('/users/$targetUserId/follow', method: 'POST');
|
|
}
|
|
|
|
/// Unfollow a user
|
|
Future<void> unfollowUser(String targetUserId) async {
|
|
await _callGoApi('/users/$targetUserId/unfollow', method: 'POST');
|
|
}
|
|
|
|
/// Check if current user follows target user
|
|
Future<bool> isFollowing(String targetUserId) async {
|
|
final data = await _callGoApi('/users/$targetUserId/is-following', method: 'GET');
|
|
return data['is_following'] as bool? ?? false;
|
|
}
|
|
|
|
/// Get mutual followers between current user and target user
|
|
Future<List<Map<String, dynamic>>> getMutualFollowers(String targetUserId) async {
|
|
final data = await _callGoApi('/users/$targetUserId/mutual-followers', method: 'GET');
|
|
return (data['mutual_followers'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
/// Get suggested users to follow
|
|
Future<List<Map<String, dynamic>>> getSuggestedUsers({int limit = 10}) async {
|
|
final data = await _callGoApi('/users/suggested', method: 'GET', queryParams: {'limit': '$limit'});
|
|
return (data['suggestions'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
/// Get list of followers for a user
|
|
Future<List<Map<String, dynamic>>> getFollowers(String userId) async {
|
|
final data = await _callGoApi('/users/$userId/followers', method: 'GET');
|
|
return (data['followers'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
|
|
/// Get list of users that a user follows
|
|
Future<List<Map<String, dynamic>>> getFollowing(String userId) async {
|
|
final data = await _callGoApi('/users/$userId/following', method: 'GET');
|
|
return (data['following'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
|
}
|
|
}
|