@@ -355,7 +355,7 @@ | |||||
); | ); | ||||
INFOPLIST_FILE = LessPass/Info.plist; | INFOPLIST_FILE = LessPass/Info.plist; | ||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; | ||||
MARKETING_VERSION = 9.3.0; | |||||
MARKETING_VERSION = 9.4.0; | |||||
OTHER_LDFLAGS = ( | OTHER_LDFLAGS = ( | ||||
"$(inherited)", | "$(inherited)", | ||||
"-ObjC", | "-ObjC", | ||||
@@ -384,7 +384,7 @@ | |||||
DEVELOPMENT_TEAM = 5Y4MF2AT83; | DEVELOPMENT_TEAM = 5Y4MF2AT83; | ||||
INFOPLIST_FILE = LessPass/Info.plist; | INFOPLIST_FILE = LessPass/Info.plist; | ||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; | ||||
MARKETING_VERSION = 9.3.0; | |||||
MARKETING_VERSION = 9.4.0; | |||||
OTHER_LDFLAGS = ( | OTHER_LDFLAGS = ( | ||||
"$(inherited)", | "$(inherited)", | ||||
"-ObjC", | "-ObjC", | ||||
@@ -1,6 +1,6 @@ | |||||
{ | { | ||||
"name": "lesspass-mobile", | "name": "lesspass-mobile", | ||||
"version": "9.3.0", | |||||
"version": "9.4.0", | |||||
"description": "LessPass mobile application", | "description": "LessPass mobile application", | ||||
"license": "(MPL-2.0 OR GPL-3.0)", | "license": "(MPL-2.0 OR GPL-3.0)", | ||||
"author": "Guillaume Vincent <guillaume@oslab.fr>", | "author": "Guillaume Vincent <guillaume@oslab.fr>", | ||||
@@ -30,7 +30,7 @@ | |||||
"@react-navigation/native": "^6.0.6", | "@react-navigation/native": "^6.0.6", | ||||
"@react-navigation/stack": "^6.0.11", | "@react-navigation/stack": "^6.0.11", | ||||
"axios": "^0.23.0", | "axios": "^0.23.0", | ||||
"fuse.js": "^6.4.6", | |||||
"fuzzysort": "^1.1.4", | |||||
"lesspass-fingerprint": "latest", | "lesspass-fingerprint": "latest", | ||||
"lesspass-render-password": "latest", | "lesspass-render-password": "latest", | ||||
"lodash": "^4.17.21", | "lodash": "^4.17.21", | ||||
@@ -10,6 +10,7 @@ import { | |||||
Provider, | Provider, | ||||
Paragraph, | Paragraph, | ||||
Button, | Button, | ||||
Searchbar, | |||||
} from "react-native-paper"; | } from "react-native-paper"; | ||||
import { useDispatch, useSelector } from "react-redux"; | import { useDispatch, useSelector } from "react-redux"; | ||||
import { | import { | ||||
@@ -18,15 +19,21 @@ import { | |||||
} from "../password/profilesActions"; | } from "../password/profilesActions"; | ||||
import routes from "../routes"; | import routes from "../routes"; | ||||
import Styles from "../ui/Styles"; | import Styles from "../ui/Styles"; | ||||
import fuzzysort from "fuzzysort"; | |||||
import { cleanPasswordProfile, setPasswordProfile } from "./profileActions"; | import { cleanPasswordProfile, setPasswordProfile } from "./profileActions"; | ||||
import { sortByNewestFirst } from "./sort"; | import { sortByNewestFirst } from "./sort"; | ||||
export default function ProfilesScreen() { | export default function ProfilesScreen() { | ||||
const navigation = useNavigation(); | const navigation = useNavigation(); | ||||
const dispatch = useDispatch(); | const dispatch = useDispatch(); | ||||
const profiles = useSelector((state) => state.profiles); | |||||
const profiles = useSelector((state) => Object.values(state.profiles)); | |||||
const [profileToDelete, setProfileToDelete] = useState(null); | const [profileToDelete, setProfileToDelete] = useState(null); | ||||
const [isLoading, setIsLoading] = useState(true); | const [isLoading, setIsLoading] = useState(true); | ||||
const [query, setQuery] = useState(""); | |||||
const results = | |||||
query === "" | |||||
? profiles | |||||
: fuzzysort.go(query, profiles, { key: "site" }).map((r) => r.obj); | |||||
const _getPasswordProfilesCallback = useCallback(() => { | const _getPasswordProfilesCallback = useCallback(() => { | ||||
setIsLoading(true); | setIsLoading(true); | ||||
@@ -73,37 +80,59 @@ export default function ProfilesScreen() { | |||||
</Dialog.Actions> | </Dialog.Actions> | ||||
</Dialog> | </Dialog> | ||||
</Portal> | </Portal> | ||||
<List.Section title="Password profiles"> | |||||
{isLoading ? null : Object.keys(profiles).length === 0 ? ( | |||||
<List.Section> | |||||
{isLoading ? null : profiles.length === 0 ? ( | |||||
<List.Item | <List.Item | ||||
description={ | description={ | ||||
"You don't have any password profiles. Save a password profile when you generate it." | "You don't have any password profiles. Save a password profile when you generate it." | ||||
} | } | ||||
/> | /> | ||||
) : ( | ) : ( | ||||
sortByNewestFirst(Object.values(profiles)).map((profile) => ( | |||||
<View key={profile.id}> | |||||
<View> | |||||
<View style={{ paddingHorizontal: 10 }}> | |||||
<Searchbar | |||||
placeholder={`Search in your ${profiles.length} profile${ | |||||
profiles.length === 1 ? "" : "s" | |||||
}`} | |||||
onChangeText={setQuery} | |||||
value={query} | |||||
autoCapitalize="none" | |||||
autoCorrect={false} | |||||
/> | |||||
</View> | |||||
{results.length === 0 ? ( | |||||
<List.Item | <List.Item | ||||
title={profile.site} | |||||
description={profile.login} | |||||
onPress={() => { | |||||
dispatch(setPasswordProfile(profile)); | |||||
navigation.navigate(routes.PASSWORD_GENERATOR); | |||||
}} | |||||
right={(props) => ( | |||||
<IconButton | |||||
{...props} | |||||
icon="delete" | |||||
onPress={(event) => { | |||||
setProfileToDelete(profile); | |||||
event.stopPropagation(); | |||||
description={ | |||||
"There is no password profile that matches this search." | |||||
} | |||||
/> | |||||
) : ( | |||||
sortByNewestFirst(results).map((profile) => ( | |||||
<View key={profile.id}> | |||||
<List.Item | |||||
title={profile.site} | |||||
description={profile.login} | |||||
onPress={() => { | |||||
dispatch(setPasswordProfile(profile)); | |||||
setQuery(""); | |||||
navigation.navigate(routes.PASSWORD_GENERATOR); | |||||
}} | }} | ||||
right={(props) => ( | |||||
<IconButton | |||||
{...props} | |||||
icon="delete" | |||||
onPress={(event) => { | |||||
setProfileToDelete(profile); | |||||
event.stopPropagation(); | |||||
}} | |||||
/> | |||||
)} | |||||
/> | /> | ||||
)} | |||||
/> | |||||
<Divider /> | |||||
</View> | |||||
)) | |||||
<Divider /> | |||||
</View> | |||||
)) | |||||
)} | |||||
</View> | |||||
)} | )} | ||||
</List.Section> | </List.Section> | ||||
</ScrollView> | </ScrollView> | ||||
@@ -1,13 +0,0 @@ | |||||
import { isEmpty } from "lodash"; | |||||
import Fuse from "fuse.js"; | |||||
export function returnMatchingData(query, data, dataKey) { | |||||
if (isEmpty(query)) return []; | |||||
const options = { | |||||
keys: [dataKey], | |||||
minMatchCharLength: 2, | |||||
includeMatches: true, | |||||
}; | |||||
const fuse = new Fuse(data, options); | |||||
return fuse.search(query).slice(0, 3); | |||||
} |
@@ -1,167 +0,0 @@ | |||||
import { returnMatchingData } from "./filter"; | |||||
test("returnMatchingData", () => { | |||||
const matches = returnMatchingData( | |||||
"exam", | |||||
[ | |||||
{ site: "example.org", login: "test@example.org" }, | |||||
{ site: "lesspass.com" }, | |||||
], | |||||
"site" | |||||
); | |||||
expect(matches).toEqual([ | |||||
{ | |||||
item: { | |||||
site: "example.org", | |||||
login: "test@example.org", | |||||
}, | |||||
matches: [ | |||||
{ | |||||
indices: [[0, 3]], | |||||
value: "example.org", | |||||
key: "site", | |||||
}, | |||||
], | |||||
refIndex: 0, | |||||
}, | |||||
]); | |||||
}); | |||||
test("returnMatchingData substring", () => { | |||||
const matches = returnMatchingData( | |||||
"exam", | |||||
[ | |||||
{ site: "www.example.org", login: "test@example.org" }, | |||||
{ site: "lesspass.com" }, | |||||
], | |||||
"site" | |||||
); | |||||
expect(matches).toEqual([ | |||||
{ | |||||
item: { | |||||
site: "www.example.org", | |||||
login: "test@example.org", | |||||
}, | |||||
matches: [ | |||||
{ | |||||
indices: [[4, 7]], | |||||
value: "www.example.org", | |||||
key: "site", | |||||
}, | |||||
], | |||||
refIndex: 0, | |||||
}, | |||||
]); | |||||
}); | |||||
test("returnMatchingData typo", () => { | |||||
const matches = returnMatchingData( | |||||
"exem", | |||||
[ | |||||
{ site: "www.example.org", login: "test@example.org" }, | |||||
{ site: "lesspass.com" }, | |||||
], | |||||
"site" | |||||
); | |||||
expect(matches).toEqual([ | |||||
{ | |||||
item: { | |||||
site: "www.example.org", | |||||
login: "test@example.org", | |||||
}, | |||||
matches: [ | |||||
{ | |||||
indices: [[4, 5]], | |||||
value: "www.example.org", | |||||
key: "site", | |||||
}, | |||||
], | |||||
refIndex: 0, | |||||
}, | |||||
]); | |||||
}); | |||||
test("returnMatchingData max length is 3", () => { | |||||
const matches = returnMatchingData( | |||||
"exam", | |||||
[ | |||||
{ site: "example.org" }, | |||||
{ site: "www.example.org" }, | |||||
{ site: "https://www.example.org" }, | |||||
{ site: "example" }, | |||||
], | |||||
"site" | |||||
); | |||||
expect(matches.length).toBe(3); | |||||
}); | |||||
test("returnMatchingData ignore one char match", () => { | |||||
const matches = returnMatchingData( | |||||
"exemp", | |||||
[ | |||||
{ site: "www.example.org", login: "test@example.org" }, | |||||
{ site: "lesspass.com" }, | |||||
], | |||||
"site" | |||||
); | |||||
expect(matches).toEqual([ | |||||
{ | |||||
item: { | |||||
site: "www.example.org", | |||||
login: "test@example.org", | |||||
}, | |||||
matches: [ | |||||
{ | |||||
indices: [ | |||||
[4, 5], | |||||
[7, 8], | |||||
], | |||||
value: "www.example.org", | |||||
key: "site", | |||||
}, | |||||
], | |||||
refIndex: 0, | |||||
}, | |||||
]); | |||||
}); | |||||
test("returnMatchingData no match", () => { | |||||
const matches = returnMatchingData( | |||||
"no match", | |||||
[ | |||||
{ site: "example.org", login: "test@example.org" }, | |||||
{ site: "lesspass.com" }, | |||||
], | |||||
"site" | |||||
); | |||||
expect(matches).toEqual([]); | |||||
}); | |||||
test("returnMatchingData no data", () => { | |||||
const matches = returnMatchingData("lesspass", [], "site"); | |||||
expect(matches).toEqual([]); | |||||
}); | |||||
test("returnMatchingData no valid key", () => { | |||||
const matches = returnMatchingData( | |||||
"lesspass", | |||||
[ | |||||
{ site: "example.org", login: "test@example.org" }, | |||||
{ site: "lesspass.com" }, | |||||
], | |||||
"unknown key" | |||||
); | |||||
expect(matches).toEqual([]); | |||||
}); | |||||
test("returnMatchingData no query", () => { | |||||
const matches = returnMatchingData( | |||||
"", | |||||
[ | |||||
{ site: "example.org", login: "test@example.org" }, | |||||
{ site: "lesspass.com" }, | |||||
], | |||||
"site" | |||||
); | |||||
expect(matches).toEqual([]); | |||||
}); |
@@ -1,3 +1,3 @@ | |||||
{ | { | ||||
"version": "9.3.0" | |||||
"version": "9.4.0" | |||||
} | } |
@@ -3260,10 +3260,10 @@ functional-red-black-tree@^1.0.1: | |||||
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" | resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" | ||||
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= | integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= | ||||
fuse.js@^6.4.6: | |||||
version "6.4.6" | |||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.4.6.tgz#62f216c110e5aa22486aff20be7896d19a059b79" | |||||
integrity sha512-/gYxR/0VpXmWSfZOIPS3rWwU8SHgsRTwWuXhyb2O6s7aRuVtHtxCkR33bNYu3wyLyNx/Wpv0vU7FZy8Vj53VNw== | |||||
fuzzysort@^1.1.4: | |||||
version "1.1.4" | |||||
resolved "https://registry.yarnpkg.com/fuzzysort/-/fuzzysort-1.1.4.tgz#a0510206ed44532cbb52cf797bf5a3cb12acd4ba" | |||||
integrity sha512-JzK/lHjVZ6joAg3OnCjylwYXYVjRiwTY6Yb25LvfpJHK8bjisfnZJ5bY8aVWwTwCXgxPNgLAtmHL+Hs5q1ddLQ== | |||||
gensync@^1.0.0-beta.2: | gensync@^1.0.0-beta.2: | ||||
version "1.0.0-beta.2" | version "1.0.0-beta.2" | ||||