Improvements to the UriBar implementation and some navigation tweaks.
This commit is contained in:
parent
7c8f9a6662
commit
6a60ce07e9
20 changed files with 206 additions and 43 deletions
|
@ -38,7 +38,6 @@ const discoverStack = StackNavigator({
|
||||||
navigationOptions: ({ navigation }) => ({
|
navigationOptions: ({ navigation }) => ({
|
||||||
title: 'Discover',
|
title: 'Discover',
|
||||||
headerLeft: <Feather name="menu" size={24} style={discoverStyle.drawerHamburger} onPress={() => navigation.navigate('DrawerOpen')} />,
|
headerLeft: <Feather name="menu" size={24} style={discoverStyle.drawerHamburger} onPress={() => navigation.navigate('DrawerOpen')} />,
|
||||||
headerRight: <Feather name="search" size={24} style={discoverStyle.rightHeaderIcon} onPress={() => navigation.navigate('Search')} />
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
File: {
|
File: {
|
||||||
|
@ -51,9 +50,7 @@ const discoverStack = StackNavigator({
|
||||||
Search: {
|
Search: {
|
||||||
screen: SearchPage,
|
screen: SearchPage,
|
||||||
navigationOptions: ({ navigation }) => ({
|
navigationOptions: ({ navigation }) => ({
|
||||||
drawerLockMode: 'locked-closed',
|
drawerLockMode: 'locked-closed'
|
||||||
headerTitle: <SearchInput style={searchStyle.searchInput} />,
|
|
||||||
headerRight: <SearchRightHeaderIcon style={discoverStyle.rightHeaderIcon} size={24} navigation={navigation} />
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
@ -66,7 +63,6 @@ const walletStack = StackNavigator({
|
||||||
navigationOptions: ({ navigation }) => ({
|
navigationOptions: ({ navigation }) => ({
|
||||||
title: 'Wallet',
|
title: 'Wallet',
|
||||||
headerLeft: <Feather name="menu" size={24} style={discoverStyle.drawerHamburger} onPress={() => navigation.navigate('DrawerOpen')} />,
|
headerLeft: <Feather name="menu" size={24} style={discoverStyle.drawerHamburger} onPress={() => navigation.navigate('DrawerOpen')} />,
|
||||||
headerRight: <Feather name="search" size={24} style={discoverStyle.rightHeaderIcon} onPress={() => navigation.navigate('Search')} />
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
TransactionHistory: {
|
TransactionHistory: {
|
||||||
|
@ -185,6 +181,7 @@ class AppWithNavigationState extends React.Component {
|
||||||
if (evt.url) {
|
if (evt.url) {
|
||||||
const navigateAction = NavigationActions.navigate({
|
const navigateAction = NavigationActions.navigate({
|
||||||
routeName: 'File',
|
routeName: 'File',
|
||||||
|
key: 'filePage',
|
||||||
params: { uri: evt.url }
|
params: { uri: evt.url }
|
||||||
});
|
});
|
||||||
dispatch(navigateAction);
|
dispatch(navigateAction);
|
||||||
|
|
|
@ -61,7 +61,7 @@ class FileItem extends React.PureComponent {
|
||||||
if (NativeModules.Mixpanel) {
|
if (NativeModules.Mixpanel) {
|
||||||
NativeModules.Mixpanel.track('Discover Tap', { Uri: uri });
|
NativeModules.Mixpanel.track('Discover Tap', { Uri: uri });
|
||||||
}
|
}
|
||||||
navigation.navigate('File', { uri: uri });
|
navigation.navigate({ routeName: 'File', key: 'filePage', params: { uri } });
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
<FileItemMedia title={title} thumbnail={thumbnail} blurRadius={obscureNsfw ? 15 : 0} resizeMode="cover" />
|
<FileItemMedia title={title} thumbnail={thumbnail} blurRadius={obscureNsfw ? 15 : 0} resizeMode="cover" />
|
||||||
|
|
|
@ -23,7 +23,11 @@ class TransactionListItem extends React.PureComponent {
|
||||||
{name && claimId && (
|
{name && claimId && (
|
||||||
<Link
|
<Link
|
||||||
style={transactionListStyle.link}
|
style={transactionListStyle.link}
|
||||||
onPress={() => navigation && navigation.navigate('File', { uri: buildURI({ claimName: name, claimId }) })}
|
onPress={() => navigation && navigation.navigate({
|
||||||
|
routeName: 'File',
|
||||||
|
key: 'filePage',
|
||||||
|
params: { uri: buildURI({ claimName: name, claimId }) }})
|
||||||
|
}
|
||||||
text={name} />
|
text={name} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { doUpdateSearchQuery, selectSearchState as selectSearch } from 'lbry-redux';
|
||||||
import UriBar from './view';
|
import UriBar from './view';
|
||||||
|
|
||||||
const select = state => ({
|
const select = state => {
|
||||||
|
const { ...searchState } = selectSearch(state);
|
||||||
|
|
||||||
});
|
return {
|
||||||
|
...searchState
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const perform = dispatch => ({
|
const perform = dispatch => ({
|
||||||
|
updateSearchQuery: query => dispatch(doUpdateSearchQuery(query)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(select, perform)(UriBar);
|
export default connect(select, perform)(UriBar);
|
||||||
|
|
38
app/src/component/uriBar/internal/uri-bar-item.js
Normal file
38
app/src/component/uriBar/internal/uri-bar-item.js
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
// @flow
|
||||||
|
import React from 'react';
|
||||||
|
import { SEARCH_TYPES, normalizeURI } from 'lbry-redux';
|
||||||
|
import { Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import Feather from 'react-native-vector-icons/Feather';
|
||||||
|
import uriBarStyle from '../../../styles/uriBar';
|
||||||
|
|
||||||
|
class UriBarItem extends React.PureComponent {
|
||||||
|
render() {
|
||||||
|
const { item, onPress } = this.props;
|
||||||
|
const { type, value } = item;
|
||||||
|
|
||||||
|
let icon;
|
||||||
|
switch (type) {
|
||||||
|
case SEARCH_TYPES.CHANNEL:
|
||||||
|
icon = <Feather name="at-sign" size={18} />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SEARCH_TYPES.SEARCH:
|
||||||
|
icon = <Feather name="search" size={18} />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SEARCH_TYPES.FILE:
|
||||||
|
default:
|
||||||
|
icon = <Feather name="compass" size={18} />
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={uriBarStyle.item} onPress={onPress}>
|
||||||
|
{icon}
|
||||||
|
<Text style={uriBarStyle.itemText} numberOfLines={1}>{value}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UriBarItem;
|
|
@ -1,41 +1,96 @@
|
||||||
// @flow
|
// @flow
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { normalizeURI } from 'lbry-redux';
|
import { SEARCH_TYPES, isNameValid, normalizeURI } from 'lbry-redux';
|
||||||
import { TextInput, View } from 'react-native';
|
import { FlatList, Keyboard, TextInput, View } from 'react-native';
|
||||||
|
import UriBarItem from './internal/uri-bar-item';
|
||||||
import uriBarStyle from '../../styles/uriBar';
|
import uriBarStyle from '../../styles/uriBar';
|
||||||
|
|
||||||
class UriBar extends React.PureComponent {
|
class UriBar extends React.PureComponent {
|
||||||
|
static INPUT_TIMEOUT = 500;
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
uri: null,
|
changeTextTimeout: null,
|
||||||
currentValue: null
|
currentValue: null,
|
||||||
|
inputText: null,
|
||||||
|
focused: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleChangeText = text => {
|
||||||
|
const newValue = text ? text : '';
|
||||||
|
clearTimeout(this.state.changeTextTimeout);
|
||||||
|
const { updateSearchQuery } = this.props;
|
||||||
|
|
||||||
|
let timeout = setTimeout(() => {
|
||||||
|
updateSearchQuery(text);
|
||||||
|
}, UriBar.INPUT_TIMEOUT);
|
||||||
|
this.setState({ inputText: newValue, currentValue: newValue, changeTextTimeout: timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleItemPress = (item) => {
|
||||||
|
const { navigation, updateSearchQuery } = this.props;
|
||||||
|
const { type, value } = item;
|
||||||
|
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
|
if (SEARCH_TYPES.SEARCH === type) {
|
||||||
|
navigation.navigate({ routeName: 'Search', key: 'searchPage', params: { searchQuery: value }});
|
||||||
|
} else {
|
||||||
|
navigation.navigate({ routeName: 'File', key: 'filePage', params: { uri: normalizeURI(value) }});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { value, navigation } = this.props;
|
const { navigation, suggestions, updateSearchQuery, value } = this.props;
|
||||||
if (!this.state.currentValue) {
|
if (this.state.currentValue === null) {
|
||||||
this.setState({ currentValue: value });
|
this.setState({ currentValue: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Search and URI suggestions overlay
|
let style = [uriBarStyle.overlay];
|
||||||
|
if (this.state.focused) {
|
||||||
|
style.push(uriBarStyle.inFocus);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={uriBarStyle.uriContainer}>
|
<View style={style}>
|
||||||
<TextInput style={uriBarStyle.uriText}
|
{this.state.focused && (
|
||||||
placeholder={'Enter a LBRY URI or some text'}
|
<View style={uriBarStyle.suggestions}>
|
||||||
underlineColorAndroid={'transparent'}
|
<FlatList style={uriBarStyle.suggestionList}
|
||||||
numberOfLines={1}
|
data={suggestions}
|
||||||
value={this.state.currentValue}
|
keyboardShouldPersistTaps={'handled'}
|
||||||
returnKeyType={'go'}
|
keyExtractor={(item, value) => item.value}
|
||||||
onChangeText={(text) => this.setState({uri: text, currentValue: text})}
|
renderItem={({item}) => <UriBarItem item={item}
|
||||||
onSubmitEditing={() => {
|
navigation={navigation}
|
||||||
if (this.state.uri) {
|
onPress={() => this.handleItemPress(item)} />} />
|
||||||
let uri = this.state.uri;
|
</View>)}
|
||||||
uri = uri.replace(/ /g, '-');
|
<View style={uriBarStyle.uriContainer}>
|
||||||
navigation.navigate('File', { uri: normalizeURI(uri) });
|
<TextInput style={uriBarStyle.uriText}
|
||||||
}
|
selectTextOnFocus={true}
|
||||||
}}/>
|
placeholder={'Search for videos, music, games and more'}
|
||||||
|
underlineColorAndroid={'transparent'}
|
||||||
|
numberOfLines={1}
|
||||||
|
clearButtonMode={'while-editing'}
|
||||||
|
value={this.state.currentValue}
|
||||||
|
returnKeyType={'go'}
|
||||||
|
inlineImageLeft={'baseline_search_black_24'}
|
||||||
|
inlineImagePadding={16}
|
||||||
|
onFocus={() => this.setState({ focused: true })}
|
||||||
|
onBlur={() => this.setState({ focused: false })}
|
||||||
|
onChangeText={this.handleChangeText}
|
||||||
|
onSubmitEditing={() => {
|
||||||
|
if (this.state.inputText) {
|
||||||
|
let inputText = this.state.inputText;
|
||||||
|
if (isNameValid(inputText)) {
|
||||||
|
navigation.navigate({ routeName: 'File', key: 'filePage', params: { uri: normalizeURI(inputText) }});
|
||||||
|
} else {
|
||||||
|
// Open the search page with the query populated
|
||||||
|
navigation.navigate({ routeName: 'Search', key: 'searchPage', params: { searchQuery: inputText }});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -156,7 +156,7 @@ class FilePage extends React.PureComponent {
|
||||||
<ActivityIndicator size="large" color={Colors.LbryGreen} />
|
<ActivityIndicator size="large" color={Colors.LbryGreen} />
|
||||||
<Text style={filePageStyle.infoText}>Loading decentralized data...</Text>
|
<Text style={filePageStyle.infoText}>Loading decentralized data...</Text>
|
||||||
</View>}
|
</View>}
|
||||||
{ claim === null && !isResolvingUri &&
|
{claim === null && !isResolvingUri &&
|
||||||
<View style={filePageStyle.container}>
|
<View style={filePageStyle.container}>
|
||||||
<Text style={filePageStyle.emptyClaimText}>There's nothing at this location.</Text>
|
<Text style={filePageStyle.emptyClaimText}>There's nothing at this location.</Text>
|
||||||
</View>
|
</View>
|
||||||
|
@ -220,7 +220,7 @@ class FilePage extends React.PureComponent {
|
||||||
renderIndicator={() => null} />}
|
renderIndicator={() => null} />}
|
||||||
|
|
||||||
{!this.state.showWebView && (
|
{!this.state.showWebView && (
|
||||||
<View style={filePageStyle.pageContainer}>
|
<View style={filePageStyle.innerPageContainer}>
|
||||||
<View style={filePageStyle.mediaContainer}>
|
<View style={filePageStyle.mediaContainer}>
|
||||||
{(canOpen || (!fileInfo || (isPlayable && !canLoadMedia))) &&
|
{(canOpen || (!fileInfo || (isPlayable && !canLoadMedia))) &&
|
||||||
<FileItemMedia style={filePageStyle.thumbnail} title={title} thumbnail={metadata.thumbnail} />}
|
<FileItemMedia style={filePageStyle.thumbnail} title={title} thumbnail={metadata.thumbnail} />}
|
||||||
|
|
|
@ -8,14 +8,29 @@ import {
|
||||||
View,
|
View,
|
||||||
ScrollView
|
ScrollView
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import SearchResultItem from '../../component/searchResultItem';
|
|
||||||
import Colors from '../../styles/colors';
|
import Colors from '../../styles/colors';
|
||||||
|
import PageHeader from '../../component/pageHeader';
|
||||||
|
import SearchResultItem from '../../component/searchResultItem';
|
||||||
|
import UriBar from '../../component/uriBar';
|
||||||
import searchStyle from '../../styles/search';
|
import searchStyle from '../../styles/search';
|
||||||
|
|
||||||
class SearchPage extends React.PureComponent {
|
class SearchPage extends React.PureComponent {
|
||||||
|
static navigationOptions = {
|
||||||
|
title: 'Search Results'
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const { navigation, search } = this.props;
|
||||||
|
const { searchQuery } = navigation.state.params;
|
||||||
|
if (searchQuery && searchQuery.trim().length > 0) {
|
||||||
|
search(searchQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { isSearching, navigation, uris } = this.props;
|
const { isSearching, navigation, uris, query } = this.props;
|
||||||
|
const { searchQuery } = navigation.state.params;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={searchStyle.container}>
|
<View style={searchStyle.container}>
|
||||||
{!isSearching && (!uris || uris.length === 0) &&
|
{!isSearching && (!uris || uris.length === 0) &&
|
||||||
|
@ -26,10 +41,15 @@ class SearchPage extends React.PureComponent {
|
||||||
uri={uri}
|
uri={uri}
|
||||||
style={searchStyle.resultItem}
|
style={searchStyle.resultItem}
|
||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
onPress={() => {navigation.navigate('File', { uri: uri }); }}/>)
|
onPress={() => navigation.navigate({
|
||||||
|
routeName: 'File',
|
||||||
|
key: 'filePage',
|
||||||
|
params: { uri }})
|
||||||
|
}/>)
|
||||||
) : null }
|
) : null }
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
{isSearching && <ActivityIndicator size="large" color={Colors.LbryGreen} style={searchStyle.loading} /> }
|
{isSearching && <ActivityIndicator size="large" color={Colors.LbryGreen} style={searchStyle.loading} /> }
|
||||||
|
<UriBar value={searchQuery} navigation={navigation} />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,7 +54,7 @@ class SplashScreen extends React.PureComponent {
|
||||||
navigation.dispatch(resetAction);
|
navigation.dispatch(resetAction);
|
||||||
|
|
||||||
if (this.state.launchUrl) {
|
if (this.state.launchUrl) {
|
||||||
navigation.navigate('File', { uri: this.state.launchUrl });
|
navigation.navigate({ routeName: 'File', key: 'filePage', params: { uri: this.state.launchUrl } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -11,7 +11,8 @@ const channelPageStyle = StyleSheet.create({
|
||||||
},
|
},
|
||||||
fileList: {
|
fileList: {
|
||||||
paddingTop: 30,
|
paddingTop: 30,
|
||||||
flex: 1
|
flex: 1,
|
||||||
|
marginBottom: 60
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
color: Colors.LbryGreen,
|
color: Colors.LbryGreen,
|
||||||
|
|
|
@ -5,7 +5,8 @@ const discoverStyle = StyleSheet.create({
|
||||||
flex: 1
|
flex: 1
|
||||||
},
|
},
|
||||||
scrollContainer: {
|
scrollContainer: {
|
||||||
flex: 1
|
flex: 1,
|
||||||
|
marginBottom: 60
|
||||||
},
|
},
|
||||||
busyContainer: {
|
busyContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
|
@ -12,6 +12,10 @@ const filePageStyle = StyleSheet.create({
|
||||||
pageContainer: {
|
pageContainer: {
|
||||||
flex: 1
|
flex: 1
|
||||||
},
|
},
|
||||||
|
innerPageContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginBottom: 60
|
||||||
|
},
|
||||||
mediaContainer: {
|
mediaContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: screenWidth,
|
width: screenWidth,
|
||||||
|
@ -150,7 +154,7 @@ const filePageStyle = StyleSheet.create({
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 60,
|
||||||
zIndex: 100
|
zIndex: 100
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -10,7 +10,8 @@ const searchStyle = StyleSheet.create({
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
padding: 16
|
padding: 16,
|
||||||
|
marginBottom: 60
|
||||||
},
|
},
|
||||||
scrollPadding: {
|
scrollPadding: {
|
||||||
paddingBottom: 16
|
paddingBottom: 16
|
||||||
|
|
|
@ -6,6 +6,7 @@ const uriBarStyle = StyleSheet.create({
|
||||||
backgroundColor: Colors.White,
|
backgroundColor: Colors.White,
|
||||||
padding: 8,
|
padding: 8,
|
||||||
alignSelf: 'flex-end',
|
alignSelf: 'flex-end',
|
||||||
|
height: 60,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
shadowColor: Colors.Black,
|
shadowColor: Colors.Black,
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
|
@ -24,6 +25,32 @@ const uriBarStyle = StyleSheet.create({
|
||||||
fontFamily: 'Metropolis-Regular',
|
fontFamily: 'Metropolis-Regular',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
width: '100%'
|
width: '100%'
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
bottom: 0,
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 200,
|
||||||
|
elevation: 16
|
||||||
|
},
|
||||||
|
inFocus: {
|
||||||
|
height: '100%'
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
backgroundColor: 'white',
|
||||||
|
flex: 1
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
padding: 12
|
||||||
|
},
|
||||||
|
itemText: {
|
||||||
|
fontFamily: 'Metropolis-Regular',
|
||||||
|
fontSize: 16,
|
||||||
|
marginLeft: 12,
|
||||||
|
marginRight: 12
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 386 B |
Binary file not shown.
After Width: | Height: | Size: 236 B |
Binary file not shown.
After Width: | Height: | Size: 420 B |
Binary file not shown.
After Width: | Height: | Size: 636 B |
Binary file not shown.
After Width: | Height: | Size: 827 B |
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24.0"
|
||||||
|
android:viewportHeight="24.0"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
|
||||||
|
</vector>
|
Loading…
Reference in a new issue