More accurate timeline caching/querying.

This commit is contained in:
Hank Grabowski 2022-11-21 16:21:45 -05:00
parent 1c12d74e3b
commit c0598ad58e
11 changed files with 317 additions and 171 deletions

View file

@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import '../../globals.dart';
import '../../models/timeline_entry.dart';
import '../../services/entry_manager_service.dart';
import '../../services/timeline_manager.dart';
import '../../utils/snackbar_builder.dart';
class InteractionsBarControl extends StatefulWidget {
final TimelineEntry entry;
const InteractionsBarControl({super.key, required this.entry});
@override
@ -14,29 +16,25 @@ class InteractionsBarControl extends StatefulWidget {
}
class _InteractionsBarControlState extends State<InteractionsBarControl> {
bool isFavorited = false;
int reshares = 0;
int comments = 0;
int likes = 0;
static final _logger = Logger('$InteractionsBarControl');
@override
void initState() {
super.initState();
isFavorited = widget.entry.isFavorited;
comments = widget.entry.engagementSummary.repliesCount;
reshares = widget.entry.engagementSummary.rebloggedCount;
likes = widget.entry.engagementSummary.favoritesCount;
}
bool get isFavorited => widget.entry.isFavorited;
int get reshares => widget.entry.engagementSummary.rebloggedCount;
int get comments => widget.entry.engagementSummary.repliesCount;
int get likes => widget.entry.engagementSummary.favoritesCount;
Future<void> toggleFavorited() async {
final newState = !isFavorited;
print('Trying to toggle favorite from $isFavorited to $newState');
final result = await getIt<EntryManagerService>()
_logger.finest('Trying to toggle favorite from $isFavorited to $newState');
final result = await getIt<TimelineManager>()
.toggleFavorited(widget.entry.id, newState);
result.match(onSuccess: (update) {
setState(() {
print('Success toggling! $isFavorited -> ${update.entry.isFavorited}');
isFavorited = update.entry.isFavorited;
_logger.finest(
'Success toggling! $isFavorited -> ${update.entry.isFavorited}');
});
}, onError: (error) {
buildSnackbar(context, 'Error toggling like status: $error');
@ -45,13 +43,14 @@ class _InteractionsBarControlState extends State<InteractionsBarControl> {
@override
Widget build(BuildContext context) {
_logger.finest('Building: ${widget.entry.toShortString()}');
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$likes likes, $reshares reshares, $comments comments'),
IconButton(
onPressed: toggleFavorited,
onPressed: () async => await toggleFavorited(),
icon: isFavorited
? Icon(Icons.thumb_up)
: Icon(Icons.thumb_up_outlined)),

View file

@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_portal/services/entry_manager_service.dart';
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:logging/logging.dart';
import 'package:result_monad/result_monad.dart';
import 'package:url_launcher/url_launcher.dart';
@ -12,6 +12,7 @@ import '../../models/entry_tree_item.dart';
import '../../models/timeline_entry.dart';
import '../../screens/image_viewer_screen.dart';
import '../../services/connections_manager.dart';
import '../../services/timeline_manager.dart';
import '../../utils/dateutils.dart';
import '../../utils/snackbar_builder.dart';
import '../padding.dart';
@ -27,17 +28,15 @@ class StatusControl extends StatefulWidget {
}
class _StatusControlState extends State<StatusControl> {
late EntryTreeItem item;
static final _logger = Logger('$StatusControl');
EntryTreeItem get item => widget.originalItem;
TimelineEntry get entry => item.entry;
@override
void initState() {
super.initState();
item = widget.originalItem;
}
@override
Widget build(BuildContext context) {
_logger.finest('Building ${item.entry.toShortString()}');
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
@ -196,12 +195,9 @@ class _StatusControlState extends State<StatusControl> {
if (entry.parentId.isEmpty)
ElevatedButton(
onPressed: () async {
await getIt<EntryManagerService>()
.refreshPost(widget.originalItem)
.andThenSuccessAsync((newItem) async => setState(() {
print('Updated item');
item = newItem;
}));
await getIt<TimelineManager>()
.refreshPost(item.id)
.andThenSuccessAsync((newItem) async => setState(() {}));
},
child: Center(
child: Text('Load All'),

View file

@ -92,12 +92,12 @@ class FriendicaClient {
FutureResult<List<TimelineEntry>, ExecError> getPostOrComment(String id,
{bool fullContext = false}) async {
_logger.finest(
() => 'Getting entry for status $id, full context? $fullContext');
return (await runCatchingAsync(() async {
final baseUrl = 'https://$serverName/api/v1/statuses/$id';
final url = fullContext ? '$baseUrl/context' : baseUrl;
final request = Uri.parse(url);
_logger.finest(() =>
'Getting entry for status $id, full context? $fullContext : $url');
return (await _getApiRequest(request).andThenSuccessAsync((json) async {
if (fullContext) {
final ancestors = json['ancestors'] as List<dynamic>;
@ -167,8 +167,8 @@ class FriendicaClient {
}
FutureResult<String, ExecError> _getUrl(Uri url) async {
_logger.finest('GET: $url');
try {
//SecurityContext.defaultContext.setTrustedCertificates('/etc/apache2/certificate/apache-certificate.crt');
final response = await http.get(
url,
headers: {
@ -191,6 +191,7 @@ class FriendicaClient {
FutureResult<String, ExecError> _postUrl(
Uri url, Map<String, dynamic> body) async {
_logger.finest('POST: $url');
try {
final response = await http.post(
url,

View file

@ -19,12 +19,12 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: '.env');
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((event) {
final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName;
final msg =
'${event.level.name} - $logName @ ${event.time}: ${event.message}';
print(msg);
});
// Logger.root.onRecord.listen((event) {
// final logName = event.loggerName.isEmpty ? 'ROOT' : event.loggerName;
// final msg =
// '${event.level.name} - $logName @ ${event.time}: ${event.message}';
// print(msg);
// });
final authService = AuthService();
final secretsService = SecretsService();

View file

@ -7,20 +7,41 @@ class EntryTreeItem {
final _children = <String, EntryTreeItem>{};
EntryTreeItem(this.entry, {this.isMine = true, this.isOrphaned = false});
EntryTreeItem(this.entry,
{this.isMine = true,
this.isOrphaned = false,
Map<String, EntryTreeItem>? initialChildren}) {
_children.addAll(initialChildren ?? {});
}
EntryTreeItem copy({required TimelineEntry entry}) => EntryTreeItem(
entry,
isMine: isMine,
isOrphaned: isOrphaned,
initialChildren: _children,
);
String get id => entry.id;
void addChild(EntryTreeItem child) {
void addOrUpdate(EntryTreeItem child) {
_children[child.id] = child;
}
EntryTreeItem? getChildById(String id) {
if (_children.containsKey(id)) {
return _children[id]!;
}
for (final c in _children.values) {
final result = c.getChildById(id);
if (result != null) {
return result;
}
}
return null;
}
int get totalChildren {
int t = _children.length;
for (final c in _children.values) {
@ -37,8 +58,15 @@ class EntryTreeItem {
identical(this, other) ||
other is EntryTreeItem &&
runtimeType == other.runtimeType &&
entry == other.entry;
entry == other.entry &&
isMine == other.isMine &&
isOrphaned == other.isOrphaned &&
_children == other._children;
@override
int get hashCode => entry.hashCode;
int get hashCode =>
entry.hashCode ^
isMine.hashCode ^
isOrphaned.hashCode ^
_children.hashCode;
}

View file

@ -4,7 +4,7 @@ import 'entry_tree_item.dart';
class Timeline {
final TimelineIdentifiers id;
final List<EntryTreeItem> _posts = [];
final Set<EntryTreeItem> _postsSet = {};
final Map<String, EntryTreeItem> _postsById = {};
int _lowestStatusId = 0;
int _highestStatusId = 0;
@ -12,13 +12,13 @@ class Timeline {
Timeline(this.id, {List<EntryTreeItem>? initialPosts}) {
if (initialPosts != null) {
addPosts(initialPosts);
addOrUpdate(initialPosts);
}
}
List<EntryTreeItem> get posts => List.unmodifiable(_posts);
void addPosts(List<EntryTreeItem> newPosts) {
void addOrUpdate(List<EntryTreeItem> newPosts) {
for (final p in newPosts) {
final id = int.parse(p.id);
if (_lowestStatusId > id) {
@ -28,18 +28,33 @@ class Timeline {
if (_highestStatusId < id) {
_highestStatusId = id;
}
_postsSet.add(p);
_postsById[p.id] = p;
}
_posts.clear();
_posts.addAll(_postsSet);
_posts.addAll(_postsById.values);
_posts.sort((p1, p2) {
return p2.entry.backdatedTimestamp.compareTo(p1.entry.backdatedTimestamp);
});
}
bool tryUpdateComment(EntryTreeItem comment) {
var changed = false;
final parentId = comment.entry.parentId;
for (final p in _posts) {
final parent =
p.id == parentId ? p : p.getChildById(comment.entry.parentId);
if (parent != null) {
parent.addOrUpdate(comment);
changed = true;
}
}
return changed;
}
void clear() {
_posts.clear();
_postsSet.clear();
_postsById.clear();
_lowestStatusId = 0;
_highestStatusId = 0;
}

View file

@ -139,7 +139,11 @@ class TimelineEntry {
@override
String toString() {
return 'TimelineEntry{id: $id, isReshare: $isReshare, parentId: $parentId, creationTimestamp: $creationTimestamp, modificationTimestamp: $modificationTimestamp, backdatedTimeStamp: $backdatedTimestamp, post: $body, title: $title, author: $author, parentAuthor: $parentAuthor externalLink:$externalLink}';
return 'TimelineEntry{id: $id, isReshare: $isReshare, isFavorited: $isFavorited, parentId: $parentId, creationTimestamp: $creationTimestamp, modificationTimestamp: $modificationTimestamp, backdatedTimeStamp: $backdatedTimestamp, post: $body, title: $title, author: $author, parentAuthor: $parentAuthor externalLink:$externalLink}';
}
String toShortString() {
return 'TimelineEntry{id: $id, isReshare: $isReshare, isFavorited: $isFavorited, parentId: $parentId}';
}
@override
@ -147,8 +151,47 @@ class TimelineEntry {
identical(this, other) ||
other is TimelineEntry &&
runtimeType == other.runtimeType &&
id == other.id;
id == other.id &&
parentId == other.parentId &&
parentAuthor == other.parentAuthor &&
parentAuthorId == other.parentAuthorId &&
creationTimestamp == other.creationTimestamp &&
backdatedTimestamp == other.backdatedTimestamp &&
modificationTimestamp == other.modificationTimestamp &&
body == other.body &&
title == other.title &&
isReshare == other.isReshare &&
author == other.author &&
authorId == other.authorId &&
externalLink == other.externalLink &&
locationData == other.locationData &&
isFavorited == other.isFavorited &&
links == other.links &&
likes == other.likes &&
dislikes == other.dislikes &&
mediaAttachments == other.mediaAttachments &&
engagementSummary == other.engagementSummary;
@override
int get hashCode => id.hashCode;
int get hashCode =>
id.hashCode ^
parentId.hashCode ^
parentAuthor.hashCode ^
parentAuthorId.hashCode ^
creationTimestamp.hashCode ^
backdatedTimestamp.hashCode ^
modificationTimestamp.hashCode ^
body.hashCode ^
title.hashCode ^
isReshare.hashCode ^
author.hashCode ^
authorId.hashCode ^
externalLink.hashCode ^
locationData.hashCode ^
isFavorited.hashCode ^
links.hashCode ^
likes.hashCode ^
dislikes.hashCode ^
mediaAttachments.hashCode ^
engagementSummary.hashCode;
}

View file

@ -76,15 +76,24 @@ class _HomeScreenState extends State<HomeScreen> {
print('items count = ${items.length}');
return RefreshIndicator(
onRefresh: () async {
await manager.refreshTimeline(TimelineIdentifiers.home());
await manager.refreshTimeline(TimelineIdentifiers.home(), true);
},
child: ListView.separated(
itemBuilder: (context, index) {
print('Building item: $index');
return StatusControl(originalItem: items[index]);
if (index == 0) {
return ElevatedButton(
onPressed: () async => await manager.refreshTimeline(
TimelineIdentifiers.home(), false),
child: Text('Load newer posts'));
}
final itemIndex = index - 1;
final item = items[itemIndex];
_logger.finest(
'Building item: $itemIndex: ${item.entry.toShortString()}');
return StatusControl(originalItem: item);
},
separatorBuilder: (context, index) => Divider(),
itemCount: items.length,
itemCount: items.length + 1,
),
);
}

View file

@ -24,7 +24,7 @@ extension TimelineEntryMastodonExtensions on TimelineEntry {
})
: 0;
final id = json['id'] ?? '';
final isReshare = json.containsKey('reblogged');
final isReshare = json['reblogged'] ?? false;
final parentId = json['in_reply_to_id'] ?? '';
final parentAuthor = json['in_reply_to_account_id'] ?? '';
final parentAuthorId = json['in_reply_to_account_id'] ?? '';

View file

@ -12,15 +12,13 @@ import 'auth_service.dart';
class EntryManagerService extends ChangeNotifier {
static final _logger = Logger('$EntryManagerService');
final Map<String, EntryTreeItem> _posts = {};
final List<EntryTreeItem> _orphanedComments = [];
final Map<String, EntryTreeItem> _allComments = {};
final _entries = <String, TimelineEntry>{};
final _parentPostIds = <String, String>{};
final _postNodes = <String, _Node>{};
void clear() {
_posts.clear();
_orphanedComments.clear();
_allComments.clear();
notifyListeners();
_entries.clear();
_parentPostIds.clear();
}
FutureResult<List<EntryTreeItem>, ExecError> updateTimeline(
@ -45,10 +43,15 @@ class EntryManagerService extends ChangeNotifier {
itemsResult.value.sort((t1, t2) => t1.id.compareTo(t2.id));
final updatedPosts =
await processNewItems(itemsResult.value, myHandle, client);
_orphanedComments.removeWhere((element) => !element.isOrphaned);
_logger.finest(() =>
'End of update # posts: ${_posts.length}, #comments: ${_allComments.length}, #orphans: ${_orphanedComments.length}');
notifyListeners();
_logger.finest(() {
final postCount = _entries.values.where((e) => e.parentId.isEmpty).length;
final commentCount = _entries.length - postCount;
final orphanCount = _entries.values
.where(
(e) => e.parentId.isNotEmpty && !_entries.containsKey(e.parentId))
.length;
return 'End of update # posts: $postCount, #comments: $commentCount, #orphans: $orphanCount';
});
return Result.ok(updatedPosts);
}
@ -57,74 +60,67 @@ class EntryManagerService extends ChangeNotifier {
String myHandle,
FriendicaClient? client,
) async {
final updatedPosts = <EntryTreeItem>[];
final allSeenItems = [...items];
for (final item in items) {
late EntryTreeItem entry;
final isMine = item.author == myHandle;
if (item.parentAuthor.isEmpty) {
entry = _posts.putIfAbsent(item.id,
() => EntryTreeItem(item, isMine: isMine, isOrphaned: false));
updatedPosts.add(entry);
_entries[item.id] = item;
}
final orphans = <TimelineEntry>[];
for (final item in items) {
final parent = _entries[item.parentId];
if (parent == null) {
orphans.add(item);
} else {
final parentPost = _posts[item.parentId];
bool newEntry = false;
entry = _allComments.putIfAbsent(item.id, () {
newEntry = true;
return EntryTreeItem(
item,
isMine: isMine,
isOrphaned: parentPost == null,
);
});
if (parentPost != null) {
parentPost.addChild(entry);
updatedPosts.add(parentPost);
}
if (newEntry && entry.isOrphaned) {
_orphanedComments.add(entry);
if (parent.parentId.isEmpty) {
_parentPostIds[item.id] = parent.id;
}
}
}
final orphansToProcess = [];
for (final c in _orphanedComments) {
final post = _posts[c.entry.parentId];
if (post != null) {
c.isOrphaned = false;
post.addChild(c);
updatedPosts.add(post);
continue;
}
final comment = _allComments[c.entry.parentId];
if (comment != null) {
c.isOrphaned = false;
comment.addChild(c);
continue;
}
if (client != null) {
orphansToProcess.add(c);
}
}
for (final o in orphansToProcess) {
for (final o in orphans) {
await client
?.getPostOrComment(o.id, fullContext: true)
.andThenSuccessAsync((items) async {
final moreUpdatedPosts = await processNewItems(items, myHandle, null);
updatedPosts.addAll(moreUpdatedPosts);
final parentPostId = items.firstWhere((e) => e.parentId.isEmpty).id;
_parentPostIds[o.id] = parentPostId;
allSeenItems.addAll(items);
for (final item in items) {
_entries[item.id] = item;
_parentPostIds[item.id] = parentPostId;
}
;
});
}
allSeenItems.sort((i1, i2) => int.parse(i1.id).compareTo(int.parse(i2.id)));
final postNodesToReturn = <_Node>{};
for (final item in allSeenItems) {
if (item.parentId.isEmpty) {
final postNode = _postNodes.putIfAbsent(item.id, () => _Node(item.id));
postNodesToReturn.add(postNode);
} else {
final parentPostNode = _postNodes[_parentPostIds[item.id]]!;
postNodesToReturn.add(parentPostNode);
if (parentPostNode.getChildById(item.id) == null) {
final newNode = _Node(item.id);
final injectionNode = parentPostNode.id == item.parentId
? parentPostNode
: parentPostNode.getChildById(item.parentId)!;
injectionNode.addChild(newNode);
}
}
}
final updatedPosts =
postNodesToReturn.map((node) => _nodeToTreeItem(node)).toList();
_logger.finest(
'Completed processing new items ${client == null ? 'sub level' : 'top level'}');
return updatedPosts;
}
FutureResult<EntryTreeItem, ExecError> refreshPost(EntryTreeItem item) async {
_logger.finest('Refreshing post: ${item.id}');
FutureResult<EntryTreeItem, ExecError> refreshPost(String id) async {
_logger.finest('Refreshing post: $id');
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
@ -134,18 +130,22 @@ class EntryManagerService extends ChangeNotifier {
final client = clientResult.value;
final result = await client
.getPostOrComment(item.id, fullContext: true)
.getPostOrComment(id, fullContext: false)
.andThenSuccessAsync((items) async {
await processNewItems(items, client.credentials.username, null);
});
await processNewItems(items, client.credentials.username, null);
})
.andThenAsync(
(_) async => await client.getPostOrComment(id, fullContext: true))
.andThenSuccessAsync((items) async {
await processNewItems(items, client.credentials.username, null);
});
return result.mapValue((_) {
_logger.finest('${item.id} post updated');
notifyListeners();
return _posts[item.id]!;
_logger.finest('$id post updated');
return _nodeToTreeItem(_postNodes[id]!);
}).mapError(
(error) {
_logger.finest('${item.id} error updating: $error');
_logger.finest('$id error updating: $error');
return ExecError(
type: ErrorType.localError,
message: error.toString(),
@ -155,8 +155,7 @@ class EntryManagerService extends ChangeNotifier {
}
FutureResult<EntryTreeItem, ExecError> toggleFavorited(
String id, bool newStatus,
{bool notify = false}) async {
String id, bool newStatus) async {
final auth = getIt<AuthService>();
final clientResult = auth.currentClient;
if (clientResult.isFailure) {
@ -170,35 +169,60 @@ class EntryManagerService extends ChangeNotifier {
}
final update = result.value;
late EntryTreeItem rval;
if (_posts.containsKey(update.id)) {
rval = _posts[update.id]!.copy(entry: update);
_posts[update.id] = rval;
_updateChildrenEntities(rval, update);
}
_entries[update.id] = update;
final node = update.parentId.isEmpty
? _postNodes[update.id]!
: _postNodes[update.parentId]!.getChildById(update.id)!;
if (_allComments.containsKey(update.id)) {
rval = _allComments[update.id]!.copy(entry: update);
_allComments[update.id] = rval;
_updateChildrenEntities(rval, update);
}
if (notify) {
notifyListeners();
}
return Result.ok(rval);
notifyListeners();
return Result.ok(_nodeToTreeItem(node));
}
void _updateChildrenEntities(EntryTreeItem item, TimelineEntry entry) {
final updates = item.children.where((element) => element.id == entry.id);
for (final u in updates) {
final newItem = u.copy(entry: entry);
item.children.remove(u);
item.children.add(newItem);
}
for (final c in item.children) {
_updateChildrenEntities(c, entry);
EntryTreeItem _nodeToTreeItem(_Node node) {
final childenEntries = <String, EntryTreeItem>{};
for (final c in node.children) {
childenEntries[c.id] = _nodeToTreeItem(c);
}
return EntryTreeItem(_entries[node.id]!, initialChildren: childenEntries);
}
}
class _Node {
final String id;
final _children = <String, _Node>{};
List<_Node> get children => _children.values.toList();
_Node(this.id, {Map<String, _Node>? initialChildren}) {
if (initialChildren != null) {
_children.addAll(initialChildren);
}
}
void addChild(_Node node) {
_children[node.id] = node;
}
_Node? getChildById(String id) {
if (_children.containsKey(id)) {
return _children[id]!;
}
for (final c in _children.values) {
final result = c.getChildById(id);
if (result != null) {
return result;
}
}
return null;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is _Node && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}

View file

@ -22,20 +22,32 @@ class TimelineManager extends ChangeNotifier {
// refresh timeline gets statuses newer than the newest in that timeline
Result<List<EntryTreeItem>, ExecError> getTimeline(TimelineIdentifiers type) {
_logger.finest('Getting timeline $type');
final posts = cachedTimelines[type]?.posts;
if (posts != null) {
return Result.ok(posts);
}
refreshTimeline(type);
refreshTimeline(type, true);
return Result.ok([]);
}
Future<void> refreshTimeline(TimelineIdentifiers type) async {
FutureResult<EntryTreeItem, ExecError> refreshPost(String id) async {
final result = await getIt<EntryManagerService>().refreshPost(id);
if (result.isSuccess) {
for (final t in cachedTimelines.values) {
t.addOrUpdate([result.value]);
}
notifyListeners();
}
return result;
}
Future<void> refreshTimeline(TimelineIdentifiers type, bool reload) async {
final timeline = cachedTimelines.putIfAbsent(type, () => Timeline(type));
(await getIt<EntryManagerService>()
.updateTimeline(type, timeline.highestStatusId))
.updateTimeline(type, reload ? 0 : timeline.highestStatusId))
.match(onSuccess: (posts) {
_logger.finest('Posts returned for adding to $type: ${posts.length}');
timeline.addOrUpdate(posts);
@ -43,19 +55,38 @@ class TimelineManager extends ChangeNotifier {
}, onError: (error) {
_logger.severe('Error getting timeline: $type}');
});
}
FutureResult<EntryTreeItem, ExecError> toggleFavorited(
String id, bool newStatus) async {
_logger.finest('Attempting toggling favorite $id to $newStatus');
final result =
await getIt<EntryManagerService>().toggleFavorited(id, newStatus);
if (result.isFailure) {
_logger.finest('Error toggling favorite $id: ${result.error}');
return result;
}
final update = result.value;
for (final timeline in cachedTimelines.values) {
update.entry.parentId.isEmpty
? timeline.addOrUpdate([update])
: timeline.tryUpdateComment(update);
}
notifyListeners();
return result;
}
// All statuses get dumped into the entity mangager and get full assembled posts out of it
// Timeline keeps track of posts level only so can query timeline manager for those
// Should put backing store on timelines and entity manager so can recover from restart faster
// Have a purge caches button to start that over from scratch
// Should have a contacts manager with backing store as well
// Timeline view is new control that knows how to load timeline, scrolling around with refresh and get more
// Timeline Item view displays itself and children
// Has "Add Comment" value
// Has like/dislke
// Has reshare/quote reshare (if can get that working somehow)
// If our own has delete
// All statuses get dumped into the entity mangager and get full assembled posts out of it
// Timeline keeps track of posts level only so can query timeline manager for those
// Should put backing store on timelines and entity manager so can recover from restart faster
// Have a purge caches button to start that over from scratch
// Should have a contacts manager with backing store as well
// Timeline view is new control that knows how to load timeline, scrolling around with refresh and get more
// Timeline Item view displays itself and children
// Has "Add Comment" value
// Has like/dislke
// Has reshare/quote reshare (if can get that working somehow)
// If our own has delete
}