mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: tabs shortcuts (#3112)
This commit is contained in:
parent
923285bfcf
commit
35a47bfe5d
@ -50,7 +50,7 @@ void main() {
|
||||
await tester.tapFirstDayOfWeekStartFromMonday();
|
||||
|
||||
// Open the other page and open the new calendar page again
|
||||
await tester.openPage(gettingStated);
|
||||
await tester.openPage(gettingStarted);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 300));
|
||||
await tester.openPage(name, layout: ViewLayoutPB.Calendar);
|
||||
|
||||
|
@ -34,20 +34,20 @@ void main() {
|
||||
|
||||
// delete the readme page
|
||||
await tester.hoverOnPageName(
|
||||
gettingStated,
|
||||
gettingStarted,
|
||||
onHover: () async => await tester.tapDeletePageButton(),
|
||||
);
|
||||
|
||||
// the banner should show up and the readme page should be gone
|
||||
tester.expectToSeeDocumentBanner();
|
||||
tester.expectNotToSeePageName(gettingStated);
|
||||
tester.expectNotToSeePageName(gettingStarted);
|
||||
|
||||
// restore the readme page
|
||||
await tester.tapRestoreButton();
|
||||
|
||||
// the banner should be gone and the readme page should be back
|
||||
tester.expectNotToSeeDocumentBanner();
|
||||
tester.expectToSeePageName(gettingStated);
|
||||
tester.expectToSeePageName(gettingStarted);
|
||||
});
|
||||
|
||||
testWidgets('delete the readme page and delete it permanently',
|
||||
@ -58,20 +58,20 @@ void main() {
|
||||
|
||||
// delete the readme page
|
||||
await tester.hoverOnPageName(
|
||||
gettingStated,
|
||||
gettingStarted,
|
||||
onHover: () async => await tester.tapDeletePageButton(),
|
||||
);
|
||||
|
||||
// the banner should show up and the readme page should be gone
|
||||
tester.expectToSeeDocumentBanner();
|
||||
tester.expectNotToSeePageName(gettingStated);
|
||||
tester.expectNotToSeePageName(gettingStarted);
|
||||
|
||||
// delete the page permanently
|
||||
await tester.tapDeletePermanentlyButton();
|
||||
|
||||
// the banner should be gone and the readme page should be gone
|
||||
tester.expectNotToSeeDocumentBanner();
|
||||
tester.expectNotToSeePageName(gettingStated);
|
||||
tester.expectNotToSeePageName(gettingStarted);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -59,7 +59,7 @@ void main() {
|
||||
);
|
||||
|
||||
// switch to other page and switch back
|
||||
await tester.openPage(gettingStated);
|
||||
await tester.openPage(gettingStarted);
|
||||
await tester.openPage(pageName);
|
||||
|
||||
// the numbered list should be kept
|
||||
@ -91,7 +91,7 @@ void main() {
|
||||
}
|
||||
|
||||
// switch to other page and switch back
|
||||
await tester.openPage(gettingStated);
|
||||
await tester.openPage(gettingStarted);
|
||||
await tester.openPage(pageName);
|
||||
|
||||
// this screenshots are different on different platform, so comment it out temporarily.
|
||||
|
@ -17,7 +17,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// expect to see a getting started page
|
||||
tester.expectToSeePageName(gettingStated);
|
||||
tester.expectToSeePageName(gettingStarted);
|
||||
|
||||
await tester.tapAddViewButton();
|
||||
await tester.tapImportButton();
|
||||
|
@ -16,6 +16,7 @@ import 'share_markdown_test.dart' as share_markdown_test;
|
||||
import 'switch_folder_test.dart' as switch_folder_test;
|
||||
import 'sidebar/sidebar_test_runner.dart' as sidebar_test_runner;
|
||||
import 'board/board_test_runner.dart' as board_test_runner;
|
||||
import 'tabs_test.dart' as tabs_test;
|
||||
|
||||
/// The main task runner for all integration tests in AppFlowy.
|
||||
///
|
||||
@ -51,6 +52,9 @@ void main() {
|
||||
database_view_test.main();
|
||||
database_calendar_test.main();
|
||||
|
||||
// Tabs
|
||||
tabs_test.main();
|
||||
|
||||
// board_test.main();
|
||||
// empty_document_test.main();
|
||||
// smart_menu_test.main();
|
||||
|
@ -16,7 +16,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// expect to see a readme page
|
||||
tester.expectToSeePageName(gettingStated);
|
||||
tester.expectToSeePageName(gettingStarted);
|
||||
|
||||
// mock the file picker
|
||||
final path = await mockSaveFilePath(
|
||||
@ -43,11 +43,11 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
// expect to see a getting started page
|
||||
tester.expectToSeePageName(gettingStated);
|
||||
tester.expectToSeePageName(gettingStarted);
|
||||
|
||||
// rename the document
|
||||
await tester.hoverOnPageName(
|
||||
gettingStated,
|
||||
gettingStarted,
|
||||
onHover: () async {
|
||||
await tester.renamePage('example');
|
||||
},
|
||||
|
@ -30,7 +30,7 @@ void main() {
|
||||
2,
|
||||
].map((e) => 'document_$e').toList();
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
final parentName = i == 0 ? gettingStated : names[i - 1];
|
||||
final parentName = i == 0 ? gettingStarted : names[i - 1];
|
||||
await tester.createNewPageWithName(
|
||||
name: names[i],
|
||||
parentName: parentName,
|
||||
@ -44,9 +44,9 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
await tester.favoriteViewByName(gettingStated);
|
||||
await tester.favoriteViewByName(gettingStarted);
|
||||
expect(
|
||||
tester.findFavoritePageName(gettingStated),
|
||||
tester.findFavoritePageName(gettingStarted),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
@ -56,9 +56,9 @@ void main() {
|
||||
findsNWidgets(2),
|
||||
);
|
||||
|
||||
await tester.unfavoriteViewByName(gettingStated);
|
||||
await tester.unfavoriteViewByName(gettingStarted);
|
||||
expect(
|
||||
tester.findFavoritePageName(gettingStated),
|
||||
tester.findFavoritePageName(gettingStarted),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
@ -84,9 +84,9 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
const name = 'test';
|
||||
await tester.favoriteViewByName(gettingStated);
|
||||
await tester.favoriteViewByName(gettingStarted);
|
||||
await tester.hoverOnPageName(
|
||||
gettingStated,
|
||||
gettingStarted,
|
||||
layout: ViewLayoutPB.Document,
|
||||
onHover: () async {
|
||||
await tester.renamePage(name);
|
||||
@ -112,7 +112,7 @@ void main() {
|
||||
|
||||
final names = [1, 2].map((e) => 'document_$e').toList();
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
final parentName = i == 0 ? gettingStated : names[i - 1];
|
||||
final parentName = i == 0 ? gettingStarted : names[i - 1];
|
||||
await tester.createNewPageWithName(
|
||||
name: names[i],
|
||||
parentName: parentName,
|
||||
@ -120,7 +120,7 @@ void main() {
|
||||
);
|
||||
tester.expectToSeePageName(names[i], parentName: parentName);
|
||||
}
|
||||
await tester.favoriteViewByName(gettingStated);
|
||||
await tester.favoriteViewByName(gettingStarted);
|
||||
await tester.favoriteViewByName(names[0]);
|
||||
await tester.favoriteViewByName(names[1]);
|
||||
|
||||
@ -154,7 +154,7 @@ void main() {
|
||||
);
|
||||
|
||||
await tester.hoverOnPageName(
|
||||
gettingStated,
|
||||
gettingStarted,
|
||||
layout: ViewLayoutPB.Document,
|
||||
onHover: () async {
|
||||
await tester.tapDeletePageButton();
|
||||
@ -181,7 +181,7 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.createNewPageWithName();
|
||||
await tester.favoriteViewByName(gettingStated);
|
||||
await tester.favoriteViewByName(gettingStarted);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
@ -201,9 +201,9 @@ void main() {
|
||||
await tester.tapGoButton();
|
||||
|
||||
await tester.createNewPageWithName();
|
||||
await tester.favoriteViewByName(gettingStated);
|
||||
await tester.favoriteViewByName(gettingStarted);
|
||||
await tester.hoverOnPageName(
|
||||
gettingStated,
|
||||
gettingStarted,
|
||||
layout: ViewLayoutPB.Document,
|
||||
useLast: false,
|
||||
onHover: () async {
|
||||
|
@ -70,7 +70,7 @@ void main() {
|
||||
break;
|
||||
}
|
||||
|
||||
await tester.openPage(gettingStated);
|
||||
await tester.openPage(gettingStarted);
|
||||
}
|
||||
});
|
||||
|
||||
@ -80,7 +80,7 @@ void main() {
|
||||
|
||||
final names = [1, 2, 3, 4].map((e) => 'document_$e').toList();
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
final parentName = i == 0 ? gettingStated : names[i - 1];
|
||||
final parentName = i == 0 ? gettingStarted : names[i - 1];
|
||||
await tester.createNewPageWithName(
|
||||
name: names[i],
|
||||
parentName: parentName,
|
||||
@ -92,7 +92,7 @@ void main() {
|
||||
// move the document_3 to the getting started page
|
||||
await tester.movePageToOtherPage(
|
||||
name: names[3],
|
||||
parentName: gettingStated,
|
||||
parentName: gettingStarted,
|
||||
layout: ViewLayoutPB.Document,
|
||||
parentLayout: ViewLayoutPB.Document,
|
||||
);
|
||||
@ -101,7 +101,7 @@ void main() {
|
||||
.view
|
||||
.parentViewId;
|
||||
final toId = tester
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(gettingStated))
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(gettingStarted))
|
||||
.view
|
||||
.id;
|
||||
expect(fromId, toId);
|
||||
@ -109,13 +109,13 @@ void main() {
|
||||
// move the document_2 before document_1
|
||||
await tester.movePageToOtherPage(
|
||||
name: names[2],
|
||||
parentName: gettingStated,
|
||||
parentName: gettingStarted,
|
||||
layout: ViewLayoutPB.Document,
|
||||
parentLayout: ViewLayoutPB.Document,
|
||||
position: DraggableHoverPosition.bottom,
|
||||
);
|
||||
final childViews = tester
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(gettingStated))
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(gettingStarted))
|
||||
.view
|
||||
.childViews;
|
||||
expect(
|
||||
@ -170,7 +170,7 @@ void main() {
|
||||
|
||||
// it should not be moved
|
||||
final childViews = tester
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(gettingStated))
|
||||
.widget<SingleInnerViewItem>(tester.findPageName(gettingStarted))
|
||||
.view
|
||||
.childViews;
|
||||
expect(
|
||||
|
@ -1,23 +1,26 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'util/base.dart';
|
||||
import 'util/common_operations.dart';
|
||||
import 'util/expectation.dart';
|
||||
import 'util/keyboard.dart';
|
||||
|
||||
const _readmeName = 'Read me';
|
||||
const _documentName = 'Document';
|
||||
const _calendarName = 'Calendar';
|
||||
const _documentName = 'First Doc';
|
||||
const _documentTwoName = 'Second Doc';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Tabs', () {
|
||||
testWidgets('Open AppFlowy and open/navigate multiple tabs',
|
||||
(tester) async {
|
||||
testWidgets('Open AppFlowy and open/navigate/close tabs', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapGoButton();
|
||||
|
||||
@ -29,31 +32,21 @@ void main() {
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.createNewPageWithName(
|
||||
name: _calendarName,
|
||||
layout: ViewLayoutPB.Calendar,
|
||||
);
|
||||
await tester.createNewPageWithName(
|
||||
name: _documentName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
// Navigate current view to "Read me" document again
|
||||
await tester.tapButtonWithName(_readmeName);
|
||||
await tester.createNewPageWithName(
|
||||
name: _documentTwoName,
|
||||
layout: ViewLayoutPB.Document,
|
||||
);
|
||||
|
||||
/// Open second menu item in a new tab
|
||||
await tester.openAppInNewTab(_calendarName);
|
||||
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
|
||||
|
||||
/// Open third menu item in a new tab
|
||||
await tester.openAppInNewTab(_documentName);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabsManager),
|
||||
matching: find.byType(TabBar),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
@ -63,13 +56,32 @@ void main() {
|
||||
findsNWidgets(3),
|
||||
);
|
||||
|
||||
/// Navigate to the first tab
|
||||
/// Navigate to the second tab
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(FlowyTab),
|
||||
matching: find.text(_readmeName),
|
||||
matching: find.text(gettingStarted),
|
||||
),
|
||||
);
|
||||
|
||||
/// Close tab by shortcut
|
||||
await FlowyTestKeyboard.simulateKeyDownEvent(
|
||||
[
|
||||
Platform.isMacOS
|
||||
? LogicalKeyboardKey.meta
|
||||
: LogicalKeyboardKey.control,
|
||||
LogicalKeyboardKey.keyW,
|
||||
],
|
||||
tester: tester,
|
||||
);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(TabBar),
|
||||
matching: find.byType(FlowyTab),
|
||||
),
|
||||
findsNWidgets(2),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
|
||||
import 'package:appflowy/user/presentation/skip_log_in_screen.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
|
||||
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@ -29,7 +28,7 @@ extension CommonOperations on WidgetTester {
|
||||
|
||||
/// Tap the + button on the home page.
|
||||
Future<void> tapAddViewButton({
|
||||
String name = gettingStated,
|
||||
String name = gettingStarted,
|
||||
}) async {
|
||||
await hoverOnPageName(
|
||||
name,
|
||||
@ -211,6 +210,12 @@ extension CommonOperations on WidgetTester {
|
||||
await tapButtonWithName(ViewMoreActionType.unFavorite.name);
|
||||
}
|
||||
|
||||
/// Tap the Open in a new tab button
|
||||
Future<void> tapOpenInTabButton() async {
|
||||
await tapPageOptionButton();
|
||||
await tapButtonWithName(ViewMoreActionType.openInNewTab.name);
|
||||
}
|
||||
|
||||
/// Rename the page.
|
||||
Future<void> renamePage(String name) async {
|
||||
await tapRenamePageButton();
|
||||
@ -272,7 +277,7 @@ extension CommonOperations on WidgetTester {
|
||||
bool openAfterCreated = true,
|
||||
}) async {
|
||||
// create a new page
|
||||
await tapAddViewButton(name: parentName ?? gettingStated);
|
||||
await tapAddViewButton(name: parentName ?? gettingStarted);
|
||||
await tapButtonWithName(layout.menuName);
|
||||
await pumpAndSettle();
|
||||
|
||||
@ -336,11 +341,14 @@ extension CommonOperations on WidgetTester {
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> openAppInNewTab(String name) async {
|
||||
await hoverOnPageName(name);
|
||||
await tap(find.byType(ViewDisclosureButton));
|
||||
await pumpAndSettle();
|
||||
await tap(find.text(LocaleKeys.disclosureAction_openNewTab.tr()));
|
||||
Future<void> openAppInNewTab(String name, ViewLayoutPB layout) async {
|
||||
await hoverOnPageName(
|
||||
name,
|
||||
onHover: () async {
|
||||
await tapOpenInTabButton();
|
||||
await pumpAndSettle();
|
||||
},
|
||||
);
|
||||
await pumpAndSettle();
|
||||
}
|
||||
|
||||
|
@ -76,7 +76,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
|
||||
await tapGoButton();
|
||||
|
||||
// expect to see a readme page
|
||||
expectToSeePageName(gettingStated);
|
||||
expectToSeePageName(gettingStarted);
|
||||
|
||||
await tapAddViewButton();
|
||||
await tapImportButton();
|
||||
|
@ -11,13 +11,13 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// const String readme = 'Read me';
|
||||
const String gettingStated = '⭐️ Getting started';
|
||||
const String gettingStarted = '⭐️ Getting started';
|
||||
|
||||
extension Expectation on WidgetTester {
|
||||
/// Expect to see the home page and with a default read me page.
|
||||
void expectToSeeHomePage() {
|
||||
expect(find.byType(HomeStack), findsOneWidget);
|
||||
expect(find.textContaining(gettingStated), findsWidgets);
|
||||
expect(find.textContaining(gettingStarted), findsWidgets);
|
||||
}
|
||||
|
||||
/// Expect to see the page name on the home page.
|
||||
|
@ -8,4 +8,12 @@ extension RawKeyboardExtension on RawKeyboard {
|
||||
LogicalKeyboardKey.altRight,
|
||||
].contains(key),
|
||||
);
|
||||
|
||||
bool get isControlPressed => keysPressed.any(
|
||||
(key) => [
|
||||
LogicalKeyboardKey.control,
|
||||
LogicalKeyboardKey.controlLeft,
|
||||
LogicalKeyboardKey.controlRight,
|
||||
].contains(key),
|
||||
);
|
||||
}
|
||||
|
@ -35,9 +35,6 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
|
||||
_listener.start(appsChanged: _handleAppsOrFail);
|
||||
await _fetchApps(emit);
|
||||
},
|
||||
openPage: (e) async {
|
||||
emit(state.copyWith(plugin: e.plugin));
|
||||
},
|
||||
createApp: (_CreateApp event) async {
|
||||
final result = await _workspaceService.createApp(
|
||||
name: event.name,
|
||||
@ -110,7 +107,6 @@ class MenuBloc extends Bloc<MenuEvent, MenuState> {
|
||||
@freezed
|
||||
class MenuEvent with _$MenuEvent {
|
||||
const factory MenuEvent.initial() = _Initial;
|
||||
const factory MenuEvent.openPage(Plugin plugin) = _OpenPage;
|
||||
const factory MenuEvent.createApp(String name, {String? desc, int? index}) =
|
||||
_CreateApp;
|
||||
const factory MenuEvent.moveApp(int fromIndex, int toIndex) = _MoveApp;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:appflowy/plugins/util.dart';
|
||||
import 'package:appflowy/startup/plugin/plugin.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
@ -21,7 +22,9 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
|
||||
on<TabsEvent>((event, emit) async {
|
||||
event.when(
|
||||
selectTab: (int index) {
|
||||
if (index != state.currentIndex) {
|
||||
if (index != state.currentIndex &&
|
||||
index >= 0 &&
|
||||
index < state.pages) {
|
||||
emit(state.copyWith(newIndex: index));
|
||||
_setLatestOpenView();
|
||||
}
|
||||
@ -31,6 +34,10 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
|
||||
emit(state.closeView(pluginId));
|
||||
_setLatestOpenView();
|
||||
},
|
||||
closeCurrentTab: () {
|
||||
emit(state.closeView(state.currentPageManager.plugin.id));
|
||||
_setLatestOpenView();
|
||||
},
|
||||
openTab: (Plugin plugin, ViewPB view) {
|
||||
emit(state.openView(plugin, view));
|
||||
_setLatestOpenView(view);
|
||||
@ -54,4 +61,12 @@ class TabsBloc extends Bloc<TabsEvent, TabsState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a [TabsEvent.openTab] event for the provided [ViewPB]
|
||||
void openTab(ViewPB view) =>
|
||||
add(TabsEvent.openTab(plugin: view.plugin(), view: view));
|
||||
|
||||
/// Adds a [TabsEvent.openPlugin] event for the provided [ViewPB]
|
||||
void openPlugin(ViewPB view) =>
|
||||
add(TabsEvent.openPlugin(plugin: view.plugin(), view: view));
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ part of 'tabs_bloc.dart';
|
||||
class TabsEvent with _$TabsEvent {
|
||||
const factory TabsEvent.moveTab() = _MoveTab;
|
||||
const factory TabsEvent.closeTab(String pluginId) = _CloseTab;
|
||||
const factory TabsEvent.closeCurrentTab() = _CloseCurrentTab;
|
||||
const factory TabsEvent.selectTab(int index) = _SelectTab;
|
||||
const factory TabsEvent.openTab({
|
||||
required Plugin plugin,
|
||||
|
@ -31,6 +31,11 @@ class TabsState {
|
||||
}
|
||||
|
||||
TabsState closeView(String pluginId) {
|
||||
// Avoid closing the only open tab
|
||||
if (_pageManagers.length == 1) {
|
||||
return this;
|
||||
}
|
||||
|
||||
_pageManagers.removeWhere((pm) => pm.plugin.id == pluginId);
|
||||
|
||||
/// If currentIndex is greater than the amount of allowed indices
|
||||
@ -79,6 +84,10 @@ class TabsState {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (index == currentIndex) {
|
||||
return this;
|
||||
}
|
||||
|
||||
return copyWith(newIndex: index);
|
||||
}
|
||||
|
||||
|
@ -112,9 +112,13 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
|
||||
ext: {},
|
||||
openAfterCreate: e.openAfterCreated,
|
||||
);
|
||||
|
||||
emit(
|
||||
result.fold(
|
||||
(l) => state.copyWith(successOrFailure: left(unit)),
|
||||
(view) => state.copyWith(
|
||||
lastCreatedView: view,
|
||||
successOrFailure: left(unit),
|
||||
),
|
||||
(error) => state.copyWith(successOrFailure: right(error)),
|
||||
),
|
||||
);
|
||||
@ -218,6 +222,7 @@ class ViewState with _$ViewState {
|
||||
required bool isEditing,
|
||||
required bool isExpanded,
|
||||
required Either<Unit, FlowyError> successOrFailure,
|
||||
@Default(null) ViewPB? lastCreatedView,
|
||||
}) = _ViewState;
|
||||
|
||||
factory ViewState.init(ViewPB view) => ViewState(
|
||||
@ -226,5 +231,6 @@ class ViewState with _$ViewState {
|
||||
isExpanded: false,
|
||||
isEditing: false,
|
||||
successOrFailure: left(unit),
|
||||
lastCreatedView: null,
|
||||
);
|
||||
}
|
||||
|
@ -2,45 +2,102 @@ import 'dart:io';
|
||||
|
||||
import 'package:appflowy/workspace/application/appearance.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
typedef KeyDownHandler = void Function(HotKey hotKey);
|
||||
|
||||
/// Helper class that utilizes the global [HotKeyManager] to easily
|
||||
/// add a [HotKey] with different handlers.
|
||||
///
|
||||
/// Makes registration of a [HotKey] simple and easy to read, and makes
|
||||
/// sure the [KeyDownHandler], and other handlers, are grouped with the
|
||||
/// relevant [HotKey].
|
||||
///
|
||||
class HotKeyItem {
|
||||
final HotKey hotKey;
|
||||
final KeyDownHandler? keyDownHandler;
|
||||
|
||||
HotKeyItem({
|
||||
required this.hotKey,
|
||||
this.keyDownHandler,
|
||||
});
|
||||
|
||||
void register() =>
|
||||
hotKeyManager.register(hotKey, keyDownHandler: keyDownHandler);
|
||||
}
|
||||
|
||||
class HomeHotKeys extends StatelessWidget {
|
||||
final Widget child;
|
||||
const HomeHotKeys({required this.child, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final HotKey hotKey = HotKey(
|
||||
KeyCode.backslash,
|
||||
modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
|
||||
// Set hotkey scope (default is HotKeyScope.system)
|
||||
scope: HotKeyScope.inapp, // Set as inapp-wide hotkey.
|
||||
);
|
||||
hotKeyManager.register(
|
||||
hotKey,
|
||||
keyDownHandler: (hotKey) {
|
||||
context
|
||||
.read<HomeSettingBloc>()
|
||||
.add(const HomeSettingEvent.collapseMenu());
|
||||
},
|
||||
);
|
||||
// Collapse sidebar menu
|
||||
HotKeyItem(
|
||||
hotKey: HotKey(
|
||||
KeyCode.backslash,
|
||||
modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
|
||||
// Set hotkey scope (default is HotKeyScope.system)
|
||||
scope: HotKeyScope.inapp, // Set as inapp-wide hotkey.
|
||||
),
|
||||
keyDownHandler: (_) => context
|
||||
.read<HomeSettingBloc>()
|
||||
.add(const HomeSettingEvent.collapseMenu()),
|
||||
).register();
|
||||
|
||||
// Toggle theme mode light/dark
|
||||
HotKeyItem(
|
||||
hotKey: HotKey(
|
||||
KeyCode.keyL,
|
||||
modifiers: [
|
||||
Platform.isMacOS ? KeyModifier.meta : KeyModifier.control,
|
||||
KeyModifier.shift,
|
||||
],
|
||||
scope: HotKeyScope.inapp,
|
||||
),
|
||||
keyDownHandler: (_) =>
|
||||
context.read<AppearanceSettingsCubit>().toggleThemeMode(),
|
||||
).register();
|
||||
|
||||
// Close current tab
|
||||
HotKeyItem(
|
||||
hotKey: HotKey(
|
||||
KeyCode.keyW,
|
||||
modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
|
||||
scope: HotKeyScope.inapp,
|
||||
),
|
||||
keyDownHandler: (_) =>
|
||||
context.read<TabsBloc>().add(const TabsEvent.closeCurrentTab()),
|
||||
).register();
|
||||
|
||||
// Go to previous tab
|
||||
HotKeyItem(
|
||||
hotKey: HotKey(
|
||||
KeyCode.pageUp,
|
||||
modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
|
||||
scope: HotKeyScope.inapp,
|
||||
),
|
||||
keyDownHandler: (_) => _selectTab(context, -1),
|
||||
).register();
|
||||
|
||||
// Go to next tab
|
||||
HotKeyItem(
|
||||
hotKey: HotKey(
|
||||
KeyCode.pageDown,
|
||||
modifiers: [Platform.isMacOS ? KeyModifier.meta : KeyModifier.control],
|
||||
scope: HotKeyScope.inapp,
|
||||
),
|
||||
keyDownHandler: (_) => _selectTab(context, 1),
|
||||
).register();
|
||||
|
||||
final HotKey hotKeyForToggleThemeMode = HotKey(
|
||||
KeyCode.keyL,
|
||||
modifiers: [
|
||||
Platform.isMacOS ? KeyModifier.meta : KeyModifier.control,
|
||||
KeyModifier.shift,
|
||||
],
|
||||
scope: HotKeyScope.inapp,
|
||||
);
|
||||
hotKeyManager.register(
|
||||
hotKeyForToggleThemeMode,
|
||||
keyDownHandler: (_) {
|
||||
context.read<AppearanceSettingsCubit>().toggleThemeMode();
|
||||
},
|
||||
);
|
||||
return child;
|
||||
}
|
||||
|
||||
void _selectTab(BuildContext context, int change) {
|
||||
final bloc = context.read<TabsBloc>();
|
||||
bloc.add(TabsEvent.selectTab(bloc.state.currentIndex + change));
|
||||
}
|
||||
}
|
||||
|
@ -123,12 +123,7 @@ class ViewSectionItem extends StatelessWidget {
|
||||
.add(FavoriteEvent.toggle(view));
|
||||
break;
|
||||
case ViewDisclosureAction.openInNewTab:
|
||||
blocContext.read<TabsBloc>().add(
|
||||
TabsEvent.openTab(
|
||||
plugin: state.view.plugin(),
|
||||
view: blocContext.read<ViewBloc>().state.view,
|
||||
),
|
||||
);
|
||||
blocContext.read<TabsBloc>().openTab(state.view);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
@ -7,7 +7,6 @@ import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
|
||||
@ -49,34 +48,18 @@ class HomeMenu extends StatelessWidget {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<MenuBloc>(
|
||||
create: (context) {
|
||||
final menuBloc = MenuBloc(
|
||||
user: user,
|
||||
workspace: workspaceSetting.workspace,
|
||||
);
|
||||
menuBloc.add(const MenuEvent.initial());
|
||||
return menuBloc;
|
||||
},
|
||||
create: (context) => MenuBloc(
|
||||
user: user,
|
||||
workspace: workspaceSetting.workspace,
|
||||
)..add(const MenuEvent.initial()),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (ctx) =>
|
||||
create: (context) =>
|
||||
getIt<FavoriteBloc>()..add(const FavoriteEvent.initial()),
|
||||
)
|
||||
],
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<MenuBloc, MenuState>(
|
||||
listenWhen: (p, c) => p.plugin.id != c.plugin.id,
|
||||
listener: (context, state) {
|
||||
getIt<TabsBloc>().add(
|
||||
TabsEvent.openPlugin(plugin: state.plugin),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<MenuBloc, MenuState>(
|
||||
builder: (context, state) => _renderBody(context),
|
||||
),
|
||||
child: BlocBuilder<MenuBloc, MenuState>(
|
||||
builder: (context, state) => _renderBody(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,14 +1,13 @@
|
||||
import 'package:appflowy/core/raw_keyboard_extension.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/menu.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra_ui/style_widget/button.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class FavoriteFolder extends StatelessWidget {
|
||||
@ -24,6 +23,7 @@ class FavoriteFolder extends StatelessWidget {
|
||||
if (views.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocProvider<FolderBloc>(
|
||||
create: (context) => FolderBloc(type: FolderCategoryType.favorite)
|
||||
..add(
|
||||
@ -54,11 +54,14 @@ class FavoriteFolder extends StatelessWidget {
|
||||
view: view,
|
||||
level: 0,
|
||||
onSelected: (view) {
|
||||
getIt<MenuSharedState>().latestOpenView = view;
|
||||
context
|
||||
.read<MenuBloc>()
|
||||
.add(MenuEvent.openPage(view.plugin()));
|
||||
if (RawKeyboard.instance.isControlPressed) {
|
||||
context.read<TabsBloc>().openTab(view);
|
||||
}
|
||||
|
||||
context.read<TabsBloc>().openPlugin(view);
|
||||
},
|
||||
onTertiarySelected: (view) =>
|
||||
context.read<TabsBloc>().openTab(view),
|
||||
),
|
||||
)
|
||||
],
|
||||
|
@ -1,15 +1,15 @@
|
||||
import 'package:appflowy/core/raw_keyboard_extension.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_ext.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flowy_infra/image.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class PersonalFolder extends StatelessWidget {
|
||||
@ -52,13 +52,14 @@ class PersonalFolder extends StatelessWidget {
|
||||
leftPadding: 16,
|
||||
isFeedback: false,
|
||||
onSelected: (view) {
|
||||
getIt<TabsBloc>().add(
|
||||
TabsEvent.openPlugin(
|
||||
plugin: view.plugin(),
|
||||
view: view,
|
||||
),
|
||||
);
|
||||
if (RawKeyboard.instance.isControlPressed) {
|
||||
context.read<TabsBloc>().openTab(view);
|
||||
}
|
||||
|
||||
context.read<TabsBloc>().openPlugin(view);
|
||||
},
|
||||
onTertiarySelected: (view) =>
|
||||
context.read<TabsBloc>().openTab(view),
|
||||
),
|
||||
)
|
||||
],
|
||||
|
@ -1,4 +1,3 @@
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/menu/menu_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
|
||||
@ -7,6 +6,7 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_pa
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_top_menu.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_trash.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_user.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
|
||||
show UserProfilePB;
|
||||
@ -48,17 +48,18 @@ class HomeSideBar extends StatelessWidget {
|
||||
],
|
||||
child: BlocListener<MenuBloc, MenuState>(
|
||||
listenWhen: (p, c) => p.plugin.id != c.plugin.id,
|
||||
listener: (context, state) => getIt<TabsBloc>().add(
|
||||
TabsEvent.openPlugin(plugin: state.plugin),
|
||||
),
|
||||
listener: (context, state) => context
|
||||
.read<TabsBloc>()
|
||||
.add(TabsEvent.openPlugin(plugin: state.plugin)),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final menuState = context.watch<MenuBloc>().state;
|
||||
final favoriteState = context.watch<FavoriteBloc>().state;
|
||||
|
||||
return _buildSidebar(
|
||||
context,
|
||||
menuState,
|
||||
favoriteState,
|
||||
menuState.views,
|
||||
favoriteState.views,
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -68,11 +69,10 @@ class HomeSideBar extends StatelessWidget {
|
||||
|
||||
Widget _buildSidebar(
|
||||
BuildContext context,
|
||||
MenuState state,
|
||||
FavoriteState favoriteState,
|
||||
List<ViewPB> views,
|
||||
List<ViewPB> favoriteViews,
|
||||
) {
|
||||
final views = state.views;
|
||||
return Container(
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
border: Border(
|
||||
@ -95,7 +95,7 @@ class HomeSideBar extends StatelessWidget {
|
||||
child: SingleChildScrollView(
|
||||
child: SidebarFolder(
|
||||
views: views,
|
||||
favoriteViews: favoriteState.views,
|
||||
favoriteViews: favoriteViews,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -48,12 +48,7 @@ class SidebarNewPageButton extends StatelessWidget {
|
||||
value: '',
|
||||
confirm: (value) {
|
||||
if (value.isNotEmpty) {
|
||||
context.read<MenuBloc>().add(
|
||||
MenuEvent.createApp(
|
||||
value,
|
||||
desc: '',
|
||||
),
|
||||
);
|
||||
context.read<MenuBloc>().add(MenuEvent.createApp(value));
|
||||
}
|
||||
},
|
||||
).show(context);
|
||||
|
@ -19,6 +19,8 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
typedef ViewItemOnSelected = void Function(ViewPB);
|
||||
|
||||
class ViewItem extends StatelessWidget {
|
||||
const ViewItem({
|
||||
super.key,
|
||||
@ -28,6 +30,7 @@ class ViewItem extends StatelessWidget {
|
||||
required this.level,
|
||||
this.leftPadding = 10,
|
||||
required this.onSelected,
|
||||
this.onTertiarySelected,
|
||||
this.isFirstChild = false,
|
||||
this.isDraggable = true,
|
||||
required this.isFeedback,
|
||||
@ -46,7 +49,11 @@ class ViewItem extends StatelessWidget {
|
||||
// the left padding of the each level = level * leftPadding
|
||||
final double leftPadding;
|
||||
|
||||
final void Function(ViewPB) onSelected;
|
||||
// Selected by normal conventions
|
||||
final ViewItemOnSelected onSelected;
|
||||
|
||||
// Selected by middle mouse button
|
||||
final ViewItemOnSelected? onTertiarySelected;
|
||||
|
||||
// used for indicating the first child of the parent view, so that we can
|
||||
// add top border to the first child
|
||||
@ -62,7 +69,12 @@ class ViewItem extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
|
||||
child: BlocBuilder<ViewBloc, ViewState>(
|
||||
child: BlocConsumer<ViewBloc, ViewState>(
|
||||
listenWhen: (p, c) =>
|
||||
c.lastCreatedView != null &&
|
||||
p.lastCreatedView?.id != c.lastCreatedView!.id,
|
||||
listener: (context, state) =>
|
||||
context.read<TabsBloc>().openPlugin(state.lastCreatedView!),
|
||||
builder: (context, state) {
|
||||
// don't remove this code. it's related to the backend service.
|
||||
view.childViews
|
||||
@ -78,6 +90,7 @@ class ViewItem extends StatelessWidget {
|
||||
showActions: state.isEditing,
|
||||
isExpanded: state.isExpanded,
|
||||
onSelected: onSelected,
|
||||
onTertiarySelected: onTertiarySelected,
|
||||
isFirstChild: isFirstChild,
|
||||
isDraggable: isDraggable,
|
||||
isFeedback: isFeedback,
|
||||
@ -101,6 +114,7 @@ class InnerViewItem extends StatelessWidget {
|
||||
required this.leftPadding,
|
||||
required this.showActions,
|
||||
required this.onSelected,
|
||||
this.onTertiarySelected,
|
||||
this.isFirstChild = false,
|
||||
required this.isFeedback,
|
||||
});
|
||||
@ -120,7 +134,8 @@ class InnerViewItem extends StatelessWidget {
|
||||
final double leftPadding;
|
||||
|
||||
final bool showActions;
|
||||
final void Function(ViewPB) onSelected;
|
||||
final ViewItemOnSelected onSelected;
|
||||
final ViewItemOnSelected? onTertiarySelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -131,6 +146,7 @@ class InnerViewItem extends StatelessWidget {
|
||||
showActions: showActions,
|
||||
categoryType: categoryType,
|
||||
onSelected: onSelected,
|
||||
onTertiarySelected: onTertiarySelected,
|
||||
isExpanded: isExpanded,
|
||||
isDraggable: isDraggable,
|
||||
leftPadding: leftPadding,
|
||||
@ -148,6 +164,7 @@ class InnerViewItem extends StatelessWidget {
|
||||
view: childView,
|
||||
level: level + 1,
|
||||
onSelected: onSelected,
|
||||
onTertiarySelected: onTertiarySelected,
|
||||
isDraggable: isDraggable,
|
||||
leftPadding: leftPadding,
|
||||
isFeedback: isFeedback,
|
||||
@ -176,6 +193,7 @@ class InnerViewItem extends StatelessWidget {
|
||||
categoryType: categoryType,
|
||||
level: level,
|
||||
onSelected: onSelected,
|
||||
onTertiarySelected: onTertiarySelected,
|
||||
isDraggable: false,
|
||||
leftPadding: leftPadding,
|
||||
isFeedback: true,
|
||||
@ -206,6 +224,7 @@ class SingleInnerViewItem extends StatefulWidget {
|
||||
required this.categoryType,
|
||||
required this.showActions,
|
||||
required this.onSelected,
|
||||
this.onTertiarySelected,
|
||||
required this.isFeedback,
|
||||
});
|
||||
|
||||
@ -220,7 +239,8 @@ class SingleInnerViewItem extends StatefulWidget {
|
||||
|
||||
final bool isDraggable;
|
||||
final bool showActions;
|
||||
final void Function(ViewPB) onSelected;
|
||||
final ViewItemOnSelected onSelected;
|
||||
final ViewItemOnSelected? onTertiarySelected;
|
||||
final FolderCategoryType categoryType;
|
||||
|
||||
@override
|
||||
@ -279,6 +299,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => widget.onSelected(widget.view),
|
||||
onTertiaryTapDown: (_) => widget.onTertiarySelected?.call(widget.view),
|
||||
child: SizedBox(
|
||||
height: 26,
|
||||
child: Padding(
|
||||
@ -377,12 +398,7 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
|
||||
context.read<ViewBloc>().add(const ViewEvent.duplicate());
|
||||
break;
|
||||
case ViewMoreActionType.openInNewTab:
|
||||
context.read<TabsBloc>().add(
|
||||
TabsEvent.openTab(
|
||||
plugin: widget.view.plugin(),
|
||||
view: widget.view,
|
||||
),
|
||||
);
|
||||
context.read<TabsBloc>().openTab(widget.view);
|
||||
break;
|
||||
default:
|
||||
throw UnsupportedError('$action is not supported');
|
||||
|
Loading…
Reference in New Issue
Block a user