Show comments bar when no unread bar.
This commit is contained in:
		
							parent
							
								
									cf48152853
								
							
						
					
					
						commit
						9abca29f4c
					
				
					 9 changed files with 121 additions and 63 deletions
				
			
		|  | @ -2104,7 +2104,7 @@ void History::addUnreadBar() { | ||||||
| 	} | 	} | ||||||
| 	if (const auto count = chatListUnreadCount()) { | 	if (const auto count = chatListUnreadCount()) { | ||||||
| 		_unreadBarView = _firstUnreadView; | 		_unreadBarView = _firstUnreadView; | ||||||
| 		_unreadBarView->createUnreadBar(); | 		_unreadBarView->createUnreadBar(tr::lng_unread_bar_some()); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -144,8 +144,8 @@ TextSelection ShiftItemSelection( | ||||||
| 	return ShiftItemSelection(selection, byText.length()); | 	return ShiftItemSelection(selection, byText.length()); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| void UnreadBar::init() { | void UnreadBar::init(const QString &string) { | ||||||
| 	text = tr::lng_unread_bar_some(tr::now); | 	text = string; | ||||||
| 	width = st::semiboldFont->width(text); | 	width = st::semiboldFont->width(text); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -440,12 +440,18 @@ bool Element::computeIsAttachToPrevious(not_null<Element*> previous) { | ||||||
| 	return false; | 	return false; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| void Element::createUnreadBar() { | void Element::createUnreadBar(rpl::producer<QString> text) { | ||||||
| 	if (!AddComponents(UnreadBar::Bit())) { | 	if (!AddComponents(UnreadBar::Bit())) { | ||||||
| 		return; | 		return; | ||||||
| 	} | 	} | ||||||
| 	const auto bar = Get<UnreadBar>(); | 	const auto bar = Get<UnreadBar>(); | ||||||
| 	bar->init(); | 	std::move( | ||||||
|  | 		text | ||||||
|  | 	) | rpl::start_with_next([=](const QString &text) { | ||||||
|  | 		if (const auto bar = Get<UnreadBar>()) { | ||||||
|  | 			bar->init(text); | ||||||
|  | 		} | ||||||
|  | 	}, bar->lifetime); | ||||||
| 	if (data()->mainView() == this) { | 	if (data()->mainView() == this) { | ||||||
| 		recountAttachToPreviousInBlocks(); | 		recountAttachToPreviousInBlocks(); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -118,7 +118,7 @@ TextSelection ShiftItemSelection( | ||||||
| // Any HistoryView::Element can have this Component for
 | // Any HistoryView::Element can have this Component for
 | ||||||
| // displaying the unread messages bar above the message.
 | // displaying the unread messages bar above the message.
 | ||||||
| struct UnreadBar : public RuntimeComponent<UnreadBar, Element> { | struct UnreadBar : public RuntimeComponent<UnreadBar, Element> { | ||||||
| 	void init(); | 	void init(const QString &string); | ||||||
| 
 | 
 | ||||||
| 	static int height(); | 	static int height(); | ||||||
| 	static int marginTop(); | 	static int marginTop(); | ||||||
|  | @ -127,6 +127,7 @@ struct UnreadBar : public RuntimeComponent<UnreadBar, Element> { | ||||||
| 
 | 
 | ||||||
| 	QString text; | 	QString text; | ||||||
| 	int width = 0; | 	int width = 0; | ||||||
|  | 	rpl::lifetime lifetime; | ||||||
| 
 | 
 | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -206,7 +207,7 @@ public: | ||||||
| 
 | 
 | ||||||
| 	bool computeIsAttachToPrevious(not_null<Element*> previous); | 	bool computeIsAttachToPrevious(not_null<Element*> previous); | ||||||
| 
 | 
 | ||||||
| 	void createUnreadBar(); | 	void createUnreadBar(rpl::producer<QString> text); | ||||||
| 	void destroyUnreadBar(); | 	void destroyUnreadBar(); | ||||||
| 
 | 
 | ||||||
| 	int displayedDateHeight() const; | 	int displayedDateHeight() const; | ||||||
|  |  | ||||||
|  | @ -394,13 +394,23 @@ void ListWidget::animatedScrollTo( | ||||||
| 		_delegate->listScrollTo(scrollTop); | 		_delegate->listScrollTo(scrollTop); | ||||||
| 		return; | 		return; | ||||||
| 	} | 	} | ||||||
|  | 	const auto transition = (type == AnimatedScroll::Full) | ||||||
|  | 		? anim::sineInOut | ||||||
|  | 		: anim::easeOutCubic; | ||||||
|  | 	if (delta > 0 && scrollTop == height() - (_visibleBottom - _visibleTop)) { | ||||||
|  | 		// Animated scroll to bottom.
 | ||||||
|  | 		_scrollToAnimation.start( | ||||||
|  | 			[=] { scrollToAnimationCallback(FullMsgId(), 0); }, | ||||||
|  | 			-delta, | ||||||
|  | 			0, | ||||||
|  | 			st::slideDuration, | ||||||
|  | 			transition); | ||||||
|  | 		return; | ||||||
|  | 	} | ||||||
| 	const auto index = findNearestItem(attachPosition); | 	const auto index = findNearestItem(attachPosition); | ||||||
| 	Assert(index >= 0 && index < int(_items.size())); | 	Assert(index >= 0 && index < int(_items.size())); | ||||||
| 	const auto attachTo = _items[index]; | 	const auto attachTo = _items[index]; | ||||||
| 	const auto attachToId = attachTo->data()->fullId(); | 	const auto attachToId = attachTo->data()->fullId(); | ||||||
| 	const auto transition = (type == AnimatedScroll::Full) |  | ||||||
| 		? anim::sineInOut |  | ||||||
| 		: anim::easeOutCubic; |  | ||||||
| 	const auto initial = scrollTop - delta; | 	const auto initial = scrollTop - delta; | ||||||
| 	_delegate->listScrollTo(initial); | 	_delegate->listScrollTo(initial); | ||||||
| 
 | 
 | ||||||
|  | @ -422,6 +432,14 @@ bool ListWidget::animatedScrolling() const { | ||||||
| void ListWidget::scrollToAnimationCallback( | void ListWidget::scrollToAnimationCallback( | ||||||
| 		FullMsgId attachToId, | 		FullMsgId attachToId, | ||||||
| 		int relativeTo) { | 		int relativeTo) { | ||||||
|  | 	if (!attachToId) { | ||||||
|  | 		// Animated scroll to bottom.
 | ||||||
|  | 		const auto current = int(std::round(_scrollToAnimation.value(0))); | ||||||
|  | 		_delegate->listScrollTo(height() | ||||||
|  | 			- (_visibleBottom - _visibleTop) | ||||||
|  | 			+ current); | ||||||
|  | 		return; | ||||||
|  | 	} | ||||||
| 	const auto attachTo = session().data().message(attachToId); | 	const auto attachTo = session().data().message(attachToId); | ||||||
| 	const auto attachToView = viewForItem(attachTo); | 	const auto attachToView = viewForItem(attachTo); | ||||||
| 	if (!attachToView) { | 	if (!attachToView) { | ||||||
|  | @ -483,11 +501,14 @@ void ListWidget::updateHighlightedMessage() { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| void ListWidget::checkUnreadBarCreation() { | void ListWidget::checkUnreadBarCreation() { | ||||||
| 	if (!_unreadBarElement) { | 	if (!_bar.element) { | ||||||
| 		if (const auto index = _delegate->listUnreadBarView(_items)) { | 		if (auto data = _delegate->listMessagesBar(_items); data.bar.element) { | ||||||
| 			_unreadBarElement = _items[*index].get(); | 			_bar = std::move(data.bar); | ||||||
| 			_unreadBarElement->createUnreadBar(); | 			_barText = std::move(data.text); | ||||||
| 			refreshAttachmentsAtIndex(*index); | 			_bar.element->createUnreadBar(_barText.value()); | ||||||
|  | 			const auto i = ranges::find(_items, not_null{ _bar.element }); | ||||||
|  | 			Assert(i != end(_items)); | ||||||
|  | 			refreshAttachmentsAtIndex(i - begin(_items)); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | @ -507,10 +528,10 @@ void ListWidget::restoreScrollState() { | ||||||
| 		return; | 		return; | ||||||
| 	} | 	} | ||||||
| 	if (!_scrollTopState.item) { | 	if (!_scrollTopState.item) { | ||||||
| 		if (!_unreadBarElement) { | 		if (!_bar.element || !_bar.focus) { | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 		_scrollTopState.item = _unreadBarElement->data()->position(); | 		_scrollTopState.item = _bar.element->data()->position(); | ||||||
| 		_scrollTopState.shift = st::lineWidth + st::historyUnreadBarMargin; | 		_scrollTopState.shift = st::lineWidth + st::historyUnreadBarMargin; | ||||||
| 	} | 	} | ||||||
| 	const auto index = findNearestItem(_scrollTopState.item); | 	const auto index = findNearestItem(_scrollTopState.item); | ||||||
|  | @ -2605,11 +2626,11 @@ void ListWidget::viewReplaced(not_null<const Element*> was, Element *now) { | ||||||
| 	if (_visibleTopItem == was) _visibleTopItem = now; | 	if (_visibleTopItem == was) _visibleTopItem = now; | ||||||
| 	if (_scrollDateLastItem == was) _scrollDateLastItem = now; | 	if (_scrollDateLastItem == was) _scrollDateLastItem = now; | ||||||
| 	if (_overElement == was) _overElement = now; | 	if (_overElement == was) _overElement = now; | ||||||
| 	if (_unreadBarElement == was) { | 	if (_bar.element == was.get()) { | ||||||
| 		const auto bar = _unreadBarElement->Get<UnreadBar>(); | 		const auto bar = _bar.element->Get<UnreadBar>(); | ||||||
| 		_unreadBarElement = now; | 		_bar.element = now; | ||||||
| 		if (now && bar) { | 		if (now && bar) { | ||||||
| 			_unreadBarElement->createUnreadBar(); | 			_bar.element->createUnreadBar(_barText.value()); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -48,7 +48,16 @@ struct SelectedItem { | ||||||
| 	bool canDelete = false; | 	bool canDelete = false; | ||||||
| 	bool canForward = false; | 	bool canForward = false; | ||||||
| 	bool canSendNow = false; | 	bool canSendNow = false; | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
|  | struct MessagesBar { | ||||||
|  | 	Element *element = nullptr; | ||||||
|  | 	bool focus = false; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | struct MessagesBarData { | ||||||
|  | 	MessagesBar bar; | ||||||
|  | 	rpl::producer<QString> text; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| using SelectedItems = std::vector<SelectedItem>; | using SelectedItems = std::vector<SelectedItem>; | ||||||
|  | @ -70,7 +79,7 @@ public: | ||||||
| 		not_null<HistoryItem*> second) = 0; | 		not_null<HistoryItem*> second) = 0; | ||||||
| 	virtual void listSelectionChanged(SelectedItems &&items) = 0; | 	virtual void listSelectionChanged(SelectedItems &&items) = 0; | ||||||
| 	virtual void listVisibleItemsChanged(HistoryItemsList &&items) = 0; | 	virtual void listVisibleItemsChanged(HistoryItemsList &&items) = 0; | ||||||
| 	virtual std::optional<int> listUnreadBarView( | 	virtual MessagesBarData listMessagesBar( | ||||||
| 		const std::vector<not_null<Element*>> &elements) = 0; | 		const std::vector<not_null<Element*>> &elements) = 0; | ||||||
| 	virtual void listContentRefreshed() = 0; | 	virtual void listContentRefreshed() = 0; | ||||||
| 	virtual ClickHandlerPtr listDateLink(not_null<Element*> view) = 0; | 	virtual ClickHandlerPtr listDateLink(not_null<Element*> view) = 0; | ||||||
|  | @ -493,7 +502,8 @@ private: | ||||||
| 	ClickHandlerPtr _scrollDateLink; | 	ClickHandlerPtr _scrollDateLink; | ||||||
| 	SingleQueuedInvokation _applyUpdatedScrollState; | 	SingleQueuedInvokation _applyUpdatedScrollState; | ||||||
| 
 | 
 | ||||||
| 	Element *_unreadBarElement = nullptr; | 	MessagesBar _bar; | ||||||
|  | 	rpl::variable<QString> _barText; | ||||||
| 
 | 
 | ||||||
| 	MouseAction _mouseAction = MouseAction::None; | 	MouseAction _mouseAction = MouseAction::None; | ||||||
| 	TextSelectType _mouseSelectType = TextSelectType::Letters; | 	TextSelectType _mouseSelectType = TextSelectType::Letters; | ||||||
|  |  | ||||||
|  | @ -1212,26 +1212,7 @@ void RepliesWidget::saveState(not_null<RepliesMemento*> memento) { | ||||||
| void RepliesWidget::restoreState(not_null<RepliesMemento*> memento) { | void RepliesWidget::restoreState(not_null<RepliesMemento*> memento) { | ||||||
| 	const auto setReplies = [&](std::shared_ptr<Data::RepliesList> replies) { | 	const auto setReplies = [&](std::shared_ptr<Data::RepliesList> replies) { | ||||||
| 		_replies = std::move(replies); | 		_replies = std::move(replies); | ||||||
| 
 | 		_topBar->setCustomTitle(tr::lng_manage_discussion_group(tr::now)); | ||||||
| 		rpl::single( |  | ||||||
| 			tr::lng_contacts_loading() |  | ||||||
| 		) | rpl::then(rpl::combine( |  | ||||||
| 			_replies->fullCount(), |  | ||||||
| 			_areComments.value() |  | ||||||
| 		) | rpl::map([=](int count, bool areComments) { |  | ||||||
| 			return count |  | ||||||
| 				? (areComments |  | ||||||
| 					? tr::lng_comments_header |  | ||||||
| 					: tr::lng_replies_header)( |  | ||||||
| 						lt_count, |  | ||||||
| 						rpl::single(count) | tr::to_count()) |  | ||||||
| 				: (areComments |  | ||||||
| 					? tr::lng_comments_header_none |  | ||||||
| 					: tr::lng_replies_header_none)(); |  | ||||||
| 		})) | rpl::flatten_latest( |  | ||||||
| 		) | rpl::start_with_next([=](const QString &text) { |  | ||||||
| 			_topBar->setCustomTitle(text); |  | ||||||
| 		}, lifetime()); |  | ||||||
| 	}; | 	}; | ||||||
| 	if (auto replies = memento->getReplies()) { | 	if (auto replies = memento->getReplies()) { | ||||||
| 		setReplies(std::move(replies)); | 		setReplies(std::move(replies)); | ||||||
|  | @ -1447,15 +1428,19 @@ void RepliesWidget::listSelectionChanged(SelectedItems &&items) { | ||||||
| 	_topBar->showSelected(state); | 	_topBar->showSelected(state); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| void RepliesWidget::readTill(MsgId tillId) { | void RepliesWidget::readTill(not_null<HistoryItem*> item) { | ||||||
| 	if (!_commentsRoot) { | 	if (!_commentsRoot) { | ||||||
| 		return; | 		return; | ||||||
| 	} | 	} | ||||||
| 	const auto now = _commentsRoot->commentsReadTill(); | 	const auto was = _commentsRoot->commentsReadTill(); | ||||||
| 	if (now < tillId) { | 	const auto now = item->id; | ||||||
| 		_commentsRoot->setCommentsReadTill(tillId); | 	const auto fast = item->out(); | ||||||
|  | 	if (was < now) { | ||||||
|  | 		_commentsRoot->setCommentsReadTill(now); | ||||||
| 		if (!_readRequestTimer.isActive()) { | 		if (!_readRequestTimer.isActive()) { | ||||||
| 			_readRequestTimer.callOnce(kReadRequestTimeout); | 			_readRequestTimer.callOnce(fast ? 0 : kReadRequestTimeout); | ||||||
|  | 		} else if (fast && _readRequestTimer.remainingTime() > 0) { | ||||||
|  | 			_readRequestTimer.callOnce(0); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | @ -1466,31 +1451,66 @@ void RepliesWidget::listVisibleItemsChanged(HistoryItemsList &&items) { | ||||||
| 		return IsServerMsgId(item->id); | 		return IsServerMsgId(item->id); | ||||||
| 	}); | 	}); | ||||||
| 	if (good != end(reversed)) { | 	if (good != end(reversed)) { | ||||||
| 		readTill((*good)->id); | 		readTill(*good); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| std::optional<int> RepliesWidget::listUnreadBarView( | MessagesBarData RepliesWidget::listMessagesBar( | ||||||
| 		const std::vector<not_null<Element*>> &elements) { | 		const std::vector<not_null<Element*>> &elements) { | ||||||
| 	if (!_commentsRoot) { | 	if (!_commentsRoot || elements.empty()) { | ||||||
| 		return std::nullopt; | 		return MessagesBarData(); | ||||||
| 	} | 	} | ||||||
|  | 	const auto rootBar = [&] { | ||||||
|  | 		const auto fromRoot = (elements.front()->data().get() == _root); | ||||||
|  | 		if (elements.size() < 2 || !fromRoot) { | ||||||
|  | 			return MessagesBarData(); | ||||||
|  | 		} | ||||||
|  | 		auto text = rpl::combine( | ||||||
|  | 			_replies->fullCount(), | ||||||
|  | 			_areComments.value() | ||||||
|  | 		) | rpl::map([=](int count, bool areComments) { | ||||||
|  | 			return count | ||||||
|  | 				? (areComments | ||||||
|  | 					? tr::lng_comments_header | ||||||
|  | 					: tr::lng_replies_header)( | ||||||
|  | 						lt_count, | ||||||
|  | 						rpl::single(count) | tr::to_count()) | ||||||
|  | 				: (areComments | ||||||
|  | 					? tr::lng_comments_header_none | ||||||
|  | 					: tr::lng_replies_header_none)(); | ||||||
|  | 		}) | rpl::flatten_latest(); | ||||||
|  | 
 | ||||||
|  | 		return MessagesBarData{ | ||||||
|  | 			// Designated initializers here crash MSVC 16.7.3.
 | ||||||
|  | 			MessagesBar{ | ||||||
|  | 				.element = elements[1], | ||||||
|  | 				.focus = false, | ||||||
|  | 			}, | ||||||
|  | 			std::move(text), | ||||||
|  | 		}; | ||||||
|  | 	}; | ||||||
| 	const auto till = _commentsRoot->commentsReadTill(); | 	const auto till = _commentsRoot->commentsReadTill(); | ||||||
| 	if (till < 2) { | 	if (till < 2) { | ||||||
| 		return std::nullopt; | 		return rootBar(); | ||||||
| 	} | 	} | ||||||
| 	for (auto i = 0, count = int(elements.size()); i != count; ++i) { | 	for (auto i = 0, count = int(elements.size()); i != count; ++i) { | ||||||
| 		const auto item = elements[i]->data(); | 		const auto item = elements[i]->data(); | ||||||
| 		if (item->id > till) { | 		if (IsServerMsgId(item->id) && item->id > till) { | ||||||
| 			if (item->out()) { | 			if (item->out()) { | ||||||
| 				_commentsRoot->setCommentsReadTill(item->id); | 				readTill(item); | ||||||
| 				_readRequestTimer.callOnce(kReadRequestTimeout); |  | ||||||
| 			} else { | 			} else { | ||||||
| 				return i; | 				return MessagesBarData{ | ||||||
|  | 					// Designated initializers here crash MSVC 16.7.3.
 | ||||||
|  | 					MessagesBar{ | ||||||
|  | 						.element = elements[i], | ||||||
|  | 						.focus = true, | ||||||
|  | 					}, | ||||||
|  | 					tr::lng_unread_bar_some(), | ||||||
|  | 				}; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	return std::nullopt; | 	return rootBar(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| void RepliesWidget::listContentRefreshed() { | void RepliesWidget::listContentRefreshed() { | ||||||
|  |  | ||||||
|  | @ -116,7 +116,7 @@ public: | ||||||
| 		not_null<HistoryItem*> second) override; | 		not_null<HistoryItem*> second) override; | ||||||
| 	void listSelectionChanged(SelectedItems &&items) override; | 	void listSelectionChanged(SelectedItems &&items) override; | ||||||
| 	void listVisibleItemsChanged(HistoryItemsList &&items) override; | 	void listVisibleItemsChanged(HistoryItemsList &&items) override; | ||||||
| 	std::optional<int> listUnreadBarView( | 	MessagesBarData listMessagesBar( | ||||||
| 		const std::vector<not_null<Element*>> &elements) override; | 		const std::vector<not_null<Element*>> &elements) override; | ||||||
| 	void listContentRefreshed() override; | 	void listContentRefreshed() override; | ||||||
| 	ClickHandlerPtr listDateLink(not_null<Element*> view) override; | 	ClickHandlerPtr listDateLink(not_null<Element*> view) override; | ||||||
|  | @ -155,7 +155,7 @@ private: | ||||||
| 	void refreshRootView(); | 	void refreshRootView(); | ||||||
| 	void setupDragArea(); | 	void setupDragArea(); | ||||||
| 	void sendReadTillRequest(); | 	void sendReadTillRequest(); | ||||||
| 	void readTill(MsgId id); | 	void readTill(not_null<HistoryItem*> item); | ||||||
| 
 | 
 | ||||||
| 	void setupScrollDownButton(); | 	void setupScrollDownButton(); | ||||||
| 	void scrollDownClicked(); | 	void scrollDownClicked(); | ||||||
|  |  | ||||||
|  | @ -1134,9 +1134,9 @@ void ScheduledWidget::listSelectionChanged(SelectedItems &&items) { | ||||||
| void ScheduledWidget::listVisibleItemsChanged(HistoryItemsList &&items) { | void ScheduledWidget::listVisibleItemsChanged(HistoryItemsList &&items) { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| std::optional<int> ScheduledWidget::listUnreadBarView( | MessagesBarData ScheduledWidget::listMessagesBar( | ||||||
| 		const std::vector<not_null<Element*>> &elements) { | 		const std::vector<not_null<Element*>> &elements) { | ||||||
| 	return std::nullopt; | 	return MessagesBarData(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| void ScheduledWidget::listContentRefreshed() { | void ScheduledWidget::listContentRefreshed() { | ||||||
|  |  | ||||||
|  | @ -106,7 +106,7 @@ public: | ||||||
| 		not_null<HistoryItem*> second) override; | 		not_null<HistoryItem*> second) override; | ||||||
| 	void listSelectionChanged(SelectedItems &&items) override; | 	void listSelectionChanged(SelectedItems &&items) override; | ||||||
| 	void listVisibleItemsChanged(HistoryItemsList &&items) override; | 	void listVisibleItemsChanged(HistoryItemsList &&items) override; | ||||||
| 	std::optional<int> listUnreadBarView( | 	MessagesBarData listMessagesBar( | ||||||
| 		const std::vector<not_null<Element*>> &elements) override; | 		const std::vector<not_null<Element*>> &elements) override; | ||||||
| 	void listContentRefreshed() override; | 	void listContentRefreshed() override; | ||||||
| 	ClickHandlerPtr listDateLink(not_null<Element*> view) override; | 	ClickHandlerPtr listDateLink(not_null<Element*> view) override; | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue
	
	 John Preston
						John Preston