mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
add option to disable auto-scroll. (#225)
This commit is contained in:
BIN
assets/hacki-github.xcf
Normal file
BIN
assets/hacki-github.xcf
Normal file
Binary file not shown.
BIN
assets/hacki.xcf
Normal file
BIN
assets/hacki.xcf
Normal file
Binary file not shown.
BIN
assets/screenshots/hacki-1.png
Normal file
BIN
assets/screenshots/hacki-1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 890 KiB |
BIN
assets/screenshots/hacki-2.png
Normal file
BIN
assets/screenshots/hacki-2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 873 KiB |
BIN
assets/screenshots/hacki-3.png
Normal file
BIN
assets/screenshots/hacki-3.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 770 KiB |
BIN
assets/screenshots/hacki-4.png
Normal file
BIN
assets/screenshots/hacki-4.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 517 KiB |
@ -68,6 +68,8 @@ class PreferenceState extends Equatable {
|
|||||||
|
|
||||||
bool get swipeGestureEnabled => _isOn<SwipeGesturePreference>();
|
bool get swipeGestureEnabled => _isOn<SwipeGesturePreference>();
|
||||||
|
|
||||||
|
bool get autoScrollEnabled => _isOn<AutoScrollModePreference>();
|
||||||
|
|
||||||
List<StoryType> get tabs {
|
List<StoryType> get tabs {
|
||||||
final String result =
|
final String result =
|
||||||
preferences.singleWhereType<TabOrderPreference>().val.toString();
|
preferences.singleWhereType<TabOrderPreference>().val.toString();
|
||||||
|
@ -48,3 +48,11 @@ class SearchState extends Equatable {
|
|||||||
params,
|
params,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SearchStateExtension on SearchState {
|
||||||
|
bool get showDateRangeShortcutChips {
|
||||||
|
return hasDateFilter &&
|
||||||
|
dateFilter?.startTime != null &&
|
||||||
|
dateFilter?.endTime != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -30,6 +30,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
|||||||
const StoryUrlModePreference(),
|
const StoryUrlModePreference(),
|
||||||
const NotificationModePreference(),
|
const NotificationModePreference(),
|
||||||
const SwipeGesturePreference(),
|
const SwipeGesturePreference(),
|
||||||
|
const AutoScrollModePreference(),
|
||||||
const CollapseModePreference(),
|
const CollapseModePreference(),
|
||||||
const ReaderModePreference(),
|
const ReaderModePreference(),
|
||||||
const MarkReadStoriesModePreference(),
|
const MarkReadStoriesModePreference(),
|
||||||
@ -54,12 +55,13 @@ const bool _notificationModeDefaultValue = true;
|
|||||||
const bool _swipeGestureModeDefaultValue = false;
|
const bool _swipeGestureModeDefaultValue = false;
|
||||||
const bool _displayModeDefaultValue = true;
|
const bool _displayModeDefaultValue = true;
|
||||||
const bool _eyeCandyModeDefaultValue = false;
|
const bool _eyeCandyModeDefaultValue = false;
|
||||||
const bool _trueDarkModeDefaultValue = false;
|
const bool _trueDarkModeDefaultValue = true;
|
||||||
const bool _readerModeDefaultValue = true;
|
const bool _readerModeDefaultValue = true;
|
||||||
const bool _markReadStoriesModeDefaultValue = true;
|
const bool _markReadStoriesModeDefaultValue = true;
|
||||||
const bool _metadataModeDefaultValue = true;
|
const bool _metadataModeDefaultValue = true;
|
||||||
const bool _storyUrlModeDefaultValue = true;
|
const bool _storyUrlModeDefaultValue = true;
|
||||||
const bool _collapseModeDefaultValue = true;
|
const bool _collapseModeDefaultValue = true;
|
||||||
|
const bool _autoScrollModeDefaultValue = true;
|
||||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||||
final int _fontSizeDefaultValue = FontSize.regular.index;
|
final int _fontSizeDefaultValue = FontSize.regular.index;
|
||||||
@ -127,6 +129,26 @@ class CollapseModePreference extends BooleanPreference {
|
|||||||
'''if disabled, tap on the top of comment tile to collapse.''';
|
'''if disabled, tap on the top of comment tile to collapse.''';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AutoScrollModePreference extends BooleanPreference {
|
||||||
|
const AutoScrollModePreference({bool? val})
|
||||||
|
: super(val: val ?? _autoScrollModeDefaultValue);
|
||||||
|
|
||||||
|
@override
|
||||||
|
AutoScrollModePreference copyWith({required bool? val}) {
|
||||||
|
return AutoScrollModePreference(val: val);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get key => 'autoScrollMode';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get title => 'Auto-scroll on collapsing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get subtitle =>
|
||||||
|
'''Automatically scroll to next comment when you collapse a comment.''';
|
||||||
|
}
|
||||||
|
|
||||||
/// The value deciding whether or not the story
|
/// The value deciding whether or not the story
|
||||||
/// tile should display link preview. Defaults to true.
|
/// tile should display link preview. Defaults to true.
|
||||||
class DisplayModePreference extends BooleanPreference {
|
class DisplayModePreference extends BooleanPreference {
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||||
import 'package:hacki/config/constants.dart';
|
import 'package:hacki/config/constants.dart';
|
||||||
@ -32,31 +31,9 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
final RefreshController refreshController = RefreshController();
|
final RefreshController refreshController = RefreshController();
|
||||||
final ScrollController scrollController = ScrollController();
|
final ScrollController scrollController = ScrollController();
|
||||||
final Debouncer debouncer = Debouncer(delay: Durations.oneSecond);
|
final Debouncer debouncer = Debouncer(delay: Durations.oneSecond);
|
||||||
bool showChips = true;
|
|
||||||
bool shouldOffStageChips = false;
|
|
||||||
|
|
||||||
static const Duration chipsAnimationDuration = Durations.ms300;
|
static const Duration chipsAnimationDuration = Durations.ms300;
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
scrollController.addListener(() {
|
|
||||||
if (scrollController.position.userScrollDirection ==
|
|
||||||
ScrollDirection.reverse &&
|
|
||||||
showChips) {
|
|
||||||
setState(() {
|
|
||||||
showChips = false;
|
|
||||||
});
|
|
||||||
} else if (scrollController.position.userScrollDirection ==
|
|
||||||
ScrollDirection.forward &&
|
|
||||||
!showChips) {
|
|
||||||
setState(() {
|
|
||||||
showChips = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
refreshController.dispose();
|
refreshController.dispose();
|
||||||
@ -108,83 +85,13 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
AnimatedCrossFade(
|
AnimatedCrossFade(
|
||||||
duration: chipsAnimationDuration,
|
duration: chipsAnimationDuration,
|
||||||
crossFadeState: showChips
|
crossFadeState: state.showDateRangeShortcutChips
|
||||||
? CrossFadeState.showSecond
|
? CrossFadeState.showSecond
|
||||||
: CrossFadeState.showFirst,
|
: CrossFadeState.showFirst,
|
||||||
firstChild: SizedBox.fromSize(),
|
firstChild: SizedBox.fromSize(),
|
||||||
secondChild: Column(
|
secondChild: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
if (state.hasDateFilter &&
|
|
||||||
state.dateFilter?.startTime != null &&
|
|
||||||
state.dateFilter?.endTime != null)
|
|
||||||
SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
const SizedBox(
|
|
||||||
width: Dimens.pt8,
|
|
||||||
),
|
|
||||||
DateTimeShortcutChip.dayBefore(
|
|
||||||
onDateTimeRangeUpdated: context
|
|
||||||
.read<SearchCubit>()
|
|
||||||
.onDateTimeRangeUpdated,
|
|
||||||
startDate: state.dateFilter!.startTime!,
|
|
||||||
endDate: state.dateFilter!.endTime!,
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
width: Dimens.pt8,
|
|
||||||
),
|
|
||||||
DateTimeShortcutChip.dayAfter(
|
|
||||||
onDateTimeRangeUpdated: context
|
|
||||||
.read<SearchCubit>()
|
|
||||||
.onDateTimeRangeUpdated,
|
|
||||||
startDate: state.dateFilter!.startTime!,
|
|
||||||
endDate: state.dateFilter!.endTime!,
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
width: Dimens.pt8,
|
|
||||||
),
|
|
||||||
DateTimeShortcutChip.weekBefore(
|
|
||||||
onDateTimeRangeUpdated: context
|
|
||||||
.read<SearchCubit>()
|
|
||||||
.onDateTimeRangeUpdated,
|
|
||||||
startDate: state.dateFilter!.startTime!,
|
|
||||||
endDate: state.dateFilter!.endTime!,
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
width: Dimens.pt8,
|
|
||||||
),
|
|
||||||
DateTimeShortcutChip.weekAfter(
|
|
||||||
onDateTimeRangeUpdated: context
|
|
||||||
.read<SearchCubit>()
|
|
||||||
.onDateTimeRangeUpdated,
|
|
||||||
startDate: state.dateFilter!.startTime!,
|
|
||||||
endDate: state.dateFilter!.endTime!,
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
width: Dimens.pt8,
|
|
||||||
),
|
|
||||||
DateTimeShortcutChip.monthBefore(
|
|
||||||
onDateTimeRangeUpdated: context
|
|
||||||
.read<SearchCubit>()
|
|
||||||
.onDateTimeRangeUpdated,
|
|
||||||
startDate: state.dateFilter!.startTime!,
|
|
||||||
endDate: state.dateFilter!.endTime!,
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
width: Dimens.pt8,
|
|
||||||
),
|
|
||||||
DateTimeShortcutChip.monthAfter(
|
|
||||||
onDateTimeRangeUpdated: context
|
|
||||||
.read<SearchCubit>()
|
|
||||||
.onDateTimeRangeUpdated,
|
|
||||||
startDate: state.dateFilter!.startTime!,
|
|
||||||
endDate: state.dateFilter!.endTime!,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -192,78 +99,143 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: Dimens.pt8,
|
width: Dimens.pt8,
|
||||||
),
|
),
|
||||||
DateTimeRangeFilterChip(
|
DateTimeShortcutChip.dayBefore(
|
||||||
filter: state.dateFilter,
|
|
||||||
initialStartDate: state.dateFilter?.startTime,
|
|
||||||
initialEndDate: state.dateFilter?.endTime,
|
|
||||||
onDateTimeRangeUpdated: context
|
onDateTimeRangeUpdated: context
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
.onDateTimeRangeUpdated,
|
.onDateTimeRangeUpdated,
|
||||||
onDateTimeRangeRemoved: context
|
startDate: state.dateFilter?.startTime,
|
||||||
|
endDate: state.dateFilter?.endTime,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
width: Dimens.pt8,
|
||||||
|
),
|
||||||
|
DateTimeShortcutChip.dayAfter(
|
||||||
|
onDateTimeRangeUpdated: context
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
.removeFilter<DateTimeRangeFilter>,
|
.onDateTimeRangeUpdated,
|
||||||
|
startDate: state.dateFilter?.startTime,
|
||||||
|
endDate: state.dateFilter?.endTime,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: Dimens.pt8,
|
width: Dimens.pt8,
|
||||||
),
|
),
|
||||||
PostedByFilterChip(
|
DateTimeShortcutChip.weekBefore(
|
||||||
filter: state.params.get<PostedByFilter>(),
|
onDateTimeRangeUpdated: context
|
||||||
onChanged: context
|
|
||||||
.read<SearchCubit>()
|
.read<SearchCubit>()
|
||||||
.onPostedByChanged,
|
.onDateTimeRangeUpdated,
|
||||||
|
startDate: state.dateFilter?.startTime,
|
||||||
|
endDate: state.dateFilter?.endTime,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: Dimens.pt8,
|
width: Dimens.pt8,
|
||||||
),
|
),
|
||||||
CustomChip(
|
DateTimeShortcutChip.weekAfter(
|
||||||
onSelected: (_) =>
|
onDateTimeRangeUpdated: context
|
||||||
context.read<SearchCubit>().onSortToggled(),
|
.read<SearchCubit>()
|
||||||
selected: state.params.sorted,
|
.onDateTimeRangeUpdated,
|
||||||
label: '''newest first''',
|
startDate: state.dateFilter?.startTime,
|
||||||
|
endDate: state.dateFilter?.endTime,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: Dimens.pt8,
|
width: Dimens.pt8,
|
||||||
),
|
),
|
||||||
for (final CustomDateTimeRange range
|
DateTimeShortcutChip.monthBefore(
|
||||||
in CustomDateTimeRange.values) ...<Widget>[
|
onDateTimeRangeUpdated: context
|
||||||
CustomRangeFilterChip(
|
.read<SearchCubit>()
|
||||||
range: range,
|
.onDateTimeRangeUpdated,
|
||||||
onTap: context
|
startDate: state.dateFilter?.startTime,
|
||||||
.read<SearchCubit>()
|
endDate: state.dateFilter?.endTime,
|
||||||
.onDateTimeRangeUpdated,
|
),
|
||||||
),
|
const SizedBox(
|
||||||
const SizedBox(
|
width: Dimens.pt8,
|
||||||
width: Dimens.pt8,
|
),
|
||||||
),
|
DateTimeShortcutChip.monthAfter(
|
||||||
],
|
onDateTimeRangeUpdated: context
|
||||||
|
.read<SearchCubit>()
|
||||||
|
.onDateTimeRangeUpdated,
|
||||||
|
startDate: state.dateFilter?.startTime,
|
||||||
|
endDate: state.dateFilter?.endTime,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SingleChildScrollView(
|
],
|
||||||
scrollDirection: Axis.horizontal,
|
),
|
||||||
child: Row(
|
),
|
||||||
children: <Widget>[
|
SingleChildScrollView(
|
||||||
for (final TypeTagFilter filter
|
scrollDirection: Axis.horizontal,
|
||||||
in TypeTagFilter.all) ...<Widget>[
|
child: Row(
|
||||||
const SizedBox(
|
children: <Widget>[
|
||||||
width: Dimens.pt8,
|
const SizedBox(
|
||||||
),
|
width: Dimens.pt8,
|
||||||
CustomChip(
|
|
||||||
onSelected: (_) => context
|
|
||||||
.read<SearchCubit>()
|
|
||||||
.onToggled(filter),
|
|
||||||
selected: context
|
|
||||||
.read<SearchCubit>()
|
|
||||||
.state
|
|
||||||
.params
|
|
||||||
.get<TypeTagFilter>() ==
|
|
||||||
filter,
|
|
||||||
label: filter.query,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
DateTimeRangeFilterChip(
|
||||||
|
filter: state.dateFilter,
|
||||||
|
initialStartDate: state.dateFilter?.startTime,
|
||||||
|
initialEndDate: state.dateFilter?.endTime,
|
||||||
|
onDateTimeRangeUpdated: context
|
||||||
|
.read<SearchCubit>()
|
||||||
|
.onDateTimeRangeUpdated,
|
||||||
|
onDateTimeRangeRemoved: context
|
||||||
|
.read<SearchCubit>()
|
||||||
|
.removeFilter<DateTimeRangeFilter>,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
width: Dimens.pt8,
|
||||||
|
),
|
||||||
|
PostedByFilterChip(
|
||||||
|
filter: state.params.get<PostedByFilter>(),
|
||||||
|
onChanged:
|
||||||
|
context.read<SearchCubit>().onPostedByChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
width: Dimens.pt8,
|
||||||
|
),
|
||||||
|
CustomChip(
|
||||||
|
onSelected: (_) =>
|
||||||
|
context.read<SearchCubit>().onSortToggled(),
|
||||||
|
selected: state.params.sorted,
|
||||||
|
label: '''newest first''',
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
width: Dimens.pt8,
|
||||||
|
),
|
||||||
|
for (final CustomDateTimeRange range
|
||||||
|
in CustomDateTimeRange.values) ...<Widget>[
|
||||||
|
CustomRangeFilterChip(
|
||||||
|
range: range,
|
||||||
|
onTap: context
|
||||||
|
.read<SearchCubit>()
|
||||||
|
.onDateTimeRangeUpdated,
|
||||||
|
),
|
||||||
|
const SizedBox(
|
||||||
|
width: Dimens.pt8,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
for (final TypeTagFilter filter
|
||||||
|
in TypeTagFilter.all) ...<Widget>[
|
||||||
|
const SizedBox(
|
||||||
|
width: Dimens.pt8,
|
||||||
|
),
|
||||||
|
CustomChip(
|
||||||
|
onSelected: (_) =>
|
||||||
|
context.read<SearchCubit>().onToggled(filter),
|
||||||
|
selected: context
|
||||||
|
.read<SearchCubit>()
|
||||||
|
.state
|
||||||
|
.params
|
||||||
|
.get<TypeTagFilter>() ==
|
||||||
|
filter,
|
||||||
|
label: filter.query,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -68,8 +68,8 @@ class DateTimeShortcutChip extends StatelessWidget {
|
|||||||
_calculator = ((DateTime date) => date.add(const Duration(days: 30)));
|
_calculator = ((DateTime date) => date.add(const Duration(days: 30)));
|
||||||
|
|
||||||
final void Function(DateTime, DateTime) onDateTimeRangeUpdated;
|
final void Function(DateTime, DateTime) onDateTimeRangeUpdated;
|
||||||
final DateTime startDate;
|
final DateTime? startDate;
|
||||||
final DateTime endDate;
|
final DateTime? endDate;
|
||||||
final String label;
|
final String label;
|
||||||
final Calculator _calculator;
|
final Calculator _calculator;
|
||||||
|
|
||||||
@ -77,8 +77,9 @@ class DateTimeShortcutChip extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return CustomChip(
|
return CustomChip(
|
||||||
onSelected: (bool value) {
|
onSelected: (bool value) {
|
||||||
final DateTime updatedStartDate = _calculator(startDate);
|
if (startDate == null || endDate == null) return;
|
||||||
final DateTime updatedEndDate = _calculator(endDate);
|
final DateTime updatedStartDate = _calculator(startDate!);
|
||||||
|
final DateTime updatedEndDate = _calculator(endDate!);
|
||||||
onDateTimeRangeUpdated(updatedStartDate, updatedEndDate);
|
onDateTimeRangeUpdated(updatedStartDate, updatedEndDate);
|
||||||
},
|
},
|
||||||
selected: false,
|
selected: false,
|
||||||
|
@ -349,7 +349,8 @@ class CommentTile extends StatelessWidget {
|
|||||||
void _collapse(BuildContext context) {
|
void _collapse(BuildContext context) {
|
||||||
HapticFeedbackUtil.selection();
|
HapticFeedbackUtil.selection();
|
||||||
context.read<CollapseCubit>().collapse();
|
context.read<CollapseCubit>().collapse();
|
||||||
if (context.read<CollapseCubit>().state.collapsed) {
|
if (context.read<CollapseCubit>().state.collapsed &&
|
||||||
|
context.read<PreferenceCubit>().state.autoScrollEnabled) {
|
||||||
Future<void>.delayed(
|
Future<void>.delayed(
|
||||||
Durations.ms300,
|
Durations.ms300,
|
||||||
() {
|
() {
|
||||||
|
@ -237,7 +237,7 @@ class LinkView extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: isUsingSerifFont! ? Dimens.pt2 : Dimens.pt4,
|
height: isUsingSerifFont! ? Dimens.zero : Dimens.pt4,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
name: hacki
|
name: hacki
|
||||||
description: A Hacker News reader.
|
description: A Hacker News reader.
|
||||||
version: 1.7.1+112
|
version: 1.7.2+113
|
||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
Reference in New Issue
Block a user