Skip to content
2 changes: 1 addition & 1 deletion assets/l10n/de-DE.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/l10n/en-US.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/l10n/es-ES.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/l10n/fr-FR.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/l10n/ja-JP.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/l10n/ko-KR.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/l10n/zh-Hans-CN.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion assets/l10n/zh-Hant-TW.json

Large diffs are not rendered by default.

534 changes: 529 additions & 5 deletions lib/chats/chat_list_view_model.dart

Large diffs are not rendered by default.

395 changes: 395 additions & 0 deletions lib/chats/local_pin_store.dart

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,8 @@ abstract final class AppStringKeys {
static const chatListDeleteChatQuestion = 'chatListDeleteChatQuestion';
static const chatListLeaveAndDeleteGroupConfirmation =
'chatListLeaveAndDeleteGroupConfirmation';
static const chatListLocalPinEnabled = 'chatListLocalPinEnabled';
static const chatListLocalPinLimitReached = 'chatListLocalPinLimitReached';
static const chatListMarkUnread = 'chatListMarkUnread';
static const desktopChatOpenSeparate = 'desktopChatOpenSeparate';
static const desktopChatWindowUnavailable = 'desktopChatWindowUnavailable';
Expand Down
5 changes: 5 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import 'chat/group_remark_controller.dart';
import 'chat/link_preview_fixer.dart';
import 'chat/music_player_controller.dart';
import 'chats/chat_folder_tag_controller.dart';
import 'chats/local_pin_store.dart';
import 'components/drawer_controller.dart' as dc;
import 'config/build_flags.dart';
import 'history/message_history_store.dart';
Expand Down Expand Up @@ -268,6 +269,10 @@ Future<void> _bootstrapAndRunApp() async {
unawaited(MessageHistoryStore.shared.ensureLoaded());
MessageHistoryStore.shared.startObserving();
}
// Device-only pins beyond the server cap live in this build's own
// preferences; the store is inert (and reads nothing) until its switch is
// turned on.
LocalPinStore.shared.initialize(prefs);
// Preload Telegram blocked-user list so chat filters have data right away.
unawaited(BlockedUserService.shared.loadBlockedUsers());
// Firebase + analytics + Sentry tags are several platform-channel round
Expand Down
57 changes: 57 additions & 0 deletions lib/settings/advanced_settings_view.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import 'dart:async';

import 'package:flutter/material.dart';

import '../chats/local_pin_store.dart';
import '../components/app_icons.dart';
import '../components/ui_components.dart';
import '../config/build_flags.dart';
import '../history/message_history_view.dart';
import '../l10n/app_localizations.dart';
import '../tdlib/json_helpers.dart';
import '../tdlib/td_client.dart';
import '../theme/app_motion.dart';
import 'bot_api_endpoint_view.dart';
Expand All @@ -25,12 +29,47 @@ class _AdvancedSettingsViewState extends State<AdvancedSettingsView> {
bool _transferBoostEnabled = false;
String _botApiEndpoint = 'https://api.telegram.org';

/// The server's pin cap, for the switch's explanatory line. The default
/// matches the server's own default until the option answers.
int _serverPinLimit = 5;

@override
void initState() {
super.initState();
_refreshRelayStatus();
_refreshTransferBoostStatus();
_refreshBotApiEndpoint();
LocalPinStore.shared.addListener(_handleLocalPinStoreChanged);
unawaited(_refreshServerPinLimit());
}

@override
void dispose() {
LocalPinStore.shared.removeListener(_handleLocalPinStoreChanged);
super.dispose();
}

void _handleLocalPinStoreChanged() {
if (mounted) setState(() {});
}

Future<void> _setUnlimitedLocalPins(bool enabled) async {
await LocalPinStore.shared.setUnlimitedEnabled(enabled);
}

Future<void> _refreshServerPinLimit() async {
try {
final option = await TdClient.shared.query({
'@type': 'getOption',
'name': 'pinned_chat_count_max',
});
final value = option.integer('value');
if (mounted && value != null && value > 0) {
setState(() => _serverPinLimit = value);
}
} catch (_) {
// Keep the server default until the option answers.
}
}

Future<void> _refreshRelayStatus() async {
Expand Down Expand Up @@ -118,6 +157,24 @@ class _AdvancedSettingsViewState extends State<AdvancedSettingsView> {
),
],
),
// Device-only pins beyond the server cap: everything stays in this
// app's own preference store, and the store is inert while the
// switch is off (the default).
const SettingsSectionHeader(AppStringKeys.chatListLocalPinEnabled),
SettingsCard.rows(
rows: [
SettingsSwitchRow(
key: const ValueKey('local-pins-unlimited'),
title: AppStrings.t(AppStringKeys.chatListLocalPinEnabled),
subtitle: AppStrings.t(
AppStringKeys.chatListLocalPinLimitReached,
{'value1': '$_serverPinLimit'},
),
value: LocalPinStore.shared.unlimitedEnabled,
onChanged: (value) => unawaited(_setUnlimitedLocalPins(value)),
),
],
),
// Telegram §1.4 exception: the mirror and its viewer ship only in
// opted-in self-builds; with the flag off there is no entry point.
if (allowTelegramTosViolations)
Expand Down
103 changes: 103 additions & 0 deletions test/local_pin_settings_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//
// The device-only pin switch in Advanced settings: the row shows the server's
// pin cap in its explanatory line, and toggling it persists the preference and
// flips the store's state. The inertness half of the contract lives in
// local_pins_test.dart.
//

import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mithka/chats/local_pin_store.dart';
import 'package:mithka/l10n/app_localizations.dart';
import 'package:mithka/settings/advanced_settings_view.dart';
import 'package:mithka/tdlib/td_client.dart';
import 'package:mithka/theme/theme_controller.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
TestWidgetsFlutterBinding.ensureInitialized();

setUpAll(() {
TdClient.shared.configureProxy(
TdClientProxyTransport(
accountSlot: 0,
query: (request) async => switch (request['@type']) {
'getOption' => <String, dynamic>{
'@type': 'optionValueInteger',
'value': 5,
},
_ => <String, dynamic>{'@type': 'ok'},
},
send: (_) async {},
updates: const Stream<Map<String, dynamic>>.empty(),
),
);
});

setUp(() {
SharedPreferences.setMockInitialValues({});
});

tearDownAll(() async {
await LocalPinStore.shared.setUnlimitedEnabled(false);
LocalPinStore.shared.setActiveAccountUserId(null);
await TdClient.shared.closeProxy();
});

testWidgets('the advanced settings switch drives the device-only pins', (
tester,
) async {
final prefs = await SharedPreferences.getInstance();
LocalPinStore.shared.initialize(prefs);
// A store left in an earlier test's state must not leak in; the switch
// starts from the pristine (off) state.
expect(LocalPinStore.shared.unlimitedEnabled, isFalse);

final theme = ThemeController(prefs);
addTearDown(theme.dispose);
await tester.pumpWidget(
ChangeNotifierProvider<ThemeController>.value(
value: theme,
child: const MaterialApp(
locale: Locale('en'),
localizationsDelegates: [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: AdvancedSettingsView(),
),
),
);
tester.view.physicalSize = const Size(900, 2600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpAndSettle();

final row = find.byKey(const ValueKey('local-pins-unlimited'));
await tester.scrollUntilVisible(row, 200);
expect(row, findsOneWidget);
// The explanatory line names the server's cap (answered as 5 above).
expect(
find.textContaining('keep more than 5 pinned chats'),
findsOneWidget,
);

await tester.tap(row);
await tester.pumpAndSettle();

expect(LocalPinStore.shared.unlimitedEnabled, isTrue);
expect(prefs.getBool(LocalPinStore.unlimitedPreferenceKey), isTrue);

// And back off again.
await tester.tap(row);
await tester.pumpAndSettle();
expect(LocalPinStore.shared.unlimitedEnabled, isFalse);
expect(prefs.getBool(LocalPinStore.unlimitedPreferenceKey), isNull);
});
}
Loading
Loading