diff --git a/components/VidDetails.tsx b/components/VidDetails.tsx new file mode 100644 index 00000000..b293bd44 --- /dev/null +++ b/components/VidDetails.tsx @@ -0,0 +1,134 @@ +import { formatDistanceToNow } from 'date-fns'; +import React from 'react'; +import { Image } from 'react-native'; +import { ListItem } from 'react-native-elements'; +import { VID, VIDCredential } from '../types/vid'; +import { Column, Row, Text } from './ui'; +import { Colors } from './ui/styleUtils'; +import { TextItem } from './ui/TextItem'; + +export const VidDetails: React.FC = (props) => { + return ( + + + + + Generated + + + {new Date(props.vid?.generatedOn).toLocaleDateString()} + + + + + UIN + + + {props.vid?.uin} + + + + + Status + + + Valid + + + + {props.vid?.credential.biometrics && ( + + + + Photo + + + + + + + )} + + + + + + + {props.vid?.reason?.length > 0 && ( + + Reason(s) for sharing + + )} + {props.vid?.reason?.map((reason, index) => { + ; + })} + + ); +}; + +interface VidDetailsProps { + vid: VID; +} + +interface LocalizedField { + language: string; + value: string; +} + +function getFullAddress(credential: VIDCredential) { + if (!credential) { + return ''; + } + + const fields = [ + 'addressLine1', + 'addressLine2', + 'addressLine3', + 'city', + 'province', + ]; + return ( + fields.map((field) => getLocalizedField(credential[field])).join(', ') + + ', ' + + credential.postalCode + ); +} + +function getLocalizedField(rawField: string) { + try { + const locales: LocalizedField[] = JSON.parse(rawField); + // TODO: language switching + return locales.find((locale) => locale.language === 'eng').value; + } catch (e) { + return ''; + } +} diff --git a/machines/request.ts b/machines/request.ts index 0c81c5cf..8ac7eb45 100644 --- a/machines/request.ts +++ b/machines/request.ts @@ -162,7 +162,7 @@ export const requestMachine = model.createMachine( VC_RESPONSE: [ { cond: 'hasExistingVc', - target: '#accepted', + target: 'requestingExistingVc', }, { target: 'prependingReceivedVc', @@ -170,6 +170,18 @@ export const requestMachine = model.createMachine( ], }, }, + requestingExistingVc: { + entry: ['requestExistingVc'], + on: { + STORE_RESPONSE: 'mergingIncomingVc', + }, + }, + mergingIncomingVc: { + entry: ['mergeIncomingVc'], + on: { + STORE_RESPONSE: '#accepted', + }, + }, prependingReceivedVc: { entry: ['prependReceivedVc'], on: { @@ -179,17 +191,14 @@ export const requestMachine = model.createMachine( storingVc: { entry: ['storeVc'], on: { - STORE_RESPONSE: { - target: '#accepted', - actions: ['sendVcReceived'], - }, + STORE_RESPONSE: '#accepted', }, }, }, }, accepted: { - entry: ['logReceived'], id: 'accepted', + entry: ['sendVcReceived', 'logReceived'], invoke: { src: 'sendVcResponse', data: { @@ -295,6 +304,23 @@ export const requestMachine = model.createMachine( { to: (context) => context.serviceRefs.store } ), + requestExistingVc: send( + (context) => StoreEvents.GET(VC_ITEM_STORE_KEY(context.incomingVc)), + { to: (context) => context.serviceRefs.store } + ), + + mergeIncomingVc: send( + (context, event) => { + const existing = event.response as VC; + const updated: VC = { + ...existing, + reason: existing.reason.concat(context.incomingVc.reason), + }; + return StoreEvents.SET(VC_ITEM_STORE_KEY(updated), updated); + }, + { to: (context) => context.serviceRefs.store } + ), + storeVc: send( (context) => StoreEvents.SET( diff --git a/machines/request.typegen.ts b/machines/request.typegen.ts index 95dbef3d..a02984fe 100644 --- a/machines/request.typegen.ts +++ b/machines/request.typegen.ts @@ -6,7 +6,6 @@ export interface Typegen0 { setReceiverInfo: 'RECEIVE_DEVICE_INFO'; setSenderInfo: 'EXCHANGE_DONE'; setIncomingVc: 'VC_RECEIVED'; - sendVcReceived: 'STORE_RESPONSE'; removeLoggers: | 'SCREEN_BLUR' | 'xstate.after(CLEAR_DELAY)#clearingConnection' @@ -18,9 +17,12 @@ export interface Typegen0 { | 'DISMISS'; requestReceiverInfo: 'CONNECTED'; requestReceivedVcs: 'xstate.init'; + requestExistingVc: 'VC_RESPONSE'; + mergeIncomingVc: 'STORE_RESPONSE'; prependReceivedVc: 'VC_RESPONSE'; storeVc: 'STORE_RESPONSE'; - logReceived: 'VC_RESPONSE' | 'STORE_RESPONSE'; + sendVcReceived: 'STORE_RESPONSE'; + logReceived: 'STORE_RESPONSE'; }; 'internalEvents': { 'xstate.after(CLEAR_DELAY)#clearingConnection': { @@ -51,7 +53,7 @@ export interface Typegen0 { advertiseDevice: 'xstate.after(CLEAR_DELAY)#clearingConnection' | 'DISMISS'; exchangeDeviceInfo: 'RECEIVE_DEVICE_INFO'; receiveVc: 'EXCHANGE_DONE'; - sendVcResponse: 'REJECT' | 'CANCEL' | 'VC_RESPONSE' | 'STORE_RESPONSE'; + sendVcResponse: 'REJECT' | 'CANCEL' | 'STORE_RESPONSE'; }; 'eventsCausingGuards': { hasExistingVc: 'VC_RESPONSE'; @@ -75,6 +77,8 @@ export interface Typegen0 { | 'reviewing.idle' | 'reviewing.accepting' | 'reviewing.accepting.requestingReceivedVcs' + | 'reviewing.accepting.requestingExistingVc' + | 'reviewing.accepting.mergingIncomingVc' | 'reviewing.accepting.prependingReceivedVc' | 'reviewing.accepting.storingVc' | 'reviewing.accepted' @@ -92,6 +96,8 @@ export interface Typegen0 { | { accepting?: | 'requestingReceivedVcs' + | 'requestingExistingVc' + | 'mergingIncomingVc' | 'prependingReceivedVc' | 'storingVc'; }; diff --git a/machines/scan.ts b/machines/scan.ts index d0601e69..63cbf616 100644 --- a/machines/scan.ts +++ b/machines/scan.ts @@ -304,7 +304,7 @@ export const scanMachine = model.createMachine( selectedVc: (context, event) => { return { ...event.vc, - reason: context.reason, + reason: [{ message: context.reason, timestamp: Date.now() }], }; }, }), diff --git a/machines/vid.ts b/machines/vid.ts new file mode 100644 index 00000000..8d374431 --- /dev/null +++ b/machines/vid.ts @@ -0,0 +1,232 @@ +import { EventFrom, StateFrom } from 'xstate'; +import { send, sendParent } from 'xstate'; +import { createModel } from 'xstate/lib/model'; +import { StoreEvents } from './store'; +import { VID } from '../types/vid'; +import { AppServices } from '../shared/GlobalContext'; +import { log, respond } from 'xstate/lib/actions'; +import { VidItemEvents } from './vidItem'; +import { + MY_VIDS_STORE_KEY, + RECEIVED_VIDS_STORE_KEY, + VID_ITEM_STORE_KEY, +} from '../shared/constants'; + +const model = createModel( + { + serviceRefs: {} as AppServices, + myVids: [] as string[], + receivedVids: [] as string[], + vids: {} as Record, + }, + { + events: { + VIEW_VID: (vid: VID) => ({ vid }), + GET_VID_ITEM: (vidKey: string) => ({ vidKey }), + STORE_RESPONSE: (response: any) => ({ response }), + STORE_ERROR: (error: Error) => ({ error }), + VID_ADDED: (vidKey: string) => ({ vidKey }), + VID_RECEIVED: (vidKey: string) => ({ vidKey }), + VID_DOWNLOADED: (vid: VID) => ({ vid }), + REFRESH_MY_VIDS: () => ({}), + REFRESH_RECEIVED_VIDS: () => ({}), + GET_RECEIVED_VIDS: () => ({}), + }, + } +); + +export const VidEvents = model.events; + +type GetVidItemEvent = EventFrom; +type StoreResponseEvent = EventFrom; +type VidDownloadedEvent = EventFrom; +type VidAddedEvent = EventFrom; +type VidReceivedEvent = EventFrom; + +export const vidMachine = model.createMachine( + { + id: 'vid', + context: model.initialContext, + initial: 'init', + states: { + init: { + initial: 'myVids', + states: { + myVids: { + entry: ['loadMyVids'], + on: { + STORE_RESPONSE: { + target: 'receivedVids', + actions: ['setMyVids'], + }, + }, + }, + receivedVids: { + entry: ['loadReceivedVids'], + on: { + STORE_RESPONSE: { + target: '#ready', + actions: ['setReceivedVids'], + }, + }, + }, + }, + }, + ready: { + id: 'ready', + entry: [sendParent('READY')], + on: { + GET_RECEIVED_VIDS: { + actions: ['getReceivedVidsResponse'], + }, + GET_VID_ITEM: { + actions: ['getVidItemResponse'], + }, + VID_ADDED: { + actions: ['prependToMyVids'], + }, + VID_DOWNLOADED: { + actions: ['setDownloadedVid'], + }, + VID_RECEIVED: [ + { + cond: 'hasExistingReceivedVid', + actions: ['moveExistingVidToTop'], + }, + { + actions: ['prependToReceivedVids'], + }, + ], + }, + type: 'parallel', + states: { + myVids: { + initial: 'idle', + states: { + idle: { + on: { + REFRESH_MY_VIDS: 'refreshing', + }, + }, + refreshing: { + entry: ['loadMyVids'], + on: { + STORE_RESPONSE: { + target: 'idle', + actions: ['setMyVids'], + }, + }, + }, + }, + }, + receivedVids: { + initial: 'idle', + states: { + idle: { + on: { + REFRESH_RECEIVED_VIDS: 'refreshing', + }, + }, + refreshing: { + entry: ['loadReceivedVids'], + on: { + STORE_RESPONSE: { + target: 'idle', + actions: ['setReceivedVids'], + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + actions: { + getReceivedVidsResponse: respond((context) => ({ + type: 'VID_RESPONSE', + response: context.receivedVids, + })), + + getVidItemResponse: respond((context, event: GetVidItemEvent) => { + const vid = context.vids[event.vidKey]; + return VidItemEvents.GET_VID_RESPONSE(vid); + }), + + loadMyVids: send(StoreEvents.GET(MY_VIDS_STORE_KEY), { + to: (context) => context.serviceRefs.store, + }), + + loadReceivedVids: send(StoreEvents.GET(RECEIVED_VIDS_STORE_KEY), { + to: (context) => context.serviceRefs.store, + }), + + setMyVids: model.assign({ + myVids: (_, event: StoreResponseEvent) => event.response || [], + }), + + setReceivedVids: model.assign({ + receivedVids: (_, event: StoreResponseEvent) => event.response || [], + }), + + setDownloadedVid: (context, event: VidDownloadedEvent) => { + context.vids[VID_ITEM_STORE_KEY(event.vid.uin, event.vid.requestId)] = + event.vid; + }, + + prependToMyVids: model.assign({ + myVids: (context, event: VidAddedEvent) => [ + event.vidKey, + ...context.myVids, + ], + }), + + prependToReceivedVids: model.assign({ + receivedVids: (context, event: VidReceivedEvent) => [ + event.vidKey, + ...context.receivedVids, + ], + }), + + moveExistingVidToTop: model.assign({ + receivedVids: (context, event: VidReceivedEvent) => { + return [ + event.vidKey, + ...context.receivedVids.filter((value) => value === event.vidKey), + ]; + }, + }), + }, + + guards: { + hasExistingReceivedVid: (context, event: VidReceivedEvent) => + context.receivedVids.includes(event.vidKey), + }, + } +); + +export function createVidMachine(serviceRefs: AppServices) { + return vidMachine.withContext({ + ...vidMachine.context, + serviceRefs, + }); +} + +type State = StateFrom; + +export function selectMyVids(state: State) { + return state.context.myVids; +} + +export function selectReceivedVids(state: State) { + return state.context.receivedVids; +} + +export function selectIsRefreshingMyVids(state: State) { + return state.matches('ready.myVids.refreshing'); +} + +export function selectIsRefreshingReceivedVids(state: State) { + return state.matches('ready.receivedVids.refreshing'); +} diff --git a/types/vc.ts b/types/vc.ts index 25474f85..0bfc93cd 100644 --- a/types/vc.ts +++ b/types/vc.ts @@ -8,8 +8,8 @@ export interface VC { requestId: string; isVerified: boolean; lastVerifiedOn: number; - reason?: string; locked: boolean; + reason?: string[]; } export type VcIdType = 'UIN' | 'VID'; diff --git a/types/vid.ts b/types/vid.ts new file mode 100644 index 00000000..7b5dfc00 --- /dev/null +++ b/types/vid.ts @@ -0,0 +1,63 @@ +export interface VID { + tag: string; + uin: string; + credential: VIDCredential; + verifiableCredential: VIDVerifiableCredential; + generatedOn: Date; + requestId: string; + reason?: VIDSharingReason[]; +} + +export interface VIDSharingReason { + timestamp: number; + message: string; +} + +export interface VIDCredential { + id: string; + uin: string; + fullName: string; + gender: string; + biometrics: { + // Encrypted Base64Encoded Biometrics + face: string; + finger: { + left_thumb: string; + right_thumb: string; + }; + }; + dateOfBirth: string; + phone: string; + email: string; + region: string; + addressLine1: string; + addressLine2: string; + addressLine3: string; + city: string; + province: string; + postalCode: string; +} + +export interface VIDVerifiableCredential { + id: string; + transactionId: string; + type: { + namespace: string; + name: string; + }; + timestamp: string; + dataShareUri: string; + data: { + credential: string; + proof: { + signature: string; + }; + credentialType: string; + protectionKey: string; + }; +} + +export interface VIDLabel { + singular: string; + plural: string; +}