Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Highlight personal token rollcall + sort rollcall #1640

1 change: 1 addition & 0 deletions fe1-web/src/core/styles/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ export const white = 'white';
export const gray = 'gray';
export const popGray = '#363636';
export const popBlue = '#008ec2';
export const lightPopBlue = 'rgb(211, 242, 255)';
49 changes: 45 additions & 4 deletions fe1-web/src/features/rollCall/components/AttendeeList.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,46 @@
import { ListItem } from '@rneui/themed';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { View } from 'react-native';
import React, { useEffect, useState } from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { useToast } from 'react-native-toast-notifications';

import { PoPIcon } from 'core/components';
import { PublicKey } from 'core/objects';
import { Color, Icon, List, Typography } from 'core/styles';
import { FOUR_SECONDS } from 'resources/const';
import STRINGS from 'resources/strings';

const AttendeeList = ({ popTokens }: IPropTypes) => {
const styles = StyleSheet.create({
tokenHighlight: {
backgroundColor: Color.lightPopBlue,
} as ViewStyle,
});

const AttendeeList = ({ popTokens, personalToken }: IPropTypes) => {
const [showItems, setShowItems] = useState(true);
const toast = useToast();

// check if the attendee list is sorted alphabetically
useEffect(() => {
if (popTokens) {
const isAttendeeListSorted = popTokens.reduce<[boolean, PublicKey]>(
([isSorted, lastValue], currentValue) => [
isSorted && lastValue < currentValue,
currentValue,
],
[true, new PublicKey('')],
);

if (!isAttendeeListSorted[0]) {
toast.show(STRINGS.roll_call_danger_attendee_list_not_sorted, {
type: 'warning',
placement: 'bottom',
duration: FOUR_SECONDS,
});
}
}
}, [popTokens, toast]);

return (
<View style={List.container}>
<ListItem.Accordion
Expand All @@ -25,9 +56,13 @@ const AttendeeList = ({ popTokens }: IPropTypes) => {
onPress={() => setShowItems(!showItems)}>
{popTokens.map((token, idx) => {
const listStyle = List.getListItemStyles(idx === 0, idx === popTokens.length - 1);
const isPersonalToken = personalToken !== undefined && token.valueOf() === personalToken;

return (
<ListItem key={token.valueOf()} containerStyle={listStyle} style={listStyle}>
<ListItem
key={token.valueOf()}
containerStyle={[listStyle, isPersonalToken && styles.tokenHighlight]}
style={listStyle}>
<View style={List.icon}>
<PoPIcon name="qrCode" color={Color.primary} size={Icon.size} />
</View>
Expand All @@ -49,7 +84,13 @@ const AttendeeList = ({ popTokens }: IPropTypes) => {

const propTypes = {
popTokens: PropTypes.arrayOf(PropTypes.instanceOf(PublicKey).isRequired).isRequired,
personalToken: PropTypes.string,
};

AttendeeList.defaultProps = {
personalToken: undefined,
};

AttendeeList.propTypes = propTypes;

type IPropTypes = PropTypes.InferProps<typeof propTypes>;
Expand Down
14 changes: 12 additions & 2 deletions fe1-web/src/features/rollCall/components/RollCallClosed.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useToast } from 'react-native-toast-notifications';

import ScreenWrapper from 'core/components/ScreenWrapper';
Expand All @@ -8,6 +8,7 @@ import { Hash } from 'core/objects';
import { FOUR_SECONDS } from 'resources/const';
import STRINGS from 'resources/strings';

import { RollCallHooks } from '../hooks';
import { requestReopenRollCall } from '../network';
import { RollCall } from '../objects';
import AttendeeList from './AttendeeList';
Expand Down Expand Up @@ -55,10 +56,19 @@ const RollCallClosed = ({ rollCall, laoId, isConnected, isOrganizer }: IPropType
];
}, [isConnected, isOrganizer, onReopenRollCall]);

// generate personal token to highlight it in the list
const generateToken = RollCallHooks.useGenerateToken();
const [popToken, setPopToken] = useState('');
generateToken(laoId, rollCall.id)
.then((token) => {
setPopToken(token.publicKey.valueOf());
})
.catch((err) => console.error(`Could not generate token: ${err}`));

return (
<ScreenWrapper toolbarItems={toolbarItems}>
<RollCallHeader rollCall={rollCall} descriptionInitiallyVisible={false} />
<AttendeeList popTokens={rollCall.attendees || []} />
<AttendeeList popTokens={rollCall.attendees || []} personalToken={popToken} />
</ScreenWrapper>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ const RollCallOpen = ({
</>
)}

{scannedPopTokens && <AttendeeList popTokens={scannedPopTokens} />}
{scannedPopTokens && <AttendeeList popTokens={scannedPopTokens} personalToken={popToken} />}
</ScreenWrapper>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ export function requestCloseRollCall(
update_id: CloseRollCall.computeCloseRollCallId(laoId, rollCallId, time),
closes: rollCallId,
closed_at: time,
attendees: attendees,
// sort the list of tokens to make de-anonymization harder
attendees: [...attendees].sort((a, b) => a.toString().localeCompare(b.toString())),
},
laoId,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@ describe('MessageApi', () => {
const attendeePks: PublicKey[] = [
'NdCY6Ga7yVK5rZay3S4xGjlz2jHZQrg2gw7fyI6tfuo=',
'BEW-uVz_NG_prXFuaKrI9Ae0EbBLGWehLQ8aLZFWY4w=',
].map((a) => new PublicKey(a));
]
.map((a) => new PublicKey(a))
.sort();

await msApi.requestCloseRollCall(mockLaoId, mockRollCallId, attendeePks);

Expand Down
45 changes: 34 additions & 11 deletions fe1-web/src/features/rollCall/objects/RollCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,56 +84,79 @@ export class RollCall {
if (obj.id === undefined) {
throw new Error("Undefined 'id' when creating 'RollCall'");
}
this.id = obj.id;

if (obj.name === undefined) {
throw new Error("Undefined 'name' when creating 'RollCall'");
}
this.name = obj.name;

if (obj.location === undefined) {
throw new Error("Undefined 'location' when creating 'RollCall'");
}
this.location = obj.location;

if (obj.creation === undefined) {
throw new Error("Undefined 'creation' when creating 'RollCall'");
}
this.creation = obj.creation;

if (obj.proposedStart === undefined) {
throw new Error("Undefined 'proposed_start' when creating 'RollCall'");
}
this.proposedStart = obj.proposedStart;

if (obj.proposedEnd === undefined) {
throw new Error("Undefined 'proposed_end' when creating 'RollCall'");
}
this.proposedEnd = obj.proposedEnd;

if (obj.status === undefined) {
throw new Error("Undefined 'status' when creating 'RollCall'");
}
this.status = obj.status;

if (obj.status !== RollCallStatus.CREATED && !obj.idAlias) {
throw new Error(
`Error when creating 'RollCall': 'idAlias' can only be undefined in status 'CREATED'`,
);
}
this.idAlias = obj.idAlias;

if (obj.status !== RollCallStatus.CREATED && !obj.openedAt) {
throw new Error(
`Error when creating 'RollCall': 'openedAt' can only be undefined in status 'CREATED'`,
);
}
this.openedAt = obj.openedAt;

if (obj.status === RollCallStatus.CLOSED && !obj.closedAt) {
throw new Error(
`Error when creating 'RollCall': 'closedAt' cannot be undefined when in status 'CLOSED'`,
);
}
this.closedAt = obj.closedAt;

this.id = obj.id;
this.idAlias = obj.idAlias;
this.name = obj.name;
this.location = obj.location;
this.description = obj.description;
this.creation = obj.creation;
this.proposedStart = obj.proposedStart;
this.proposedEnd = obj.proposedEnd;
this.status = obj.status;
if (obj.attendees) {
// ensure the list of attendees is sorted
const isAttendeeListSorted = obj.attendees.reduce<[boolean, PublicKey]>(
([isSorted, lastValue], currentValue) => [
isSorted && lastValue < currentValue,
currentValue,
],
[true, new PublicKey('')],
);

if (!isAttendeeListSorted[0]) {
console.warn(
'Attendee list is not sorted alphabetically, be careful there is a risk of de-anonymization',
);
}
}
this.attendees = obj.attendees;

this.openedAt = obj.openedAt;
this.closedAt = obj.closedAt;
// This field is optional and do not have to satisfy any checks
this.description = obj.description;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const NAME = 'myRollCall';
const LOCATION = 'location';
const TIMESTAMP_START = new Timestamp(1620355600);
const TIMESTAMP_END = new Timestamp(1620357600);
const ATTENDEES = ['attendee1', 'attendee2'];
const ATTENDEES = ['attendee1', 'attendee2'].sort();
const token = new PopToken({
publicKey: new PublicKey('attendee1'),
privateKey: new PrivateKey('privateKey'),
Expand Down Expand Up @@ -344,5 +344,26 @@ describe('RollCall object', () => {
});
expect(createWrongRollCall).toThrow(Error);
});

it('logs a warning when list of attendees is not sorted', () => {
const mockConsoleWarn = jest.spyOn(console, 'warn').mockImplementation();
const createWrongRollCall = () =>
new RollCall({
id: ID,
idAlias: ID,
start: TIMESTAMP_START,
name: NAME,
location: LOCATION,
creation: TIMESTAMP_START,
proposedStart: TIMESTAMP_START,
proposedEnd: TIMESTAMP_END,
openedAt: TIMESTAMP_START,
closedAt: TIMESTAMP_END,
status: RollCallStatus.CLOSED,
attendees: [...ATTENDEES].reverse().map((s: string) => new PublicKey(s)),
});
createWrongRollCall();
expect(mockConsoleWarn).toHaveBeenCalled();
});
});
});
3 changes: 2 additions & 1 deletion fe1-web/src/features/rollCall/screens/RollCallScanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ const RollCallOpened = () => {
return false;
}

const newTokens = [...attendeePopTokens.current, popToken];
// sort the list of tokens to make de-anonymization harder
const newTokens = [...attendeePopTokens.current, popToken].sort((a, b) => a.localeCompare(b));

// update mutable reference that allows us to check for duplicates the next time this function is called
attendeePopTokens.current = newTokens;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ describe('RollCallOpened', () => {
...mockAttendeePopTokens,
mockPublicKey2.valueOf(),
mockPublicKey3.valueOf(),
],
].sort(),
});
});

Expand Down
2 changes: 2 additions & 0 deletions fe1-web/src/resources/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@ namespace STRINGS {
export const roll_call_error_close_roll_call_no_alias =
'Could not close roll call, the event does not have an idAlias';
export const roll_call_error_close_roll_call = 'Could not close roll call';
export const roll_call_danger_attendee_list_not_sorted =
'Attendee list not sorted, risk of deanonymization';

/* --- Roll-call creation Strings --- */

Expand Down