Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## 0.32.0 - TBD

### Changed

- The Dashboard, Contacts, and History search boxes share one `SearchField`
component and a page-neutral style constant.

### Added

- Settings > Connected wallets gained an "Onboard another wallet" invite
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"@interop/storage-core": "^0.6.0",
"@interop/vc": "^11.0.6",
"@interop/verifier-core": "^3.5.4",
"@interop/wallet-core": "^0.29.0",
"@interop/wallet-core": "^0.30.0",
"@interop/was-client": "^0.33.0",
"@interop/webkms-client": "^14.7.1",
"@interop/x25519-key-agreement-key": "^5.2.1",
Expand Down
18 changes: 9 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

54 changes: 54 additions & 0 deletions src/components/SearchField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* The wallet's one search box: a small text field with a magnifier
* adornment, shared by every page that filters an in-memory list
* (credentials, history, contacts, ...) so the three of them cannot drift
* apart visually.
*/
import Box from '@mui/material/Box'
import TextField from '@mui/material/TextField'
import type { SxProps, Theme } from '@mui/material/styles'
import { MdSearch } from 'react-icons/md'
import { searchFieldStyles } from '@/styles/appStyles'

/**
* @param options {object}
* @param options.value {string} - The current query text.
* @param options.onChange {(value: string) => void} - Receives the new query
* text (not the DOM event).
* @param options.placeholder {string} - Already-translated placeholder.
* @param [options.sx] {SxProps<Theme>} - Extra styles, merged over the
* shared base.
*/
export function SearchField({
value,
onChange,
placeholder,
sx
}: {
value: string
onChange: (value: string) => void
placeholder: string
sx?: SxProps<Theme>
}) {
return (
<TextField
size="small"
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
sx={[searchFieldStyles, ...(Array.isArray(sx) ? sx : [sx])]}
slotProps={{
input: {
startAdornment: (
<Box
component="span"
sx={{ display: 'flex', color: 'text.secondary', mr: 1 }}
>
<MdSearch />
</Box>
)
}
}}
/>
)
}
5 changes: 4 additions & 1 deletion src/hooks/useCredentialDelete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ import type { Session } from '@/types/auth'
export function useCredentialDelete({
session,
cid,
title,
onSuccess
}: {
session: Session | null
cid?: string
title?: string
onSuccess: () => void
}) {
const { t } = useTranslation()
Expand Down Expand Up @@ -51,6 +53,7 @@ export function useCredentialDelete({
await session.storage.deleteCredential({ cid, keepPublicCopy })
await session.storage.addHistoryCredentialDeleted({
cid,
title: title ?? cid,
user: session.user
})
} catch (err) {
Expand All @@ -75,7 +78,7 @@ export function useCredentialDelete({
})
onSuccess()
},
[session, cid, onSuccess, t]
[session, cid, title, onSuccess, t]
)

const runDelete = useCallback(
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/useCredentialPublicLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { showToast } from '@/stores/toastStore'
import { cidFrom } from '@interop/was-client/sync'
import type { Session } from '@/types/auth'
import type { CredentialShareActions } from '@/types/credentialActions'
import { credentialTitle } from '@/lib/viewMappers/credentialTitle'

/**
* Drives a credential's public-link (sharing) state. Sharing is
Expand Down Expand Up @@ -65,6 +66,7 @@ export function useCredentialPublicLink({
setIsShared(true)
await session.storage.addHistoryCredentialShared({
cid,
title: credentialTitle(credential),
user: session.user
})
showToast({ message: t('credential.publicLinkCreated') })
Expand All @@ -77,7 +79,7 @@ export function useCredentialPublicLink({
}, [cid, credential, session, t])

const remove = useCallback(async () => {
if (!session || !cid) {
if (!session || !cid || !credential) {
return
}
setBusy(true)
Expand All @@ -87,6 +89,7 @@ export function useCredentialPublicLink({
setIsShared(false)
await session.storage.addHistoryCredentialUnshared({
cid,
title: credentialTitle(credential),
user: session.user
})
showToast({ message: t('credential.publicLinkRemoved') })
Expand All @@ -96,7 +99,7 @@ export function useCredentialPublicLink({
} finally {
setBusy(false)
}
}, [cid, session, t])
}, [cid, credential, session, t])

const toggle = useCallback(() => {
if (isShared) {
Expand Down
14 changes: 9 additions & 5 deletions src/hooks/useSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import Fuse, { type FuseOptionKey } from 'fuse.js'
* @param [options.threshold] {number} - Fuse.js match threshold (0 = exact,
* 1 = match anything). Defaults to `0.35`.
* @param [options.minMatchCharLength] {number} - Minimum query length before
* a fuzzy match counts. Defaults to `2`.
* a fuzzy match counts; a shorter query leaves the list unfiltered rather
* than matching nothing. Defaults to `2`.
* @returns {{ query: string, setQuery: (query: string) => void, results: T[] }}
*/
export function useSearch<T>({
Expand Down Expand Up @@ -51,10 +52,13 @@ export function useSearch<T>({
// consumers key their own memos on it.
const results = useMemo(() => {
const normalizedQuery = query.trim()
return normalizedQuery
? fuse.search(normalizedQuery).map(result => result.item)
: items
}, [fuse, query, items])
// A query below `minMatchCharLength` can never match, so searching would
// blank the whole list on the first keystroke -- leave it unfiltered.
if (normalizedQuery.length < minMatchCharLength) {
return items
}
return fuse.search(normalizedQuery).map(result => result.item)
}, [fuse, query, items, minMatchCharLength])

return { query, setQuery, results }
}
12 changes: 11 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,8 @@
"title": "Freewallet Dashboard",
"addCredential": "Add Credential",
"credentialsHeading": "Credentials",
"searchPlaceholder": "Search credentials...",
"noResults": "No credentials match your search.",
"loadError": "Could not load your credentials. Please try again.",
"undecryptable_one": "{{count}} credential could not be decrypted. It may be corrupted or saved under a different passphrase.",
"undecryptable_other": "{{count}} credentials could not be decrypted. They may be corrupted or saved under a different passphrase.",
Expand All @@ -387,7 +389,9 @@
"contacts": {
"title": "Contacts",
"addContacts": "Add Contacts",
"empty": "No contacts yet."
"empty": "No contacts yet.",
"searchPlaceholder": "Search contacts...",
"noResults": "No contacts match your search."
},
"contactDetail": {
"title": "Contact details"
Expand Down Expand Up @@ -451,6 +455,12 @@
"credentials": "Credentials",
"login": "Login activity",
"applications": "Applications"
},
"credentialVerbs": {
"created": "created",
"deleted": "deleted",
"shared": "shared",
"unshared": "unshared"
}
},
"storage": {
Expand Down
12 changes: 11 additions & 1 deletion src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,8 @@
"title": "Panel de Freewallet",
"addCredential": "Añadir credencial",
"credentialsHeading": "Credenciales",
"searchPlaceholder": "Buscar credenciales...",
"noResults": "Ninguna credencial coincide con su búsqueda.",
"loadError": "No se pudieron cargar sus credenciales. Inténtelo de nuevo.",
"undecryptable_one": "No se pudo descifrar {{count}} credencial. Puede estar dañada o guardada con otra frase de contraseña.",
"undecryptable_other": "No se pudieron descifrar {{count}} credenciales. Pueden estar dañadas o guardadas con otra frase de contraseña.",
Expand All @@ -387,7 +389,9 @@
"contacts": {
"title": "Contactos",
"addContacts": "Añadir contactos",
"empty": "Todavía no hay contactos."
"empty": "Todavía no hay contactos.",
"searchPlaceholder": "Buscar contactos...",
"noResults": "Ningún contacto coincide con su búsqueda."
},
"contactDetail": {
"title": "Detalle del contacto"
Expand Down Expand Up @@ -451,6 +455,12 @@
"credentials": "Credenciales",
"login": "Actividad de inicio de sesión",
"applications": "Aplicaciones"
},
"credentialVerbs": {
"created": "creada",
"deleted": "eliminada",
"shared": "compartida",
"unshared": "dejada de compartir"
}
},
"storage": {
Expand Down
52 changes: 51 additions & 1 deletion src/lib/historyActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,59 @@ import type { WalletActivity } from '@/stores/storageManager'

export type HistoryTab = 'all' | 'credentials' | 'login' | 'applications'

const CREDENTIAL_ACTIVITY_TYPES = ['Create', 'Delete', 'Share', 'Unshare']
const COLLECTION_SHARE_TYPES = ['CollectionShare', 'CollectionUnshare']

export type CredentialActivityVerb =
'created' | 'deleted' | 'shared' | 'unshared'

const CREDENTIAL_ACTIVITY_VERBS: Record<string, CredentialActivityVerb> = {
Create: 'created',
Delete: 'deleted',
Share: 'shared',
Unshare: 'unshared'
}

const CREDENTIAL_ACTIVITY_TYPES = Object.keys(CREDENTIAL_ACTIVITY_VERBS)

export interface CredentialActivityInfo {
cid: string
title?: string
verb: CredentialActivityVerb
}

/**
* Extracts the credential-display fields (cid, title when known, and the
* past-tense verb) out of a single-credential activity record, or `null`
* when `doc` isn't one (a collection share, a login, a revoke, ...).
*
* `object` carries `{ cid, title }` for records written after the title
* field was introduced, and a bare cid string for older records -- both
* shapes are handled so old history entries keep rendering.
*
* @param doc {WalletActivity}
* @returns {CredentialActivityInfo | null}
*/
export function credentialActivityInfo(
doc: WalletActivity
): CredentialActivityInfo | null {
const verbType = (doc.type ?? []).find(type =>
Object.hasOwn(CREDENTIAL_ACTIVITY_VERBS, type)
)
if (!verbType || !doc.summary?.startsWith('Credential ')) {
return null
}
const verb = CREDENTIAL_ACTIVITY_VERBS[verbType]
const object = doc.object
if (typeof object === 'string') {
return { cid: object, verb }
}
if (object && typeof object === 'object' && 'cid' in object) {
const { cid, title } = object as { cid: string; title?: string }
return { cid, title, verb }
}
return null
}

/**
* Sorts one activity into the tab it belongs under, or `'other'` when it
* doesn't fit any of the specific tabs (shown only under the `all` tab).
Expand Down
Loading