@@ -355,7 +355,7 @@ | |||
); | |||
INFOPLIST_FILE = LessPass/Info.plist; | |||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; | |||
MARKETING_VERSION = 9.3.0; | |||
MARKETING_VERSION = 9.4.0; | |||
OTHER_LDFLAGS = ( | |||
"$(inherited)", | |||
"-ObjC", | |||
@@ -384,7 +384,7 @@ | |||
DEVELOPMENT_TEAM = 5Y4MF2AT83; | |||
INFOPLIST_FILE = LessPass/Info.plist; | |||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; | |||
MARKETING_VERSION = 9.3.0; | |||
MARKETING_VERSION = 9.4.0; | |||
OTHER_LDFLAGS = ( | |||
"$(inherited)", | |||
"-ObjC", | |||
@@ -1,6 +1,6 @@ | |||
{ | |||
"name": "lesspass-mobile", | |||
"version": "9.3.0", | |||
"version": "9.4.0", | |||
"description": "LessPass mobile application", | |||
"license": "(MPL-2.0 OR GPL-3.0)", | |||
"author": "Guillaume Vincent <guillaume@oslab.fr>", | |||
@@ -30,7 +30,7 @@ | |||
"@react-navigation/native": "^6.0.6", | |||
"@react-navigation/stack": "^6.0.11", | |||
"axios": "^0.23.0", | |||
"fuse.js": "^6.4.6", | |||
"fuzzysort": "^1.1.4", | |||
"lesspass-fingerprint": "latest", | |||
"lesspass-render-password": "latest", | |||
"lodash": "^4.17.21", | |||
@@ -10,6 +10,7 @@ import { | |||
Provider, | |||
Paragraph, | |||
Button, | |||
Searchbar, | |||
} from "react-native-paper"; | |||
import { useDispatch, useSelector } from "react-redux"; | |||
import { | |||
@@ -18,15 +19,21 @@ import { | |||
} from "../password/profilesActions"; | |||
import routes from "../routes"; | |||
import Styles from "../ui/Styles"; | |||
import fuzzysort from "fuzzysort"; | |||
import { cleanPasswordProfile, setPasswordProfile } from "./profileActions"; | |||
import { sortByNewestFirst } from "./sort"; | |||
export default function ProfilesScreen() { | |||
const navigation = useNavigation(); | |||
const dispatch = useDispatch(); | |||
const profiles = useSelector((state) => state.profiles); | |||
const profiles = useSelector((state) => Object.values(state.profiles)); | |||
const [profileToDelete, setProfileToDelete] = useState(null); | |||
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(() => { | |||
setIsLoading(true); | |||
@@ -73,37 +80,59 @@ export default function ProfilesScreen() { | |||
</Dialog.Actions> | |||
</Dialog> | |||
</Portal> | |||
<List.Section title="Password profiles"> | |||
{isLoading ? null : Object.keys(profiles).length === 0 ? ( | |||
<List.Section> | |||
{isLoading ? null : profiles.length === 0 ? ( | |||
<List.Item | |||
description={ | |||
"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 | |||
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> | |||
</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" | |||
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: | |||
version "1.0.0-beta.2" | |||