...
 
Commits (16)
......@@ -152,6 +152,8 @@ exports[`Activity screen component renders correctly with an entity as param 1`]
"getBoosts": [MockFunction],
"getFeed": [MockFunction],
"getFeedChannel": [MockFunction],
"getFeedFromService": [MockFunction],
"getFeedLegacy": [MockFunction],
"getFeedSuggested": [MockFunction],
},
"stores": Object {
......@@ -466,6 +468,8 @@ exports[`Activity screen component should show loader until it loads the activit
"getBoosts": [MockFunction],
"getFeed": [MockFunction],
"getFeedChannel": [MockFunction],
"getFeedFromService": [MockFunction],
"getFeedLegacy": [MockFunction],
"getFeedSuggested": [MockFunction],
},
"stores": Object {
......
......@@ -39,7 +39,7 @@ exports[`blog card component should renders correctly 1`] = `
source={
Object {
"headers": Object {
"App-Version": "3.5.0",
"App-Version": "3.6.0",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
},
......
......@@ -158,7 +158,7 @@ exports[`blog view screen component should renders correctly 1`] = `
source={
Object {
"headers": Object {
"App-Version": "3.5.0",
"App-Version": "3.6.0",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
},
......
......@@ -22,7 +22,7 @@ exports[`channel header component owner should render correctly 1`] = `
source={
Object {
"headers": Object {
"App-Version": "3.5.0",
"App-Version": "3.6.0",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
},
......
......@@ -19,7 +19,7 @@
MYAPP_RELEASE_STORE_FILE=minds.keystore
MYAPP_RELEASE_KEY_ALIAS=alias_name
org.gradle.jvmargs=-Xmx2048m
versionCode=310022
versionName=3.5.0
versionCode=310023
versionName=3.6.0
systemProp.org.gradle.internal.http.connectionTimeout=180000
systemProp.org.gradle.internal.http.socketTimeout=180000
......@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>3.5.0</string>
<string>3.6.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
......@@ -36,7 +36,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>201905090001</string>
<string>201905170001</string>
<key>CodePushDeploymentKey</key>
<string>_C083_CqL7CmKwASrv6Xrj1wqH7erJMhIBnRQ</string>
<key>ITSAppUsesNonExemptEncryption</key>
......
......@@ -2,6 +2,9 @@ import api from './../common/services/api.service';
import { abort } from '../common/helpers/abortableFetch';
import blockListService from '../common/services/block-list.service';
import logService from '../common/services/log.service';
import feedService from '../common/services/feed.service';
import featuresService from '../common/services/features.service';
import entitiesService from '../common/services/entities.service';
/**
* Channel Service
......@@ -54,12 +57,58 @@ class ChannelService {
return result;
}
async getFeedFromService(guid, type, opts = { limit: 12 }) {
const limit = opts.limit || 12;
const { entities, next } = await feedService.get({
endpoint: `api/v2/feeds/container/${guid}/${type}`,
timebased: true,
limit,
offset: opts.offset || 0,
syncPageSize: limit * 20,
});
return {
entities: entities || [],
offset: entities && entities.length ? next : '',
}
}
async getFeed(guid, opts = { limit: 12 }) {
if (featuresService.has('es-feeds')) {
const pinned = [];
const { entities, offset } = await this.getFeedFromService(guid, 'activities', {
limit: opts.limit,
offset: opts.offset,
});
if (opts.pinned) {
const pinnedEntities = (await entitiesService.fetch(opts.pinned.split(',')))
.filter(entity => Boolean(entity))
.filter(entity => ({
...entity,
pinned: true,
}));
pinned.push(...pinnedEntities);
}
return {
entities: [...pinned, ...(entities || [])],
offset,
};
} else {
return await this.getFeedLegacy(guid, opts);
}
}
/**
*
* @param {string} guid
* @param {object} opts
*/
async getFeed(guid, opts = {limit: 12}) {
async getFeedLegacy(guid, opts = {limit: 12}) {
const tag = `channel:feed:${guid}`;
// abort previous call
abort(tag);
......@@ -82,7 +131,18 @@ class ChannelService {
return feed;
}
getImageFeed(guid, offset) {
async getImageFeed(guid, offset) {
if (featuresService.has('es-feeds')) {
return await this.getFeedFromService(guid, 'images', {
limit: 12,
offset,
});
} else {
return await this.getImageFeedLegacy(guid, offset);
}
}
getImageFeedLegacy(guid, offset) {
const tag = `channel:images:${guid}`;
// abort previous call
abort(tag);
......@@ -100,7 +160,18 @@ class ChannelService {
})
}
getVideoFeed(guid, offset) {
async getVideoFeed(guid, offset) {
if (featuresService.has('es-feeds')) {
return await this.getFeedFromService(guid, 'videos', {
limit: 12,
offset,
});
} else {
return await this.getVideoFeedLegacy(guid, offset);
}
}
getVideoFeedLegacy(guid, offset) {
const tag = `channel:images:${guid}`;
// abort previous call
abort(tag);
......@@ -118,7 +189,18 @@ class ChannelService {
})
}
getBlogFeed(guid, offset) {
async getBlogFeed(guid, offset) {
if (featuresService.has('es-feeds')) {
return await this.getFeedFromService(guid, 'blogs', {
limit: 12,
offset,
});
} else {
return await this.getBlogFeedLegacy(guid, offset);
}
}
getBlogFeedLegacy(guid, offset) {
const tag = `channel:blog:${guid}`;
// abort previous call
abort(tag);
......
......@@ -32,6 +32,9 @@ import CaptureMetaPreview from '../capture/CaptureMetaPreview';
import { CommonStyle as CS } from '../styles/Common';
import { ComponentsStyle as CmpStyle } from '../styles/Components';
import blockListService from '../common/services/block-list.service';
import autobind from "../common/helpers/autobind";
// types
type Props = {
header?: any,
......@@ -92,6 +95,7 @@ export default class CommentList extends React.Component<Props, State> {
focused: false,
hideInput: false,
guid: null,
blockedChannels: [],
selection: {
start:0,
end: 0
......@@ -106,18 +110,27 @@ export default class CommentList extends React.Component<Props, State> {
this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this._keyboardDidHide);
this.props.store.setEntity(this.props.entity);
this.loadComments();
blockListService.events.on('change', this._onBlockListChange);
}
/**
* Component will unmount
*/
componentWillUnmount() {
blockListService.events.removeListener('change', this._onBlockListChange);
this.keyboardDidShowListener.remove();
this.keyboardDidHideListener.remove();
this.props.store.unlisten();
this.props.store.clearComments();
}
@autobind
_onBlockListChange() {
this.loadBlockedChannels();
}
/**
* Post comment
*/
......@@ -211,11 +224,13 @@ export default class CommentList extends React.Component<Props, State> {
onChildFocus = (item, offset) => {
if (!offset) offset = 0;
const comments = this.getComments();
if (!this.props.parent) {
this.focusedChild = this.props.store.comments.findIndex(c => item === c);
this.focusedChild = comments.findIndex(c => item === c);
this.focusedOffset = offset;
} else {
const index = this.props.store.comments.findIndex(c => item === c);
const index = comments.findIndex(c => item === c);
const frame = this.listRef._listRef._getFrameMetricsApprox(index);
if (this.props.onInputFocus) {
this.props.onInputFocus(item, offset + frame.offset + frame.length);
......@@ -241,6 +256,8 @@ export default class CommentList extends React.Component<Props, State> {
* Load comments
*/
loadComments = async (loadingMore = false, descending = true) => {
await this.loadBlockedChannels();
let guid;
const scrollToBottom = this.props.navigation.state.params.scrollToBottom;
......@@ -258,6 +275,14 @@ export default class CommentList extends React.Component<Props, State> {
}
}
@autobind
async loadBlockedChannels() {
this.setState({
blockedChannels: (await blockListService.getList()) || [],
});
}
/**
* Select media source
*/
......@@ -395,6 +420,21 @@ export default class CommentList extends React.Component<Props, State> {
return null;
}
getComments() {
if (!this.props.store.comments) {
return [];
}
return this.props.store.comments
.filter(comment => Boolean(comment))
.filter(comment => this.state.blockedChannels.indexOf(comment.owner_guid) === -1);
}
isWholeThreadBlocked() {
return this.props.store.comments.length > 0 &&
this.props.store.comments.length !== this.getComments().length;
}
/**
* Render
*/
......@@ -413,6 +453,14 @@ export default class CommentList extends React.Component<Props, State> {
/>
}
const comments = this.getComments();
const emptyThread = (<View style={[CS.textCenter]}>
{this.isWholeThreadBlocked() && <Text style={[CS.textCenter, CS.marginBottom2x, CS.marginTop2x, CS.fontLight]}>
This thread contains replies from blocked channels.
</Text>}
</View>);
return (
<View style={[CS.flexContainer, CS.backgroundWhite, paddingBottom]} onLayout={this.onLayout}>
<KeyboardAvoidingView style={[CS.flexContainer]} behavior={Platform.OS == 'ios' ? 'padding' : null}
......@@ -421,14 +469,14 @@ export default class CommentList extends React.Component<Props, State> {
<FlatList
ref={ref => this.listRef = ref}
ListHeaderComponent={header}
data={this.props.store.comments.slice()}
data={comments}
keyboardShouldPersistTaps={'handled'}
renderItem={this.renderComment}
keyExtractor={item => item.guid}
initialNumToRender={25}
onRefresh={this.refresh}
refreshing={this.props.store.refreshing}
ListEmptyComponent={this.props.store.loaded && !this.props.store.refreshing ? <View/> : <CenteredLoading/>}
ListEmptyComponent={this.props.store.loaded && !this.props.store.refreshing ? emptyThread : <CenteredLoading/>}
style={[CS.flexContainer, CS.backgroundWhite]}
/>
{this.renderPoster()}
......@@ -450,4 +498,4 @@ export default class CommentList extends React.Component<Props, State> {
</View>
);
}
}
\ No newline at end of file
}
export default function asyncSleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
export default function normalizeUrn(urnOrGuid) {
if (!isNaN(urnOrGuid)) {
urnOrGuid = `urn:entity:${urnOrGuid}`;
} else if (urnOrGuid.indexOf('urn:') !== 0) {
console.warn(`Invalid URN: ${urnOrGuid}`);
}
return urnOrGuid;
}
......@@ -29,19 +29,17 @@ class ApiService {
return headers;
}
buildParamsString(params) {
const basicAuth = MINDS_URI_SETTINGS && MINDS_URI_SETTINGS.basicAuth,
accessToken = session.token;
buildUrl(url, params = {}) {
if (!params) {
params = {};
}
params['cb'] = Date.now(); //bust the cache every time
const paramsString = this.getParamsString(params);
const sep = url.indexOf('?') > -1 ? '&' : '?';
if (paramsString) {
return `?${paramsString}`;
}
return '';
return `${url}${sep}${paramsString}`
}
getParamsString(params) {
......@@ -57,10 +55,9 @@ class ApiService {
* @param {mixed} tag
*/
async get(url, params = {}, tag) {
const paramsString = this.buildParamsString(params);
const headers = this.buildHeaders();
try {
const response = await abortableFetch(MINDS_API_URI + url + paramsString, { headers }, tag);
const response = await abortableFetch(MINDS_API_URI + this.buildUrl(url, params), { headers }, tag);
// Bad response
if (!response.ok) {
......@@ -87,11 +84,10 @@ class ApiService {
}
async post(url, body={}) {
const paramsString = this.buildParamsString({});
const headers = this.buildHeaders();
try {
let response = await abortableFetch(MINDS_API_URI + url + paramsString, { method: 'POST', body: JSON.stringify(body), headers });
let response = await abortableFetch(MINDS_API_URI + this.buildUrl(url), { method: 'POST', body: JSON.stringify(body), headers });
if (!response.ok) {
throw response;
......@@ -117,11 +113,10 @@ class ApiService {
}
async put(url, body={}) {
const paramsString = this.buildParamsString({});
const headers = this.buildHeaders();
try {
let response = await abortableFetch(MINDS_API_URI + url + paramsString, { method: 'PUT', body: JSON.stringify(body), headers });
let response = await abortableFetch(MINDS_API_URI + this.buildUrl(url), { method: 'PUT', body: JSON.stringify(body), headers });
if (!response.ok) {
throw response;
......@@ -147,11 +142,10 @@ class ApiService {
}
async delete(url, body={}) {
const paramsString = this.buildParamsString({});
const headers = this.buildHeaders();
try {
let response = await abortableFetch(MINDS_API_URI + url + paramsString, { method: 'DELETE', body: JSON.stringify(body), headers });
let response = await abortableFetch(MINDS_API_URI + this.buildUrl(url), { method: 'DELETE', body: JSON.stringify(body), headers });
if (!response.ok) {
throw response;
......@@ -177,7 +171,6 @@ class ApiService {
}
upload(url, file, data=null, progress) {
const paramsString = this.buildParamsString({});
var formData = new FormData();
formData.append('file', file);
for (var key in data) {
......@@ -197,7 +190,7 @@ class ApiService {
if (progress) {
xhr.upload.addEventListener("progress", progress);
}
xhr.open('POST', MINDS_API_URI + url + paramsString);
xhr.open('POST', MINDS_API_URI + this.buildUrl(url));
xhr.setRequestHeader('Authorization', `Bearer ${session.token}`);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('Content-Type', 'multipart/form-data;');
......
......@@ -2,14 +2,24 @@ import BlockListSync from "../../lib/minds-sync/services/BlockListSync";
import apiService from "./api.service";
import sqliteStorageProviderService from "./sqlite-storage-provider.service";
import sessionService from "./session.service";
import { EventEmitter } from "events";
class BlockListService {
constructor() {
const storageAdapter = sqliteStorageProviderService.get();
this.sync = new BlockListSync(apiService, storageAdapter);
// Properties
this.sync = new BlockListSync(apiService, sqliteStorageProviderService.get());
this._emitter = new EventEmitter();
this._cached = [];
// Initialization
this.sync.setUp();
// Events / Reactiveness
sessionService.onSession(token => {
if (token) {
this.doSync();
......@@ -17,26 +27,52 @@ class BlockListService {
this.prune();
}
});
// Update cache on changes
this._emitter.on('change', () => this.getList());
}
async doSync() {
return await this.sync.sync();
await this.sync.sync();
await this.getList();
}
async getList() {
return await this.sync.getList();
const list = (await this.sync.getList()) || [];
this._cached = list;
return [...list];
}
/**
* @returns {String[]}
*/
getCachedList() {
return [...this._cached];
}
async add(guid: string) {
return await this.sync.add(guid);
const result = await this.sync.add(guid);
this._emitter.emit('change');
return result;
}
async remove(guid: string) {
return await this.sync.remove(guid);
const result = await this.sync.remove(guid);
this._emitter.emit('change');
return result;
}
async prune() {
return await this.sync.prune();
const result = await this.sync.prune();
this._emitter.emit('change');
return result;
}
/**
* @returns {module:events.internal.EventEmitter}
*/
get events() {
return this._emitter;
}
}
......
......@@ -10,14 +10,13 @@ class FeedsService {
constructor() {
const storageAdapter = sqliteStorageProviderService.get();
this.sync = new FeedsSync(apiService, storageAdapter, 15, 600);
this.sync = new FeedsSync(apiService, storageAdapter, 15);
this.sync.setResolvers({
stringHash: value => hashCode(value),
currentUser: () => sessionService.guid,
blockedUserGuids: async () => await blockListService.getList(),
fetchEntities: async guids => await entitiesService.fetch(guids),
prefetchEntities: async guids => await entitiesService.prefetch(guids),
});
this.sync.setUp();
......@@ -37,4 +36,4 @@ class FeedsService {
}
export default new FeedsService();
\ No newline at end of file
export default new FeedsService();
......@@ -75,7 +75,7 @@ export default class OffsetListStore {
}
@action
async clearList(updateLoaded=true) {
async clearList(updateLoaded = true) {
this.entities = [];
this.offset = '';
this.errorLoading = false;
......
export const Version = {
VERSION: '3.5.0',
BUILD: '20190509'
VERSION: '3.6.0',
BUILD: '20190517'
};
......@@ -34,7 +34,7 @@ import ErrorBoundary from '../common/components/ErrorBoundary';
/**
* Discovery Feed Screen
*/
@inject('discovery', 'channel')
@inject('discovery')
@observer
export default class DiscoveryFeedScreen extends Component {
......@@ -48,80 +48,45 @@ export default class DiscoveryFeedScreen extends Component {
* Render
*/
render() {
const discovery = this.props.discovery;
const list = discovery.list;
let renderRow;
switch (discovery.filters.type) {
case 'lastchannels':
case 'channels':
renderRow = this.renderUser;
break;
case 'groups':
renderRow = this.renderGroup;
break;
case 'blogs':
renderRow = this.renderBlog;
break;
case 'activities':
default:
renderRow = this.renderActivity;
break;
}
const footer = this.getFooter();
const showFeed = this.props.navigation.state.params.showFeed;
const list = this.props.discovery.feedStore.list;
return (
<FlatList
data={list.entities.slice(showFeed)}
renderItem={renderRow}
ListFooterComponent={footer}
ListEmptyComponent={this.getEmptyList()}
keyExtractor={item => item.rowKey}
data={list.entities}
renderItem={this.renderActivity}
ListFooterComponent={this.getFooter}
keyExtractor={this.keyExtractor}
onEndReached={this.loadFeed}
initialNumToRender={3}
style={[CS.backgroundWhite, CS.flexContainer]}
horizontal={false}
windowSize={9}
maxToRenderPerBatch={3}
windowSize={3}
removeClippedSubviews={false}
keyboardShouldPersistTaps={'handled'}
/>
)
}
/**
* Get empty list
*/
getEmptyList() {
if (!this.props.discovery.list.loaded || this.props.discovery.loading || this.props.discovery.list.errorLoading) return null;
return (
<View style={ComponentsStyle.emptyComponentContainer}>
<View style={ComponentsStyle.emptyComponent}>
<Text style={ComponentsStyle.emptyComponentMessage}>Nothing to show</Text>
</View>
</View>
);
}
keyExtractor = item => item.rowKey;
/**
* Get list footer
*/
getFooter() {
const discovery = this.props.discovery;
getFooter = () => {
const store = this.props.discovery.feedStore;
if (discovery.loading && !discovery.list.refreshing) {
if (store.loading && !store.list.refreshing) {
return (
<View style={{ flex:1, alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<View style={[CS.flexContainer, CS.centered, CS.padding3x]}>
<ActivityIndicator size={'large'} />
</View>
);
}
if (!discovery.list.errorLoading) return null;
if (!store.list.errorLoading) return null;
const message = discovery.list.entities.length ?
const message = store.list.entities.length ?
"Can't load more" :
"Can't connect";
......@@ -132,54 +97,18 @@ export default class DiscoveryFeedScreen extends Component {
* Try Again
*/
tryAgain = () => {
if (this.props.discovery.searchtext) {
this.props.discovery.search(this.props.discovery.searchtext);
} else {
this.loadFeed(null, true);
}
this.loadFeed(null, true);
}
/**
* Load feed data
*/
loadFeed = (e, force = false) => {
const type = this.props.discovery.filters.type;
if (
this.props.discovery.filters.type == 'lastchannels' ||
(this.props.discovery.list.errorLoading && !force)
) {
if (this.props.discovery.feedStore.list.errorLoading && !force) {
return;
}
const limit = this.state.showFeed ? 12 : (type == 'images' || type == 'videos' ? 24 : 12);
this.props.discovery.loadList(false, false, limit);
}
/**
* Render a tile
*/
renderTile = (row) => {
if (!this.state.active && row.item.isGif()) {
return <View style={{ height: this.state.itemHeight, width: this.state.itemHeight, backgroundColor: colors.greyed }}/>;
}
return (
<ErrorBoundary message="Render error" containerStyle={[CS.centered, {width: this.state.itemHeight, height:this.state.itemHeight}]} textSmall={true}>
<DiscoveryTile entity={row.item} size={this.state.itemHeight} onPress={() => this.setState({'showFeed': row.index})}/>
</ErrorBoundary>
);
}
/**
* Render user row
*/
renderUser = (row) => {
return (
<ErrorBoundary message="Can't show this user" containerStyle={CS.hairLineBottom}>
<DiscoveryUser store={this.props.discovery.stores['channels']} entity={row} navigation={this.props.navigation} hideButtons={this.props.discovery.filters.type == 'lastchannels'} />
</ErrorBoundary>
);
this.props.discovery.feedStore.loadList(false, false, 12);
}
/**
......@@ -192,39 +121,6 @@ export default class DiscoveryFeedScreen extends Component {
</ErrorBoundary>
);
}
/**
* Render blog item
*/
renderBlog = (row) => {
return (
<View style={styles.blogCardContainer}>
<ErrorBoundary message="Can't show this blog" containerStyle={CS.hairLineBottom}>
<BlogCard entity={row.item} navigation={this.props.navigation} />
</ErrorBoundary>
</View>
);
}
/**
* Render group item
*/
renderGroup = (row) => {
const item = row.item;
return (
<ErrorBoundary message="Can't show this group" containerStyle={CS.hairLineBottom}>
<GroupsListItem group={row.item} onPress={() => this.navigateToGroup(row.item)}/>
</ErrorBoundary>
)
}
/**
* Navigate to group
* @param {GroupModel} group
*/
navigateToGroup(group) {
this.props.navigation.push('GroupView', { group: group })
}
}
const styles = StyleSheet.create({
......
import {
observable,
action,
computed,
extendObservable
} from 'mobx';
import discoveryService from './DiscoveryService';
import ActivityModel from '../newsfeed/ActivityModel';
import BlogModel from '../blogs/BlogModel';
import OffsetFeedListStore from '../common/stores/OffsetFeedListStore';
import UserModel from '../channel/UserModel';
import GroupModel from '../groups/GroupModel';
import NewsfeedFilterStore from '../common/stores/NewsfeedFilterStore';
/**
* Discovery Feed Store
*/
class DiscoveryFeedStore {
/**
* Lists stores
*/
list;
filters;
@observable loading = false;
constructor(filters) {
this.filters = filters;
this.buildListStores();
}
setFeed(entities, offset) {
this.list.clearList();
this.list.setList({entities, offset});
}
/**
* Build lists stores
*/
buildListStores() {
this.list = new OffsetFeedListStore('shallow')
}
@action
setLoading(value) {
this.loading = value;
}
/**
* Load feed
*/
@action
async loadList(refresh = false, preloadImage = false, limit = 12) {
const type = this.filters.type;
// no more data or loading? return
if (!refresh && (this.list.cantLoadMore() || this.loading)) {
return;
}
this.list.setErrorLoading(false);
this.setLoading(true);
try {
const feed = await discoveryService.getTopFeed(
this.list.offset,
this.filters.type,
this.filters.filter,
this.filters.period,
this.filters.nsfw.concat([]),
this.filters.searchtext,
limit
);
this.createModels(type, feed, preloadImage);
this.assignRowKeys(feed);
this.list.setList(feed, refresh);
} catch (err) {
// ignore aborts
if (err.code === 'Abort') {
return;
}
if (!(typeof err === 'TypeError' && err.message === 'Network request failed')) {
logService.exception('[DiscoveryFeedStore]', err);
}
this.list.setErrorLoading(true);
} finally {
this.setLoading(false);
}
}
/**
* Generate a unique Id for use with list views
* @param {object} feed
*/
assignRowKeys(feed) {
feed.entities.forEach((entity, index) => {
entity.rowKey = `${entity.guid}:${index}:${this.list.entities.length}`;
});
}
createModels(type, feed, preloadImage) {
switch (type) {
case 'images':
case 'videos':
feed.entities = ActivityModel.createMany(feed.entities);
if (preloadImage) {
feed.entities.forEach(entity => {
entity.preloadThumb();
});
}
break;
}
}
@action
reset() {
this.list.clearList();
this.loading = false;
}
}
export default DiscoveryFeedStore;
......@@ -209,13 +209,13 @@ export default class DiscoveryScreen extends Component {
<CollapsibleHeaderFlatList
onLayout={this.onLayout}
key={'discofl' + this.cols} // we need to force component redering if we change cols
data={list.entities.slice(this.state.showFeed)}
data={list.entities}
renderItem={renderRow}
ListFooterComponent={footer}
CollapsibleHeaderComponent={this.getHeaders()}
headerHeight={(GOOGLE_PLAY_STORE && discovery.filters.type !== 'channels') ? 94 : 146}
ListEmptyComponent={this.getEmptyList()}
keyExtractor={item => item.rowKey}
keyExtractor={this.keyExtractor}
onRefresh={this.refresh}
refreshing={list.refreshing}
onEndReached={this.loadFeed}
......@@ -229,7 +229,7 @@ export default class DiscoveryScreen extends Component {
keyboardShouldPersistTaps={'handled'}
onViewableItemsChanged={this.onViewableItemsChanged}
/>
)
);
return (
<View style={[CS.flexContainer, CS.backgroundWhite]}>
......@@ -239,6 +239,8 @@ export default class DiscoveryScreen extends Component {
);
}
keyExtractor = item => item.rowKey;
/**
* Get empty list
*/
......@@ -428,11 +430,7 @@ export default class DiscoveryScreen extends Component {
}
tryAgain = () => {
if (this.props.discovery.searchtext) {
this.props.discovery.search(this.props.discovery.searchtext);
} else {
this.loadFeed(null, true);
}
this.loadFeed(null, true);
}
/**
......@@ -496,6 +494,20 @@ export default class DiscoveryScreen extends Component {
await this.props.discovery.refresh();
}
/**
* Navigate to feed screen
* @param {int} index
*/
navigateToFeed = (index) => {
this.props.discovery.feedStore.setFeed(this.props.discovery.list.entities.slice(index), this.props.discovery.list.offset);
this.props.navigation.push('DiscoveryFeed', {
'showFeed': index,
title: _.capitalize(this.props.discovery.filters.filter) + ' ' + _.capitalize(this.props.discovery.filters.type)
})
}
/**
* Render a tile
*/
......@@ -508,10 +520,7 @@ export default class DiscoveryScreen extends Component {
<DiscoveryTile
entity={row.item}
size={this.state.itemHeight}
onPress={() => this.props.navigation.push('DiscoveryFeed', {
'showFeed': row.index,
title: _.capitalize(this.props.discovery.filters.filter) + ' ' + _.capitalize(this.props.discovery.filters.type)
})}
onPress={() => this.navigateToFeed(row.index)}
/>
</ErrorBoundary>
);
......
......@@ -92,28 +92,36 @@ class DiscoveryService {
}
async getTopFeedFromSync(offset, type, filter, period, nsfw, query, limit) {
const params = {
filter: 'global',
algorithm: filter,
customType: type,
limit,
offset: offset,
period: period,
all: Boolean(appStores.hashtag.all),
query: query || '',
nsfw,
// forceSync: forceSync,
};
if (appStores.hashtag.hashtag) {
params.hashtags = appStores.hashtag.hashtag;
}
const hashtags = appStores.hashtag.hashtag ? encodeURIComponent(appStores.hashtag.hashtag) : '';
const all = appStores.hashtag.all ? '1' : '';
const { entities, next } = await feedService.get(params);
// const params = {
// filter: 'global',
// algorithm: filter,
// customType: type,
// limit,
// offset: offset,
// period: period,
// all: Boolean(appStores.hashtag.all),
// query: query || '',
// nsfw,
// // forceSync: forceSync,
// };
//
// if (appStores.hashtag.hashtag) {
// params.hashtags = appStores.hashtag.hashtag;
// }
const { entities, next } = await feedService.get({
endpoint: `api/v2/feeds/global/${filter}/${type}?hashtags=${hashtags}&period=${period}&all=${all}&query=${query}&nsfw=${nsfw}`,
timebased: false,
limit,
offset,
});
return {
entities,
offset: next || 0,
entities: entities || [],
offset: next || '',
}
}
......
......@@ -12,12 +12,18 @@ import OffsetFeedListStore from '../common/stores/OffsetFeedListStore';
import UserModel from '../channel/UserModel';
import GroupModel from '../groups/GroupModel';
import NewsfeedFilterStore from '../common/stores/NewsfeedFilterStore';
import DiscoveryFeedStore from './DiscoveryFeedStore';
/**
* Discovery Store
*/
class DiscoveryStore {
/**
* FeedStore
*/
feedStore;
/**
* Lists stores
*/
......@@ -36,6 +42,7 @@ class DiscoveryStore {
constructor() {
this.buildListStores();
this.listenChanges();
this.feedStore = new DiscoveryFeedStore(this.filters);
}
/**
......@@ -277,6 +284,7 @@ class DiscoveryStore {
this.onFilterChangeDisposer && this.onFilterChangeDisposer();
this.onSearchChangeDisposer && this.onSearchChangeDisposer();
this.filters.clear();
this.feedStore.reset();
this.listenChanges();
this.stores.images.list.clearList();
this.stores.videos.list.clearList();
......
......@@ -7,6 +7,7 @@ import OffsetFeedListStore from '../common/stores/OffsetFeedListStore';
import OffsetListStore from '../common/stores/OffsetListStore';
import UserModel from '../channel/UserModel';
import ActivityModel from '../newsfeed/ActivityModel';
import logService from '../common/services/log.service';
/**
* Groups store
......@@ -390,4 +391,4 @@ class GroupViewStore {
}
export default GroupViewStore;
\ No newline at end of file
export default GroupViewStore;
......@@ -2,6 +2,9 @@ import { InteractionManagerStatic } from 'react-native';
import api from './../common/services/api.service';
import { abort } from '../common/helpers/abortableFetch';
import stores from '../../AppStores';
import feedService from '../common/services/feed.service';
import featuresService from '../common/services/features.service';
import entitiesService from '../common/services/entities.service';
/**
* Groups Service
......@@ -43,14 +46,59 @@ class GroupsService {
return response.group;
}
async getFeedFromService(guid, type, opts = { limit: 12 }) {
const limit = opts.limit || 12;
const { entities, next } = await feedService.get({
endpoint: `api/v2/feeds/container/${guid}/${type}`,
timebased: true,
limit,
offset: opts.offset || 0,
syncPageSize: limit * 20,
});
return {
entities,
next,
}
}
async loadFeed(guid, offset, pinnedGuids = null) {
if (featuresService.has('es-feeds')) {
const pinned = [];
const { entities, next } = await this.getFeedFromService(guid, 'activities', {
limit: 12,
offset,
});
if (pinnedGuids) {
const pinnedEntities = (await entitiesService.fetch(pinnedGuids.split(',')))
.filter(entity => Boolean(entity))
.filter(entity => ({
...entity,
pinned: true,
}));
pinned.push(...pinnedEntities);
}
return {
entities: [...pinned, ...(entities || [])],
offset: entities && entities.length ? next : '',
};
} else {
return await this.loadFeedLegacy(guid, offset, pinnedGuids);
}
}
/**
* Load the group feed
* @param {string} guid
* @param {string} offset
* @param {string} pinned
*/
async loadFeed(guid, offset, pinned = null) {
async loadFeedLegacy(guid, offset, pinned = null) {
const endpoint = `api/v1/newsfeed/container/${guid}`;
const opts = { limit: 12, offset };
......@@ -198,4 +246,4 @@ class GroupsService {
}
}
export default new GroupsService();
\ No newline at end of file
export default new GroupsService();
......@@ -43,13 +43,43 @@ export default class DexieStorageAdapter {
/**
* @param {string} table
* @param {Object} data
* @returns {Promise<any>}
* @returns {Promise<*>}
*/
async insert(table, data) {
return await this.db.table(table)
.put(data);
}
/**
* @param {string} table
* @param {string} id
* @param {Object} changes
* @returns {Promise<number>}
*/
async update(table, id, changes) {
return await this.db.table(table)
.update(id, changes);
}
/**
* @param {string} table
* @param {string} id
* @param {Object} data
* @param {Object} initialData
* @returns {Promise<boolean>}
*/
async upsert(table, id, data, initialData = {}) {
const updatedRows = await this.db.table(table)
.update(id, data);
if (!updatedRows) {
await this.db.table(table)
.put(Object.assign(initialData, data));
}
return true;
}
/**
* @param {string} table
* @param {string} key
......@@ -72,7 +102,7 @@ export default class DexieStorageAdapter {
/**
* @param {String} table
* @param {*[]} rows
* @returns {Promise<any>}
* @returns {Promise<*>}
*/
async bulkInsert(table, rows) {
return await this.db.table(table)
......@@ -85,7 +115,7 @@ export default class DexieStorageAdapter {
* @param {number} value
* @returns {Dexie.Promise<number>}
*/
async deleteLesserThan(table, field, value) {
async deleteLessThan(table, field, value) {
return await this.db.table(table)
.where(field).below(value)
.delete();
......@@ -103,6 +133,18 @@ export default class DexieStorageAdapter {
.delete();
}
/**
* @param {String} table
* @param {String} index
* @param {*[]} values
* @returns {Promise<number>}
*/
async deleteAnyOf(table, index, values) {
return await this.db.table(table)
.where(index).anyOf(values)
.delete();
}
/**
* @param {String} table
* @param {String} key
......@@ -118,7 +160,7 @@ export default class DexieStorageAdapter {
* @param {String} field
* @param {String|Number} value
* @param {Object} opts
* @returns {Promise<Array<any>>}
* @returns {Promise<Array<*>>}
*/
async getAllSliced(table, field, value, opts) {
let collection = this.db.table(table)
......@@ -141,21 +183,34 @@ export default class DexieStorageAdapter {
* @param {String} table
* @param {String} field
* @param {String|Number} value
* @returns {Promise<Array<any>>}
* @param {{ sortBy }} opts
* @returns {Promise<Array<*>>}
*/
async getAllLesserThan(table, field, value) {
return await this.db.table(table)
.where(field).below(value)
.toArray();
async getAllLessThan(table, field, value, opts = {}) {
const collection = this.db.table(table)
.where(field).below(value);
if (opts.sortBy) {
return await collection.sortBy(opts.sortBy);
}
return await collection.toArray();
}
/**
* @param {string} table
* @param {{ sortBy }} opts
* @returns {Promise<*[]>}
*/
async all(table) {
return await this.db.table(table)
.toArray();
async all(table, opts = {}) {
const collection = this.db.table(table)
.toCollection();
if (opts.sortBy) {
return await collection.sortBy(opts.sortBy);
}
return await collection.toArray();
}
/**
......
export default class InMemoryStorageAdapter {
/**
* @param {Object} db
*/
constructor(db) {
db._inMemoryStorage = {
tables: {},
data: {},
};
this.db = db._inMemoryStorage;
}
/**
* @param {Number} versionNumber
* @param {Object} schema
*/
schema(versionNumber, schema) {
for (const tableName of Object.keys(schema)) {
this.db.tables[tableName] = schema[tableName];
this.db.data[tableName] = [];
}
}
/**
* @param {string} table
* @returns {string | null}
* @private
*/
_getPrimaryKey(table) {
return this.db.tables[table].primaryKey || null;
}
/**
* @param {string} table
* @param {string} field
* @param {*} value
* @returns {number}
* @private
*/
_getIndexBy(table, field, value) {
return this.db.data[table].findIndex(row => row[field] === value);
}
/**
* @param {string} table
* @param {*} value
* @returns {number}
* @private
*/
_getIndexByPrimaryKey(table, value) {
const primaryKey = this._getPrimaryKey(table);
if (!primaryKey) {
return -1;
}
return this._getIndexBy(table, primaryKey, value);
}
/**
* @param {string} table
* @param {Object} row
* @returns {number}
* @private
*/
_getIndexByPrimaryKeyOnRow(table, row) {
const primaryKey = this._getPrimaryKey(table);
if (!primaryKey) {
return -1;
}
return this._getIndexBy(table, primaryKey, row[primaryKey]);
}
/**
* @returns {Promise<boolean>}
*/
async ready() {
return true;
}
/**
* @param {string} table
* @param {Object} data
* @returns {Promise<*>}
*/
async insert(table, data) {
const row = Object.assign({}, data);
const index = this._getIndexByPrimaryKeyOnRow(table, row);
if (index > -1) {
this.db.data[table][index] = row;
} else {
this.db.data[table].push(row);
}
return true;
}
/**
* @param {string} table
* @param {string} id
* @param {Object} changes
* @returns {Promise<number>}
*/
async update(table, id, changes) {
const index = this._getIndexByPrimaryKey(table, id);
if (index > -1) {
this.db.data[table][index] = Object.assign(this.db.data[table][index], changes);
return 1;
}
return 0;
}
/**
* @param {string} table
* @param {string} id
* @param {Object} data
* @param {Object} initialData
* @returns {Promise<boolean>}
*/
async upsert(table, id, data, initialData = {}) {
const updatedRows = await this.update(table, id, data);
if (!updatedRows) {
await this.insert(table, Object.assign(initialData, data));
}
return true;
}
/**
* @param {string} table
* @param {string} key
* @returns {Promise<void>}
*/
async delete(table, key) {
const index = this._getIndexByPrimaryKey(table, key);
if (index > -1) {
this.db.data[table].splice(index, 1);
}
}
/**
* @param {string} table
* @returns {Promise<void>}
*/
async truncate(table) {
this.db.data[table] = [];
}
/**
* @param {String} table
* @param {*[]} rows
* @returns {Promise<*>}
*/
async bulkInsert(table, rows) {
for (const row of rows) {
await this.insert(table, row);
}
return true;
}
/**
* @param {String} table
* @param {String} field
* @param {number} value
* @returns {Promise<number>}
*/
async deleteLessThan(table, field, value) {
const currentSize = this.db.data[table].length;
this.db.data[table] = this.db.data[table]
.filter(row => row[field] >= value);
return currentSize - this.db.data[table].length;
}
/**
* @param {String} table
* @param {String} field
* @param {String|Number} value
* @returns {Promise<number>}
*/
async deleteEquals(table, field, value) {
const currentSize = this.db.data[table].length;
this.db.data[table] = this.db.data[table]
.filter(row => row[field] !== value);
return currentSize - this.db.data[table].length;
}
/**
* @param {String} table
* @param {String} index
* @param {*[]} values
* @returns {Promise<number>}
*/
async deleteAnyOf(table, index, values) {
const currentSize = this.db.data[table].length;
this.db.data[table] = this.db.data[table]
.filter(row => values.indexOf(row[index]) === -1);
return currentSize - this.db.data[table].length;
}
/**
* @param {String} table
* @param {String} key
* @returns {Promise<Object>}
*/
async get(table, key) {
const index = this._getIndexByPrimaryKey(table, key);
if (index === -1) {
return null;
}
return Object.assign({}, this.db.data[table][index]);
}
/**
* @param {String} table
* @param {String} field
* @param {String|Number} value
* @param {Object} opts
* @returns {Promise<Array<*>>}
*/
async getAllSliced(table, field, value, opts) {
let collection = this.db.data[table]
.filter(row => row[field] === value);
if (opts.offset && opts.limit) {
collection = collection.slice(opts.offset, opts.offset + opts.limit)
} else if (opts.limit) {
collection = collection.slice(0, opts.limit);
} else if (opts.offset) {
collection = collection.slice(opts.offset);
}
return collection.map(row => Object.assign({}, row));
}
/**
* @param {String} table
* @param {String} field
* @param {String|Number} value
* @param {{ sortBy }} opts
* @returns {Promise<Array<*>>}
*/
async getAllLessThan(table, field, value, opts = {}) {
const collection = this.db.data[table]
.filter(row => row[field] < value)
.map(row => Object.assign({}, row));
if (opts.sortBy) {
return collection.sort((a, b) => {
const aIndex = a[opts.sortBy];
const bIndex = b[opts.sortBy];
if (aIndex < bIndex) return -1;
else if (bIndex > aIndex) return -1;
return 0;
});
}
return collection;
}
/**
* @param {string} table
* @param {{ sortBy }} opts
* @returns {Promise<*[]>}
*/
async all(table, opts = {}) {
const collection = this.db.data[table]
.map(row => Object.assign({}, row));
if (opts.sortBy) {
return collection.sort((a, b) => {
const aIndex = a[opts.sortBy];
const bIndex = b[opts.sortBy];
if (aIndex < bIndex) return -1;
else if (bIndex > aIndex) return -1;
return 0;
});
}
return collection;
}
/**
* @param {String} table
* @param {String} index
* @param {*[]} values
* @returns {Promise<*[]>}
*/
async anyOf(table, index, values) {
return this.db.data[table]
.filter(row => values.indexOf(row[index]) > -1)
.map(row => Object.assign({}, row));
}
}
......@@ -12,9 +12,9 @@ export default class MindsClientHttpAdapter {
* @param {boolean} cache
* @returns {Promise<Object>}
*/
async get(endpoint, data = {}, cache = true) {
async get(endpoint, data = null, cache = true) {
try {
const response = await this.http.get(endpoint, data, { cache });
const response = await this.http.get(endpoint, data || {}, { cache });
if (!response || response.status !== 'success') {
throw new Error('Invalid response');
......
......@@ -24,7 +24,8 @@ export default class SqliteStorageAdapter {
const table = this.schemaDefinition[tableName];
const primaryKey = table.primaryKey || '';
const indexes = table.indexes || [];
await this.createTable(tableName, primaryKey, indexes, this.versionNumber);
const nonIndexFields = table.fields || [];
await this.createTable(tableName, primaryKey, indexes, nonIndexFields, this.versionNumber);
}
}
......@@ -32,11 +33,12 @@ export default class SqliteStorageAdapter {
* @param {string} tableName
* @param {string} primaryKey
* @param {array} indexes
* @param {array} nonIndexFields
* @param {number} versionNumber
*/
async createTable(tableName, primaryKey, indexes, versionNumber) {
async createTable(tableName, primaryKey, indexes, nonIndexFields, versionNumber) {
const fields = indexes.slice();
const fields = [...indexes, ...nonIndexFields];
if (primaryKey) fields.push(primaryKey);
......@@ -88,6 +90,34 @@ export default class SqliteStorageAdapter {
return await this.db.executeSql(this.schemas[table].insertSql, params)
}
/**
* @param {string} table
* @param {string} id
* @param {Object} changes
* @returns {Promise<number>}
*/
async update(table, id, changes) {
return this.upsert(table, id, changes, {});
}
/**
* @param {string} table
* @param {string} id
* @param {Object} data
* @param {Object} initialData
* @returns {Promise<boolean>}
*/
async upsert(table, id, data, initialData = {}) {
return this.insert(
table,
Object.assign(
(await this.get(table, id)) || {},
initialData,
data,
)
);
}
/**
* @param {string} table
* @param {string} key
......@@ -108,7 +138,7 @@ export default class SqliteStorageAdapter {
/**
* @param {String} table
* @param {*[]} rows
* @returns {Promise<any>}
* @returns {Promise<*>}
*/
async bulkInsert(table, rows) {
return await this.db.transaction((tx) => {
......@@ -141,9 +171,9 @@ export default class SqliteStorageAdapter {
* @param {String} table
* @param {String} field
* @param {number} value
* @returns {Dexie.Promise<number>}
* @returns {Promise<number>}
*/
async deleteLesserThan(table, field, value) {
async deleteLessThan(table, field, value) {
return await this.db.executeSql(this.schemas[table].deleteSql + `\`${table}\`.\`${field}\`<?`, [value]);
}
......@@ -157,6 +187,17 @@ export default class SqliteStorageAdapter {
return await this.db.executeSql(this.schemas[table].deleteSql + `\`${table}\`.\`${field}\`=?`, [value]);
}
/**
* @param {String} table
* @param {String} index
* @param {*[]} values
* @returns {Promise<number>}
*/
async deleteAnyOf(table, index, values) {
const wildcards = values.map(() => '?').join(', ');
return await this.db.executeSql(this.schemas[table].deleteSql + `\`${table}\`.\`${index}\` IN (${wildcards})`, [...values]);
}
/**
* @param {String} table
* @param {String} key
......@@ -176,7 +217,7 @@ export default class SqliteStorageAdapter {
* @param {String} field
* @param {String|Number} value
* @param {Object} opts
* @returns {Promise<Array<any>>}
* @returns {Promise<Array<*>>}
*/
async getAllSliced(table, field, value, opts) {
let sql = this.schemas[table].selectSql + `WHERE \`${table}\`.\`${field}\`=?`;
......@@ -192,7 +233,8 @@ export default class SqliteStorageAdapter {
const raw = result.rows.raw();
if (raw.length > 0) {
return raw.map(v => JSON.parse(v.jsonData));
};
}
return [];
}
......@@ -200,31 +242,46 @@ export default class SqliteStorageAdapter {
* @param {String} table
* @param {String} field
* @param {String|Number} value
* @returns {Promise<Array<any>>}
* @param {{ sortBy }} opts
* @returns {Promise<Array<*>>}
*/
async getAllLesserThan(table, field, value) {
async getAllLessThan(table, field, value, opts = {}) {
let sql = this.schemas[table].selectSql + `WHERE \`${table}\`.\`${field}\`<?`;
const params = [value];
const [result] = await this.db.executeSql(sql, [value]);
if (opts.sortBy) {
sql += ` ORDER BY \`${table}\`.\`${opts.sortBy}\` ASC`;
}
const [result] = await this.db.executeSql(sql, params);
const raw = result.rows.raw();
if (raw.length > 0) {
return raw.map(v => JSON.parse(v.jsonData));
};
}
return [];
}
/**
* @param {string} table
* @param {{ sortBy }} opts
* @returns {Promise<*[]>}
*/
async all(table) {
const [result] = await this.db.executeSql(this.schemas[table].selectSql, []);
async all(table, opts = {}) {
let sql = this.schemas[table].selectSql;
const values = [];
if (opts.sortBy) {
sql += ` ORDER BY \`${table}\`.\`${opts.sortBy}\` ASC`;
}
const [result] = await this.db.executeSql(sql, values);
const raw = result.rows.raw();
if (raw.length > 0) {
return raw.map(v => JSON.parse(v.jsonData));
};
}
return [];
}
......@@ -235,9 +292,10 @@ export default class SqliteStorageAdapter {
* @returns {Promise<*[]>}
*/
async anyOf(table, index, values) {
let sql = this.schemas[table].selectSql + `WHERE \`${table}\`.\`${index}\` IN ('${values.join("','")}')`;
const wildcards = values.map(() => '?').join(', ');
let sql = this.schemas[table].selectSql + `WHERE \`${table}\`.\`${index}\` IN (${wildcards})`;
const [result] = await this.db.executeSql(sql, []);
const [result] = await this.db.executeSql(sql, [...values]);
const raw = result.rows.raw();
if (raw.length > 0) {
......
export default class BlockListSync {
/**
* @param {MindsClientHttpAdapter|MindsMobileClientHttpAdapter} http
* @param {DexieStorageAdapter|SqliteStorageAdapter} db
* @param {DexieStorageAdapter|InMemoryStorageAdapter|SqliteStorageAdapter} db
*/
constructor(http, db) {
this.http = http;
......
const E_NO_RESOLVER = function () {
throw new Error('Resolver not set')
};
export default class BoostedContentSync {
/**
* @param {MindsClientHttpAdapter|MindsMobileClientHttpAdapter} http
* @param {DexieStorageAdapter|InMemoryStorageAdapter|SqliteStorageAdapter} db
* @param {Number} stale_after
* @param {Number} cooldown
* @param {Number} limit
*/
constructor(http, db, stale_after, cooldown, limit) {
this.http = http;
this.db = db;
this.stale_after_ms = stale_after * 1000;
this.cooldown_ms = cooldown * 1000;
this.limit = limit;
this.rating = null;
this.resolvers = {
currentUser: E_NO_RESOLVER,
blockedUserGuids: E_NO_RESOLVER,
fetchEntities: E_NO_RESOLVER,
};
this.synchronized = null;
this.locks = [];
this.inSync = false;
}
/**
* @returns {boolean}
*/
setUp() {
this.db.schema(2.1, {
boosts: {
primaryKey: 'urn',
indexes: ['sync', 'lastImpression', 'owner_guid'],
fields: ['impressions'],
},
});
return true;
}
/**
* @param {Object} resolvers
* @returns {BoostedContentSync}
*/
setResolvers(resolvers) {
this.resolvers = Object.assign(this.resolvers, resolvers);
return this;
}
/**
* @param rating
* @returns {BoostedContentSync}
*/
setRating(rating) {
this.rating = rating;
return this;
}
/**
* @param rating
* @returns {Promise<boolean>}
*/
async changeRating(rating) {
this.setRating(rating);
await this.destroy();
return true;
}
async fetch(opts = {}) {
const boosts = await this.get(Object.assign(opts, {
limit: 1
}));
return boosts[0] || null;
}
/**
* @param {Object} opts
* @returns {Promise<*[]>}
*/
async get(opts = {}) {
await this.db.ready();
// Default options
opts = Object.assign({
limit: 1,
offset: 0,
passive: false,
forceSync: false,
}, opts);
// Prune list
await this.prune();
if (!this.inSync) {
// Check if a sync is needed
if (opts.forceSync || !this.synchronized || (this.synchronized <= Date.now() - this.stale_after_ms)) {
const wasSynced = await this.sync(opts);
if (!wasSynced) {
console.info('Cannot sync, using cache');
} else {
this.synchronized = Date.now();
}
}
} else {
// Wait for sync to finish (max 100 iterations * 100ms = 10secs)
let count = 0;
while (true) {
count++;
if (!this.inSync || count >= 100) {
console.info('Sync finished. Fetching.');
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
// Fetch
let lockedUrns = [];
try {
let rows;
if (!opts.passive) {
rows = await this.db
.getAllLessThan('boosts', 'lastImpression', Date.now() - this.cooldown_ms, { sortBy: 'impressions' });
} else {
rows = await this.db
.all('boosts', { sortBy: 'impressions' });
}
rows = rows.filter(row => row && row.urn && (this.locks.indexOf(row.urn) === -1));
if (opts.exclude) {
rows = rows.filter(row => opts.exclude.indexOf(row.urn) === -1);
}
if (!rows || !rows.length) {
return [];
}
// Data set
const dataSet = rows.slice(opts.offset || 0, opts.limit);
if (!dataSet.length) {
return [];
}
// Lock data set URNs
lockedUrns = [...dataSet.map(row => row.urn)];
this.locks.push(...lockedUrns);
// Process rows
for (let i = 0; i < dataSet.length; i++) {
const { urn, impressions, passiveImpressions } = dataSet[i];
// Increase counters
if (!opts.passive) {
await this.db.update('boosts', urn, {
impressions: (impressions || 0) + 1,
lastImpression: Date.now(),
});
} else {
await this.db.update('boosts', urn, {
passiveImpressions: (passiveImpressions || 0) + 1,
});
}
}
// Release locks
if (lockedUrns) {
this.locks = this.locks.filter(lock => lockedUrns.indexOf(lock) === -1);
}
// Hydrate entities
return await this.resolvers.fetchEntities(dataSet.map(row => row.urn));
} catch (e) {
console.error('BoostedContentSync.fetch', e);
// Release locks
if (lockedUrns) {
this.locks = this.locks.filter(lock => lockedUrns.indexOf(lock) === -1);
}
// Return empty
return [];
}
}
/**
* @param {Object} opts
* @returns {Promise<boolean>}
*/
async sync(opts) {
await this.db.ready();
// Set flag
this.inSync = true;
// Sync
try {
const params = {
sync: 1,
limit: this.limit,
};
if (this.rating !== null) {
params.rating = this.rating;
}
const response = await this.http.get(`api/v2/boost/fetch`, params, true);
if (!response.boosts || typeof response.boosts.length === 'undefined') {
throw new Error('Invalid server response');
}
// Read blocked list
const blockedList = await this.resolvers.blockedUserGuids();
// Setup rows
const entities = response.boosts
.filter(feedSyncEntity => blockedList.indexOf(feedSyncEntity.owner_guid) === -1)
.map(feedSyncEntity => Object.assign({
sync: Date.now(),
}, feedSyncEntity));
// Insert onto DB
await Promise.all(entities.map(entity => this.db.upsert('boosts', entity.urn, entity, {
impressions: 0,
lastImpression: 0,
passiveImpressions: 0,
})));
// Remove stale entries
await this.pruneStaleBoosts();
// Remove flag
this.inSync = false;
// Return
return true;
} catch (e) {
// Remove flag
this.inSync = false;
// Warn
console.warn('BoostedContentSync.sync', e);
// Return
return false;
}
}
/**
* @returns {Promise<void>}
*/
async pruneStaleBoosts() {
try {
this.db
.deleteLessThan('boosts', 'sync', Date.now() - this.stale_after_ms);
} catch (e) {
console.error('BoostedContentSync.pruneStaleBoosts', e);
throw e;
}
}
/**
* @returns {Promise<boolean>}
*/
async prune() {
try {
await this.pruneStaleBoosts();
await this.db
.deleteAnyOf('boosts', 'owner_guid', (await this.resolvers.blockedUserGuids()) || []);
return true;
} catch (e) {
console.error('BoostedContentSync.prune', e);
throw e;
}
}
/**
* @returns {Promise<boolean>}
*/
async destroy() {
try {
await Promise.all([
this.db.truncate('boosts'),
]);
this.synchronized = null;
return true;
} catch (e) {
console.error('BoostedContentSync.destroy', e);
throw e;
}
}
/**
* @returns {Promise<boolean>}
*/
async gc() {
await this.db.ready();
await this.prune();
this.synchronized = null;
return true;
}
}
import normalizeUrn from "../../../common/helpers/normalize-urn";
export default class EntitiesSync {
/**
* @param {MindsClientHttpAdapter|MindsMobileClientHttpAdapter} http
* @param {DexieStorageAdapter|SqliteStorageAdapter} db
* @param {DexieStorageAdapter|InMemoryStorageAdapter|SqliteStorageAdapter} db
* @param {Number} stale_after
*/
constructor(http, db, stale_after) {
......@@ -17,7 +19,7 @@ export default class EntitiesSync {
this.db.schema(1, {
entities: {
primaryKey: 'urn',
indexes: [ '_syncAt' ]
indexes: ['_syncAt']
},
});
......@@ -88,12 +90,15 @@ export default class EntitiesSync {
throw new Error('Invalid server response');
}
const entities = response.entities.map(entity => ({
urn: `urn:entity:${entity.guid}`,
_syncAt: Date.now(),
...entity,
}));
const entities = response.entities.map(entity => {
let obj =
{
urn: normalizeUrn(entity.urn || entity.guid),
_syncAt: Date.now(),
};
obj = Object.assign(obj, entity);
return obj;
});
await this.db.bulkInsert('entities', entities);
} catch (e) {
console.warn('EntitiesService.fetch', e);
......@@ -108,6 +113,6 @@ export default class EntitiesSync {
*/
async gc() {
await this.db.ready();
return await this.db.deleteLesserThan('entities', '_syncAt', Date.now() - this.stale_after_ms);
return await this.db.deleteLessThan('entities', '_syncAt', Date.now() - this.stale_after_ms);
}
}
import logService from "../../../common/services/log.service";
import asyncSleep from "../../../common/helpers/async-sleep";
const E_NO_RESOLVER = function () {
throw new Error('Resolver not set')
......@@ -7,7 +7,7 @@ const E_NO_RESOLVER = function () {
export default class FeedsSync {
/**
* @param {MindsClientHttpAdapter|MindsMobileClientHttpAdapter} http
* @param {DexieStorageAdapter|SqliteStorageAdapter} db
* @param {DexieStorageAdapter|InMemoryStorageAdapter|SqliteStorageAdapter} db
* @param {Number} stale_after
* @param {Number} limit
*/
......@@ -15,14 +15,12 @@ export default class FeedsSync {
this.http = http;
this.db = db;
this.stale_after_ms = stale_after * 1000;
this.limit = limit;
this.resolvers = {
stringHash: E_NO_RESOLVER,
currentUser: E_NO_RESOLVER,
blockedUserGuids: E_NO_RESOLVER,
fetchEntities: E_NO_RESOLVER,
prefetchEntities: E_NO_RESOLVER,
}
}
......@@ -49,16 +47,12 @@ export default class FeedsSync {
* @returns {FeedsSync}
*/
setResolvers(resolvers) {
this.resolvers = {
...this.resolvers,
...resolvers,
};
this.resolvers = Object.assign(this.resolvers, resolvers);
return this;
}
/**
* @param opts
* @param {Object} opts
* @returns {Promise<{next: Number, entities: *[]}>}
*/
async get(opts) {
......@@ -66,36 +60,50 @@ export default class FeedsSync {
const key = await this.buildKey(opts);
// If it's the first page, attempt to sync
// Fetch
try {
let entities;
let next;
let attempts = 0;
if (!opts.offset) {
const wasSynced = await this.sync(opts);
while (true) {
try {
const wasSynced = await this._sync(key, opts);
if (!wasSynced) {
console.info('Cannot sync, using cache');
}
}
if (!wasSynced) {
console.info('Sync not needed, using cache');
}
} catch (e) {
console.warn('Cannot sync, using cache');
}
// Fetch
const rows = await this.db.getAllSliced('feeds', 'key', key, {
offset: opts.offset,
limit: opts.limit,
});
try {
const rows = await this.db.getAllSliced('feeds', 'key', key, {
offset: opts.offset,
limit: opts.limit,
});
if (!rows || !rows.length) {
break;
}
let next;
if (rows.length > 0) {
next = (opts.offset || 0) + opts.limit;
}
// Hydrate entities
entities = await this.resolvers.fetchEntities(rows.map(row => row.guid));
// Hydrate entities
// Calculate offset
opts.offset = (opts.offset || 0) + opts.limit;
next = opts.offset;
const entities = await this.resolvers.fetchEntities(rows.map(row => row.guid));
if (entities && entities.length) {
break;
}
// Prefetch
if (attempts++ > 15) {
break;
}
this.prefetch(opts, next);
await asyncSleep(100); // Throttle a bit
}
//
......@@ -104,71 +112,77 @@ export default class FeedsSync {
next,
}
} catch (e) {
logService.exception('[FeedsSync.get]', e);
console.error('FeedsSync.get', e);
throw e;
}
}
/**
* @param {Object} opts
* @param {Number} futureOffset
* @param key
* @param opts
* @returns {Promise<boolean>}
* @private
*/
async prefetch(opts, futureOffset) {
if (!futureOffset) {
async _sync(key, opts) {
await this.db.ready();
// Read row (if no refresh is needed), else load defaults
const _syncAtRow = await this.db.get('syncAt', key);
const syncAt = opts.offset && _syncAtRow ? _syncAtRow : {
rows: 0,
moreData: true,
next: '',
};
if (!opts.offset) { // Check if first-page sync is needed
const stale = !syncAt.sync || (syncAt.sync + this.stale_after_ms) < Date.now();
if (!stale && !opts.forceSync) {
return false;
}
} else if (opts.timebased && (!syncAt.moreData || syncAt.rows >= (opts.offset + opts.limit))) { // Check if non-first-page sync is needed
return false;
} else if (!opts.timebased && opts.offset) { // If non-first-page and not timebased, sync is not needed
return false;
}
await this.db.ready()
const key = await this.buildKey(opts);
const rows = await this.db.getAllSliced('feeds', 'key', key, {
offset: futureOffset,
limit: opts.limit,
});
// Request
await this.resolvers.prefetchEntities(rows.map(row => row.guid));
try {
// Setup parameters
return true;
}
const syncPageSize = Math.max(opts.syncPageSize || 10000, opts.limit);
const qs = ['sync=1', `limit=${syncPageSize}`];
/**
* @param {Object} opts
* @returns {Promise<boolean>}
*/
async sync(opts) {
await this.db.ready();
if (syncAt.next) {
qs.push(`from_timestamp=${syncAt.next}`);
}
const key = await this.buildKey(opts);
// Setup endpoint (with parameters)
// Is sync needed?
const endpoint = `${opts.endpoint}${opts.endpoint.indexOf('?') > -1 ? '&' : '?'}${qs.join('&')}`;
const lastSync = await this.db.get('syncAt', key);
if (lastSync && lastSync.sync && (lastSync.sync + this.stale_after_ms) >= Date.now()) {
return true;
}
// Perform request
// Sync
const response = await this.http.get(endpoint, null, true);
try {
const response = await this.http.get(`api/v2/feeds/global/${opts.algorithm}/${opts.customType}`, {
sync: 1,
limit: this.limit,
container_guid: opts.container_guid || '',
period: opts.period || '',
hashtags: opts.hashtags || '',
all: opts.all ? 1 : '',
query: opts.query || '',
nsfw: opts.nsfw || '',
}, true);
// Check if valid response
if (!response.entities || typeof response.entities.length === 'undefined') {
throw new Error('Invalid server response');
}
// Prune old list
// Check if offset response
await this.prune(key);
const next = opts.timebased && response['load-next'];
// Prune list, if necessary
if (!syncAt.next) {
await this.prune(key);
}
// Read blocked list
......@@ -177,20 +191,29 @@ export default class FeedsSync {
// Setup rows
const entities = response.entities
.filter(feedSyncEntity => Boolean(feedSyncEntity))
.filter(feedSyncEntity => blockedList.indexOf(feedSyncEntity.owner_guid) === -1)
.map((feedSyncEntity, index) => ({
.map((feedSyncEntity, index) => Object.assign(feedSyncEntity, {
key,
id: `${key}:${`${index}`.padStart(24, '0')}`,
...feedSyncEntity,
id: `${key}:${`${syncAt.rows + index}`.padStart(24, '0')}`,
}));
// Insert onto DB
// Insert entity refs
await this.db.bulkInsert('feeds', entities);
await this.db.insert('syncAt', { key, sync: Date.now() });
// Update syncAt
await this.db.upsert('syncAt', key, {
key,
rows: syncAt.rows + entities.length,
moreData: Boolean(next && entities.length),
next: next || '',
sync: Date.now(),
});
} catch (e) {
console.warn('FeedsSync.sync', e);
return false;
throw e;
}
return true;
......@@ -215,6 +238,23 @@ export default class FeedsSync {
}
}
/**
* @returns {Promise<boolean>}
*/
async destroy() {
try {
await Promise.all([
this.db.truncate('feeds'),
this.db.truncate('syncAt'),
]);
return true;
} catch (e) {
console.error('FeedsSync.prune', e);
throw e;
}
}
/**
* @param {Object} opts
* @returns {Promise<String>}
......@@ -224,14 +264,7 @@ export default class FeedsSync {
return await this.resolvers.stringHash(JSON.stringify([
userGuid,
opts.container_guid || '',
opts.algorithm || '',
opts.customType || '',
opts.period || '',
opts.hashtags || '',
Boolean(opts.all),
opts.query || '',
opts.nsfw || '',
opts.endpoint,
]));
}
......@@ -242,7 +275,7 @@ export default class FeedsSync {
const maxTimestamp = Date.now() - (this.stale_after_ms * 10);
await Promise.all(
(await this.db.getAllLesserThan('syncAt', 'sync', maxTimestamp))
(await this.db.getAllLessThan('syncAt', 'sync', maxTimestamp))
.map(row => this.prune(row.key))
);
......
......@@ -15,6 +15,7 @@ import ConversationScreen from '../messenger/ConversationScreen';
import SettingsScreen from '../settings/SettingsScreen';
import PasswordScreen from '../settings/screens/PasswordScreen';
import EmailScreen from '../settings/screens/EmailScreen';
import BlockedChannelsScreen from '../settings/screens/BlockedChannelsScreen';
import BillingScreen from '../settings/screens/BillingScreen';
import RekeyScreen from '../settings/screens/RekeyScreen';
import GroupsListScreen from '../groups/GroupsListScreen';
......@@ -112,6 +113,9 @@ const Stack = createStackNavigator({
Settings: {
screen: withErrorBoundaryScreen(SettingsScreen)
},
SettingsBlockedChannels: {
screen: withErrorBoundaryScreen(BlockedChannelsScreen)
},
SettingsEmail: {
screen: withErrorBoundaryScreen(EmailScreen)
},
......
......@@ -146,7 +146,7 @@ export default class NewsfeedList extends Component {
ListFooterComponent={footer}
data={newsfeed.list.entities.slice()}
renderItem={renderRow}
keyExtractor={item => item.rowKey}
keyExtractor={this.keyExtractor}
onRefresh={this.refresh}
refreshing={newsfeed.list.refreshing}
onEndReached={this.loadFeed}
......@@ -165,6 +165,8 @@ export default class NewsfeedList extends Component {
);
}
keyExtractor = item => item.rowKey;
getFooter() {
if (this.props.newsfeed.loading && !this.props.newsfeed.list.refreshing){
......@@ -248,7 +250,7 @@ export default class NewsfeedList extends Component {
newsfeed={this.props.newsfeed}
entity={entity}
navigation={this.props.navigation}
/>;
/>;
}
}
......
......@@ -4,7 +4,8 @@ import api from './../common/services/api.service';
import { abort } from '../common/helpers/abortableFetch';
import stores from '../../AppStores';
import blockListService from '../common/services/block-list.service';
import feedsService from '../common/services/feed.service'
import featuresService from '../common/services/features.service';
export default class NewsfeedService {
......@@ -21,6 +22,29 @@ export default class NewsfeedService {
}
async getFeed(offset, limit = 12) {
if (featuresService.has('es-feeds')) {
return await this.getFeedFromService(offset, limit);
} else {
return await this.getFeedLegacy(offset, limit);
}
}
async getFeedFromService(offset, limit = 12) {
const { entities, next } = await feedsService.get({
endpoint: `api/v2/feeds/subscribed/activities`,
timebased: true,
limit,
offset,
syncPageSize: limit * 20,
});
return {
entities: entities || [],
offset: entities && entities.length ? next : '',
}
}
async getFeedLegacy(offset, limit = 12) {
return this._getFeed('api/v1/newsfeed/', offset, limit);
}
......
......@@ -30,6 +30,7 @@ import ExplicitOverlay from '../../common/components/explicit/ExplicitOverlay';
import Lock from '../../wire/lock/Lock';
import { CommonStyle } from '../../styles/Common';
import Pinned from '../../common/components/Pinned';
import blockListService from '../../common/services/block-list.service';
/**
* Activity
......@@ -209,9 +210,21 @@ export default class Activity extends Component {
showRemind() {
const remind_object = this.props.entity.remind_object;
if (remind_object) {
const blockedUsers = blockListService.getCachedList();
if (blockedUsers.indexOf(remind_object.owner_guid) > -1) {
return (
<View style={[styles.blockedNoticeView, CommonStyle.margin2x, CommonStyle.borderRadius2x, CommonStyle.padding2x]}>
<Text style={[CommonStyle.textCenter, CommonStyle.marginBottom]}>This content is unavailable.</Text>
<Text style={[CommonStyle.textCenter, styles.blockedNoticeDesc]}>You have blocked the author of this activity.</Text>
</View>
);
}
if (this.props.entity.shouldBeBlured()) {
remind_object.is_parent_mature = true;
}
return (
<View style={styles.remind}>
<Activity
......@@ -286,5 +299,11 @@ const styles = StyleSheet.create({
activitySpacer: {
flex:1,
height: 70
},
blockedNoticeView: {
backgroundColor: '#eee',
},
blockedNoticeDesc: {
opacity: 0.7,
}
});
......@@ -15,6 +15,7 @@ import {
ActivityIndicator,
View,
TouchableOpacity,
Linking,
} from 'react-native';
import RadioForm, {RadioButton, RadioButtonInput, RadioButtonLabel} from 'react-native-simple-radio-button';
......@@ -23,34 +24,31 @@ import reportService from './ReportService';
import ModalTopbar from '../topbar/ModalTopbar';
import colors from '../styles/Colors';
import { CommonStyle } from '../styles/Common';
import { CommonStyle as CS } from '../styles/Common';
import { ComponentsStyle } from '../styles/Components';
const REASONS = [
{ value: 1 , label: 'Illegal' },
{ value: 2, label: 'Should be marked as explicit' },
{ value: 3, label: 'Encourages or incites violence' },
{ value: 4, label: 'Threatens, harasses, bullies or encourages others to do so' },
{ value: 5, label: 'Personal and confidential information' },
{ value: 6, label: 'Maliciously targets users (@name, links, images or videos)' },
{ value: 7, label: 'Impersonates someone in a misleading or deceptive manner' },
{ value: 8, label: 'Spam' },
{ value: 10, label: 'This infringes my copyright' },
{ value: 11, label: 'Another reason' }
];
import mindsService from '../common/services/minds.service';
import CenteredLoading from '../common/components/CenteredLoading';
export default class ReportScreen extends Component {
static navigationOptions = ({ navigation }) => ({
title: 'Report',
headerLeft: () => {
return <Icon name="chevron-left" size={38} color={colors.primary} onPress={
() => {
if (navigation.state.params && navigation.state.params.goBack) return navigation.state.params.goBack();
navigation.goBack();
}
}/>
},
headerRight: (
<View>
{
navigation.state.params.requireNote &&
<Button
title="Submit"
onPress={navigation.state.params.selectReason ?
navigation.state.params.selectReason : () => null}
onPress={navigation.state.params.confirmAndSubmit ?
navigation.state.params.confirmAndSubmit : () => null}
/>
}
</View>
......@@ -63,18 +61,36 @@ export default class ReportScreen extends Component {
state = {
note: '',
reason: null,
subreason: null,
reasons: null,
};
/**
* Component did mount
*/
componentDidMount() {
this.setState({
entity: this.props.navigation.state.params.entity,
});
this.props.navigation.setParams({ selectReason: this.selectReason.bind(this) });
this.loadReasons();
this.props.navigation.setParams({ confirmAndSubmit: this.confirmAndSubmit.bind(this) });
}
/**
* Load reasons from minds settings
*/
async loadReasons() {
const settings = await mindsService.getSettings();
this.setState({reasons: settings.report_reasons});
}
/**
* Submit the report
*/
async submit() {
try {
await reportService.report(this.state.entity.guid, this.state.reason.value, this.state.note);
const subreason = this.state.subreason ? this.state.subreason.value : null;
await reportService.report(this.state.entity.guid, this.state.reason.value, subreason, this.state.note);
this.props.navigation.goBack();
Alert.alert(
......@@ -90,66 +106,144 @@ export default class ReportScreen extends Component {
'Ooopppsss',
'There was a problem submitting your report',
[
{text: 'Try again', onPress: () => null},
{text: 'Try again', onPress: () => this.submit()},
{text: 'Cancel'},
],
{ cancelable: true }
)
}
}
/**
* Clear reason
*/
clearReason = () => {
this.setState({reason: null, requireNote: false, subreason: null});
this.props.navigation.setParams({ goBack: null, requireNote: false});
}
/**
* Select subreason
* @param {object} subreason
*/
async selectSubreason(subreason) {
await this.setState({subreason});
this.confirmAndSubmit();
}
/**
* Select reason
* @param {object} reason
*/
async selectReason(reason) {
if (!reason)
if (!reason) {
reason = this.state.reason;
}
if (reason.value >= 10 && !this.state.note) {
if (reason.value == 11 && !this.state.note) {
this.setState({
requireNote: true,
reason: reason,
});
this.props.navigation.setParams({ requireNote: true });
this.props.navigation.setParams({ requireNote: true, goBack: this.clearReason });
return;
}
if (reason.hasMore) {
this.props.navigation.setParams({ goBack: this.clearReason});
return this.setState({reason});
}
await this.setState({
reason
});
this.submit();
this.confirmAndSubmit();
}
render() {
const reasonItems = REASONS.map((reason, i) => {
/**
* Confirm and submit
*/
confirmAndSubmit() {
Alert.alert(
'Confirm',
`Do you want to report this post as:\n${this.state.reason.label}\n` + (this.state.subreason ? this.state.subreason.label : ''),
[
{text: 'No'},
{text: 'Yes', onPress: () => this.submit()},
],
{ cancelable: false }
);
}
/**
* Open default mailer
*/
mailToCopyright = () => {
Linking.openURL('mailto:copyright@minds.com');
}
/**
* Render reasons list
*/
renderReasons() {
if (this.state.reason && this.state.reason.value == 10) {
return <Text style={[CS.fontL, CS.padding2x, CS.textCenter]} onPress={this.mailToCopyright}>Please submit a DMCA notice to copyright@minds.com.</Text>
}
const reasons = (this.state.reason && this.state.reason.hasMore) ? this.state.reason.reasons : this.state.reasons;
const reasonItems = reasons.map((reason, i) => {
return (
<TouchableOpacity style={styles.reasonItem} key={i} onPress={ () => this.selectReason(reason) }>
<TouchableOpacity style={styles.reasonItem} key={i} onPress={ () => this.state.reason ? this.selectSubreason(reason) : this.selectReason(reason) }>
<View style={styles.reasonItemLabelContainer}>
<View style={{ flexDirection: 'row', alignItems: 'stretch' }}>
<Text style={styles.reasonItemLabel}>{ reason.label }</Text>
</View>
</View>
<View style={styles.chevronContainer}>
<Icon name="chevron-right" size={36} style={styles.chevron} />
<Icon name="chevron-right" size={36} color={reason.hasMore ? colors.primary : colors.greyed} />
</View>
</TouchableOpacity>);
});
return reasonItems;
}
/**
* Update not value
*/
updateNote = (value) => this.setState({ note: value });
/**
* Render
*/
render() {
if (!this.state.reasons) return <CenteredLoading/>
const noteInput = (
<TextInput
multiline = {true}
numberOfLines = {4}
style={{ backgroundColor: '#FFF', padding: 16, paddingTop: 24, borderWidth: 1, borderColor: '#ececec', minHeight: 100 }}
style={[CS.padding2x, CS.margin, CS.borderBottom, CS.borderGreyed]}
placeholder="Please explain why you wish to report this content in a few brief sentences."
returnKeyType="done"
autoFocus={true}
placeholderTextColor="gray"
underlineColorAndroid='transparent'
onChangeText={(value) => this.setState({ note: value })}
onChangeText={this.updateNote}
autoCapitalize={'none'}
/>
);
return (
<ScrollView style={CommonStyle.flexContainer}>
<ScrollView style={CS.flexContainer}>
{this.state.reason && <Text style={[CS.fontM, CS.backgroundPrimary, CS.colorWhite, CS.padding]}>{this.state.reason.label}</Text>}
<View style={{ flex: 1 }}>
{ !this.state.requireNote && reasonItems }
{ !this.state.requireNote && this.renderReasons() }
{ this.state.requireNote && noteInput }
......
import api from './../common/services/api.service';
/**
* Report Service
*/
class ReportService {
report(guid, subject, note) {
return api.post('api/v1/entities/report/' + guid, {subject: subject, note: note});
report(entity_guid, reason_code, sub_reason_code , note) {
return api.post('api/v2/moderation/report', {entity_guid, reason_code, sub_reason_code, note});
}
}
......
......@@ -121,7 +121,15 @@ export default class SettingsScreen extends Component {
onPress: () => {
this.props.navigation.navigate('NotificationsSettings');
}
}, {
},
{
name: 'Blocked Channels',
icon: (<Icon name='block' size={ICON_SIZE} style={ styles.icon }/>),
onPress: () => {
this.props.navigation.navigate('SettingsBlockedChannels');
}
},
{
name: 'Regenerate messenger keys',
icon: (<Icon name='vpn-key' size={ICON_SIZE} style={ styles.icon }/>),
onPress: () => {
......
import React, {
Component
} from 'react';
import {
ScrollView,
View,
Text,
StyleSheet,
} from 'react-native';
import Button from '../../common/components/Button';
import blockListService from '../../common/services/block-list.service';
import entitiesService from '../../common/services/entities.service';
import api from "../../common/services/api.service";
import { MINDS_CDN_URI } from "../../config/Config";
import Image from "react-native-image-progress";
import { CommonStyle } from "../../styles/Common";
import Touchable from "../../common/components/Touchable";
import Colors from "../../styles/Colors";
export default class BlockedChannelsScreen extends Component {
static navigationOptions = {
title: 'Blocked Channels'
};
constructor(props) {
super(props);
this.state = {
channels: [],
}
}
componentDidMount() {
this.load();
}
async load() {
await blockListService.doSync();
const guids = await blockListService.getList();
const channels = (await entitiesService.fetch(guids))
.filter(channel => Boolean(channel));
this.setState({
channels,
});
}
getAvatarSource(channel, size='medium') {
if (!channel) {
return null;
}
return { uri: `${MINDS_CDN_URI}icon/${channel.guid}/${size}/${channel.icontime}`, headers: api.buildHeaders() };
}
viewProfile(channel) {
if (this.props.navigation) {
this.props.navigation.push('Channel', { guid: channel.guid });
}
}
setUnblockedValue(channel, value) {
const channels = [...this.state.channels];
const index = channels.findIndex(channelItem => channelItem.guid === channel.guid);
if (index > -1) {
channels[index] = {
...channel,
_unblocked: Boolean(value),
};
this.setState({
channels,
});
}
}
async unblock(channel) {
try {
blockListService.remove(channel.guid);
this.setUnblockedValue(channel, true);
await api.delete(`api/v1/block/${channel.guid}`);
} catch (e) {
blockListService.add(channel.guid);
this.setUnblockedValue(channel, false);
}
}
async block(channel) {
try {
blockListService.add(channel.guid);
this.setUnblockedValue(channel, false);
await api.put(`api/v1/block/${channel.guid}`);
} catch (e) {
blockListService.remove(channel.guid);
this.setUnblockedValue(channel, true);
}
}
render() {
const rows = [];
for (const channel of this.state.channels) {
rows.push(
<View style={[CommonStyle.rowJustifyStart, CommonStyle.alignCenter, CommonStyle.padding2x]}>
<Touchable style={[styles.avatarWrapper]} onPress={() => this.viewProfile(channel)}>
<Image source={this.getAvatarSource(channel)} style={[styles.avatar]} />
</Touchable>
<Touchable style={[CommonStyle.marginLeft, CommonStyle.fillFlex]} onPress={() => this.viewProfile(channel)}>
<Text>@{channel.username}</Text>
</Touchable>
<View>
{
!channel._unblocked ?
<Button text="Unblock" color={Colors.darkGreyed} onPress={() => this.unblock(channel)} /> :
<Button text="Block" color={Colors.danger} onPress={() => this.block(channel)} />
}
</View>
</View>
);
}
return (
<ScrollView>
{rows}
</ScrollView>
);
}
}
const styles = StyleSheet.create({
avatarWrapper: {
width: 32,
height: 32,
overflow: 'hidden',
borderRadius: 16,
},
avatar: {
width: 32,
height: 32,
},
});
......@@ -70,6 +70,10 @@ export const CommonStyle = StyleSheet.create({
alignSpaceAround: {
alignContent: 'space-around'
},
//
fillFlex: {
flexGrow: 1,
},
// color
colorWhite: {
color: 'white'
......