Skip to content

Commit

Permalink
feat(thread_publish): Logic of thread publishing
Browse files Browse the repository at this point in the history
  • Loading branch information
realth000 committed Aug 28, 2024
1 parent 3d9ccf1 commit 8cb4522
Show file tree
Hide file tree
Showing 6 changed files with 277 additions and 11 deletions.
19 changes: 19 additions & 0 deletions lib/exceptions/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,22 @@ class ReplyPersonalMessageFailedException extends AppException
/// Constructor.
ReplyPersonalMessageFailedException(String message) : super(message: message);
}

/// Failed to publish a thread.
///
/// Server returned unexpected status code.
@MappableClass()
final class ThreadPublishFailedException extends AppException
with ThreadPublishFailedExceptionMappable {
/// Constructor.
ThreadPublishFailedException(this.code);

/// Unexpected http status code.
final int code;
}

/// Thread published, but the redirect location of published thread page
/// is not found in the response header.
@MappableClass()
final class ThreadPublishLocationNotFoundException extends AppException
with ThreadPublishLocationNotFoundExceptionMappable {}
84 changes: 84 additions & 0 deletions lib/features/thread_publish/bloc/thread_publish_bloc.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import 'package:bloc/bloc.dart';
import 'package:dart_mappable/dart_mappable.dart';
import 'package:tsdm_client/extensions/fp.dart';
import 'package:tsdm_client/features/thread_publish/models/models.dart';
import 'package:tsdm_client/features/thread_publish/repository/thread_pub_repository.dart';
import 'package:tsdm_client/utils/logger.dart';

part 'thread_publish_bloc.mapper.dart';
part 'thread_publish_event.dart';
part 'thread_publish_state.dart';

typedef _Emit = Emitter<ThreadPubState>;

/// Bloc of thread publish.
///
/// * Fetch thread info.
/// * Post thread data.
/// * Save thread data.
final class ThreadPubBloc extends Bloc<ThreadPubEvent, ThreadPubState>
with LoggerMixin {
/// Constructor.
ThreadPubBloc(this._repo)
: super(const ThreadPubState(status: ThreadPubStatus.initial)) {
on<ThreadPubEvent>(
(event, emit) => switch (event) {
ThreadPubFetchInfoRequested(:final fid) => _onFetchInfo(fid, emit),
ThreadPubPostThread(:final info) => _onPostThread(info, emit),
},
);
}

final ThreadPubRepository _repo;

Future<void> _onFetchInfo(String fid, _Emit emit) async {
emit(state.copyWith(status: ThreadPubStatus.loadingInfo));

final docEither = await _repo.prepareInfo(fid).run();
if (docEither.isLeft()) {
handle(docEither.unwrapErr());
emit(state.copyWith(status: ThreadPubStatus.failure));
return;
}

final doc = docEither.unwrap();
final formHash = doc.querySelector('input#formhash')?.attributes['value'];
final postTime = doc.querySelector('input#posttime')?.attributes['value'];

if (formHash == null || postTime == null) {
error('failed to fetch info: form hash or post time not found: '
'formHash=$formHash, postTime=$postTime');
emit(state.copyWith(status: ThreadPubStatus.failure));
return;
}

emit(
state.copyWith(
status: ThreadPubStatus.readyToPost,
forumHash: formHash,
postTime: postTime,
),
);
}

Future<void> _onPostThread(
ThreadPublishInfo info,
_Emit emit,
) async {
emit(state.copyWith(status: ThreadPubStatus.posting));

final urlEither = await _repo.postThread(info).run();
if (urlEither.isLeft()) {
handle(urlEither.unwrapErr());
emit(state.copyWith(status: ThreadPubStatus.failure));
return;
}

emit(
state.copyWith(
status: ThreadPubStatus.success,
redirectUrl: urlEither.unwrap(),
),
);
}
}
30 changes: 30 additions & 0 deletions lib/features/thread_publish/bloc/thread_publish_event.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
part of 'thread_publish_bloc.dart';

/// Base event.
@MappableClass()
sealed class ThreadPubEvent with ThreadPubEventMappable {
/// Constructor.
const ThreadPubEvent();
}

/// Fetch required info for publishing, including form hash, post time and more.
@MappableClass()
final class ThreadPubFetchInfoRequested extends ThreadPubEvent
with ThreadPubFetchInfoRequestedMappable {
/// Constructor.
const ThreadPubFetchInfoRequested({required this.fid});

/// Forum id.
final String fid;
}

/// Post a new thread to forum.
@MappableClass()
final class ThreadPubPostThread extends ThreadPubEvent
with ThreadPubPostThreadMappable {
/// Constructor.
const ThreadPubPostThread(this.info);

/// All info to post in body.
final ThreadPublishInfo info;
}
46 changes: 46 additions & 0 deletions lib/features/thread_publish/bloc/thread_publish_state.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
part of 'thread_publish_bloc.dart';

/// Status of thread publish.
enum ThreadPubStatus {
/// Initial state.
initial,

/// Loading form info required in posting thread.
loadingInfo,

/// Info fetched and waiting for post action.
readyToPost,

/// Posting data.
posting,

/// Post data success.
success,

/// Failed to load info or post thread.
failure,
}

/// State of thread publish
@MappableClass()
final class ThreadPubState with ThreadPubStateMappable {
/// Constructor.
const ThreadPubState({
required this.status,
this.forumHash,
this.postTime,
this.redirectUrl,
});

/// Status.
final ThreadPubStatus status;

/// Form hash.
final String? forumHash;

/// Thread post time.
final String? postTime;

/// Url of the published thread page when publish succeed.
final String? redirectUrl;
}
63 changes: 63 additions & 0 deletions lib/features/thread_publish/repository/thread_pub_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import 'dart:io' if (dart.library.js) 'package:web/web.dart';

import 'package:fpdart/fpdart.dart';
import 'package:tsdm_client/constants/url.dart';
import 'package:tsdm_client/exceptions/exceptions.dart';
import 'package:tsdm_client/features/thread_publish/models/models.dart';
import 'package:tsdm_client/instance.dart';
import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart';
import 'package:tsdm_client/utils/logger.dart';
import 'package:universal_html/html.dart' as uh;
import 'package:universal_html/parsing.dart';

/// Repository for thread publish
///
/// * Fetch info when preparing page.
/// * Post new thread content to server.
final class ThreadPubRepository with LoggerMixin {
/// Constructor.
const ThreadPubRepository();

static String _buildInfoUrl(String fid) =>
'$homePage?mod=post&action=newthread&fid=$fid';

static String _buildPostUrl(String fid) =>
'$homePage?mod=post&action=newthread&fid=$fid&extra=&topicsubmit=yes';

/// Fetch required info that used in posting new thread.
///
/// This step is far before posting final thread content to server.
AsyncEither<uh.Document> prepareInfo(String fid) => getIt
.get<NetClientProvider>()
.get(_buildInfoUrl(fid))
.mapHttp((v) => parseHtmlDocument(v.data as String));

/// Post new thread data to server.
///
/// Generally the serer will response a status code of 301 with location in
/// header to redirect to published thread page.
AsyncEither<String> postThread(ThreadPublishInfo info) =>
AsyncEither(() async {
switch (await getIt
.get<NetClientProvider>()
.get(_buildPostUrl(info.fid))
.run()) {
case Left(:final value):
return left(value);
case Right(:final value)
when value.statusCode != HttpStatus.movedPermanently:
return left(ThreadPublishFailedException(value.statusCode!));
case Right(:final value):
if (value.headers.map.containsKey(HttpHeaders.locationHeader)) {
error('location header not found in response');
return left(ThreadPublishLocationNotFoundException());
}
final locations = value.headers.map[HttpHeaders.locationHeader];
if (locations?.isEmpty ?? true) {
error('empty location header');
return left(ThreadPublishLocationNotFoundException());
}
return right(locations!.first);
}
});
}
46 changes: 35 additions & 11 deletions lib/features/thread_publish/views/thread_publish_page.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:tsdm_client/features/thread_publish/bloc/thread_publish_bloc.dart';
import 'package:tsdm_client/features/thread_publish/repository/thread_pub_repository.dart';
import 'package:tsdm_client/i18n/strings.g.dart';
import 'package:tsdm_client/routes/screen_paths.dart';
import 'package:tsdm_client/utils/show_toast.dart';
import 'package:tsdm_client/widgets/list_app_bar.dart';

import '../../../routes/screen_paths.dart';

/// Page to publish new thread.
///
/// Similar to post edit page, especially when editing the first floor, but
Expand Down Expand Up @@ -31,17 +35,37 @@ class ThreadPublishPage extends StatefulWidget {
class _ThreadPublishPageState extends State<ThreadPublishPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: ListAppBar(
title: 'publish new thread',
onSearch: () async {
await context.pushNamed(
ScreenPaths.search,
queryParameters: {'fid': widget.fid},
);
return MultiBlocProvider(
providers: [
RepositoryProvider(create: (_) => const ThreadPubRepository()),
BlocProvider(
create: (context) => ThreadPubBloc(RepositoryProvider.of(context)),
),
],
child: BlocListener<ThreadPubBloc, ThreadPubState>(
listener: (context, state) {
if (state.status == ThreadPubStatus.failure) {
showFailedToLoadSnackBar(context);
}
},
child: BlocBuilder(
builder: (context, state) {
// TODO: Page body.
return Scaffold(
appBar: ListAppBar(
title: context.t.threadPublishPage.title,
onSearch: () async {
await context.pushNamed(
ScreenPaths.search,
queryParameters: {'fid': widget.fid},
);
},
),
body: Center(child: Text('FID=${widget.fid}')),
);
},
),
),
body: Center(child: Text('FID=${widget.fid}')),
);
}
}

0 comments on commit 8cb4522

Please sign in to comment.