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
43 changes: 43 additions & 0 deletions package/src/components/ChannelList/__tests__/ChannelList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import dispatchChannelDeletedEvent from '../../../mock-builders/event/channelDel
import dispatchChannelHiddenEvent from '../../../mock-builders/event/channelHidden';
import dispatchChannelTruncatedEvent from '../../../mock-builders/event/channelTruncated';
import dispatchChannelUpdatedEvent from '../../../mock-builders/event/channelUpdated';
import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged';
import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered';
import dispatchMessageNewEvent from '../../../mock-builders/event/messageNew';
import dispatchNotificationAddedToChannelEvent from '../../../mock-builders/event/notificationAddedToChannel';
Expand Down Expand Up @@ -75,6 +76,11 @@ const ChannelListSwipeActionsProbe = () => {
return <Text testID='swipe-actions-enabled'>{`${swipeActionsEnabled}`}</Text>;
};

const ChannelListRefreshingProbe = () => {
const { refreshing } = useChannelsContext();
return <Text testID='refreshing'>{`${refreshing}`}</Text>;
};

const ChannelPreviewContent = ({ unread }) => <Text testID='preview-unread'>{`${unread}`}</Text>;

const ChannelListWithChannelPreview = () => {
Expand Down Expand Up @@ -805,6 +811,43 @@ describe('ChannelList', () => {
});
});

describe('connection.changed', () => {
it('should keep background reconnection refreshes debounced and out of pull-to-refresh UI', async () => {
useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
const deferredPromise = new DeferredPromise();
const dateNowSpy = jest.spyOn(Date, 'now');
dateNowSpy.mockReturnValueOnce(0);
dateNowSpy.mockReturnValue(6000);

render(
<Chat client={chatClient}>
<ChannelList {...props} List={ChannelListRefreshingProbe} />
</Chat>,
);

await waitFor(() => {
expect(screen.getByTestId('refreshing').children[0]).toBe('false');
});

chatClient.queryChannels = jest.fn(() => deferredPromise.promise);

act(() => dispatchConnectionChangedEvent(chatClient, false));
act(() => dispatchConnectionChangedEvent(chatClient, true));

await waitFor(() => {
expect(chatClient.queryChannels).toHaveBeenCalled();
});

act(() => dispatchConnectionChangedEvent(chatClient, true));

expect(chatClient.queryChannels).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('refreshing').children[0]).toBe('false');

deferredPromise.resolve([testChannel1]);
dateNowSpy.mockRestore();
});
});

describe('channel.truncated', () => {
it('should call the `onChannelTruncated` function prop, if provided', async () => {
useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
Expand Down
13 changes: 8 additions & 5 deletions package/src/components/ChannelList/hooks/usePaginatedChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type Parameters = {

const RETRY_INTERVAL_IN_MS = 5000;

type QueryType = 'queryLocalDB' | 'reload' | 'refresh' | 'loadChannels';
type QueryType = 'queryLocalDB' | 'reload' | 'refresh' | 'loadChannels' | 'backgroundRefresh';

export type QueryChannels = (queryType?: QueryType, retryCount?: number) => Promise<void>;

Expand Down Expand Up @@ -68,6 +68,7 @@ export const usePaginatedChannels = ({
const hasUpdatedData =
queryType === 'loadChannels' ||
queryType === 'refresh' ||
queryType === 'backgroundRefresh' ||
[
JSON.stringify(filtersRef.current) !== JSON.stringify(filters),
JSON.stringify(sortRef.current) !== JSON.stringify(sort),
Expand Down Expand Up @@ -129,15 +130,15 @@ export const usePaginatedChannels = ({
setActiveQueryType(null);
};

const refreshList = async () => {
const refreshList = async ({ isBackground = false }: { isBackground?: boolean } = {}) => {
const now = Date.now();
// Only allow pull-to-refresh 5 seconds after last successful refresh.
if (now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) {
return;
}

lastRefresh.current = Date.now();
await queryChannels('refresh');
await queryChannels(isBackground ? 'backgroundRefresh' : 'refresh');
};

const reloadList = async () => {
Expand Down Expand Up @@ -167,7 +168,9 @@ export const usePaginatedChannels = ({
'connection.changed',
async (event) => {
if (event.online) {
await refreshList();
// Reconnection refreshes should stay silent, but still share the same debounce
// path as pull-to-refresh.
await refreshList({ isBackground: true });
}
},
);
Expand Down Expand Up @@ -195,7 +198,7 @@ export const usePaginatedChannels = ({
loadingNextPage: pagination?.isLoadingNext,
loadNextPage: channelManager.loadNext,
refreshing: activeQueryType === 'refresh',
refreshList,
refreshList: () => refreshList(),
reloadList,
staticChannelsActive,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => {
</Text>
<View style={[styles.reactionSelectorContainer, reactionSelectorContainer]}>
<FlatList
showsHorizontalScrollIndicator={false}
contentContainerStyle={[styles.contentContainer, contentContainer]}
data={selectorReactions}
getItemLayout={getItemLayout}
Expand Down
77 changes: 45 additions & 32 deletions package/src/components/Poll/components/PollAnswersList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { PollInputDialog } from './PollInputDialog';
import {
PollContextProvider,
PollContextValue,
useChatContext,
usePollContext,
useTheme,
useTranslationContext,
Expand All @@ -27,6 +28,8 @@ export const AnswerListAddCommentButton = (props: PollButtonProps) => {
const [showAddCommentDialog, setShowAddCommentDialog] = useState(false);
const { onPress } = props;

const styles = useStyles();

const onPressHandler = useCallback(() => {
if (onPress) {
onPress({ message, poll });
Expand All @@ -37,10 +40,10 @@ export const AnswerListAddCommentButton = (props: PollButtonProps) => {
}, [message, onPress, poll]);

return (
<>
<View style={styles.inlineButton}>
<Button
variant={'secondary'}
type={'outline'}
type={'ghost'}
size={'lg'}
label={ownAnswer ? t('Update your comment') : t('Add a comment')}
onPress={onPressHandler}
Expand All @@ -54,7 +57,7 @@ export const AnswerListAddCommentButton = (props: PollButtonProps) => {
visible={showAddCommentDialog}
/>
) : null}
</>
</View>
);
};

Expand All @@ -64,6 +67,7 @@ export type PollAnswersListProps = PollContextValue & {
};

export const PollAnswerListItem = ({ answer }: { answer: PollAnswer }) => {
const { client } = useChatContext();
const { t, tDateTimeParser } = useTranslationContext();
const { votingVisibility } = usePollState();

Expand All @@ -87,25 +91,32 @@ export const PollAnswerListItem = ({ answer }: { answer: PollAnswer }) => {
[answer.updated_at, t, tDateTimeParser],
);

const isMyAnswer = client.userID === answer.user?.id;

const isAnonymous = useMemo(
() => votingVisibility === VotingVisibility.anonymous,
[votingVisibility],
() => votingVisibility === VotingVisibility.anonymous && !isMyAnswer,
[votingVisibility, isMyAnswer],
);

const answerAuthorName = isMyAnswer ? t('You') : answer.user?.name;

return (
<View style={[styles.listItemContainer, itemStyle.container]}>
<Text style={[styles.listItemAnswerText, itemStyle.answerText]}>{answer.answer_text}</Text>
<View style={[styles.listItemInfoContainer, itemStyle.infoContainer]}>
<View style={[styles.listItemUserInfoContainer, itemStyle.userInfoContainer]}>
{!isAnonymous && answer.user?.image ? (
<UserAvatar user={answer.user} size='md' showBorder />
) : null}
<Text style={styles.listItemInfoUserName}>
{isAnonymous ? t('Anonymous') : answer.user?.name}
</Text>
<View style={[styles.listItemWrapper, itemStyle.wrapper]}>
<View style={[styles.listItemContainer, itemStyle.container]}>
<Text style={[styles.listItemAnswerText, itemStyle.answerText]}>{answer.answer_text}</Text>
<View style={[styles.listItemInfoContainer, itemStyle.infoContainer]}>
<View style={[styles.listItemUserInfoContainer, itemStyle.userInfoContainer]}>
{!isAnonymous && answer.user?.image ? (
<UserAvatar user={answer.user} size='sm' showBorder />
) : null}
<Text style={styles.listItemInfoUserName}>
{isAnonymous ? t('Anonymous') : answerAuthorName}
</Text>
<Text style={styles.listItemInfoDate}>{dateString}</Text>
</View>
</View>
<Text style={styles.listItemInfoDate}>{dateString}</Text>
</View>
{isMyAnswer ? <AnswerListAddCommentButton /> : null}
</View>
);
};
Expand Down Expand Up @@ -137,7 +148,6 @@ export const PollAnswersListContent = ({
renderItem={renderPollAnswerListItem}
{...additionalFlatListProps}
/>
<AnswerListAddCommentButton />
</View>
);
};
Expand All @@ -164,46 +174,49 @@ const useStyles = () => {
return useMemo(
() =>
StyleSheet.create({
addCommentButtonContainer: {
alignItems: 'center',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 18,
},
contentContainer: { gap: primitives.spacingMd },
addCommentButtonText: { fontSize: 16 },
container: {
flex: 1,
padding: primitives.spacingMd,
backgroundColor: semantics.backgroundCoreElevation1,
},
listItemAnswerText: {
fontSize: primitives.typographyFontSizeMd,
lineHeight: primitives.typographyLineHeightRelaxed,
fontWeight: primitives.typographyFontWeightSemiBold,
lineHeight: primitives.typographyLineHeightNormal,
color: semantics.textPrimary,
},
listItemContainer: {
listItemWrapper: {
borderRadius: primitives.radiusLg,
padding: primitives.spacingMd,
backgroundColor: semantics.backgroundCoreSurfaceCard,
},
listItemContainer: {
padding: primitives.spacingMd,
gap: primitives.spacingXs,
},
listItemInfoContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 24,
},
listItemInfoUserName: {
color: semantics.textPrimary,
color: semantics.chatTextUsername,
fontSize: primitives.typographyFontSizeSm,
marginLeft: primitives.spacingXxs,
fontWeight: primitives.typographyFontWeightSemiBold,
lineHeight: primitives.typographyLineHeightNormal,
},
listItemInfoDate: {
fontSize: primitives.typographyFontSizeSm,
color: semantics.textTertiary,
},
listItemUserInfoContainer: { alignItems: 'center', flexDirection: 'row' },
listItemUserInfoContainer: {
gap: primitives.spacingXs,
alignItems: 'center',
flexDirection: 'row',
},
inlineButton: {
borderColor: semantics.borderCoreDefault,
borderTopWidth: 1,
},
}),
[semantics],
);
Expand Down
39 changes: 24 additions & 15 deletions package/src/components/Poll/components/PollOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
useOwnCapabilitiesContext,
usePollContext,
useTheme,
useTranslationContext,
} from '../../../contexts';

import { Check } from '../../../icons';
Expand All @@ -26,6 +27,7 @@ import { usePollState } from '../hooks/usePollState';
export type PollOptionProps = {
option: PollOptionClass;
showProgressBar?: boolean;
forceIncoming?: boolean;
};

export type PollAllOptionsContentProps = PollContextValue & {
Expand All @@ -36,6 +38,7 @@ export type PollAllOptionsContentProps = PollContextValue & {
export const PollAllOptionsContent = ({
additionalScrollViewProps,
}: Pick<PollAllOptionsContentProps, 'additionalScrollViewProps'>) => {
const { t } = useTranslationContext();
const { name, options } = usePollState();

const {
Expand All @@ -50,12 +53,13 @@ export const PollAllOptionsContent = ({
return (
<ScrollView style={[styles.allOptionsWrapper, wrapper]} {...additionalScrollViewProps}>
<View style={[styles.allOptionsTitleContainer, titleContainer]}>
<Text style={styles.allOptionsTitleMeta}>{t('Question')}</Text>
<Text style={[styles.allOptionsTitleText, titleText]}>{name}</Text>
</View>
<View style={[styles.allOptionsListContainer, listContainer]}>
{options?.map((option: PollOptionClass) => (
<View key={`full_poll_options_${option.id}`} style={styles.optionWrapper}>
<PollOption key={option.id} option={option} showProgressBar={false} />
<PollOption key={option.id} option={option} forceIncoming />
</View>
))}
</View>
Expand All @@ -78,19 +82,15 @@ export const PollAllOptions = ({
</PollContextProvider>
);

export const PollOption = ({ option, showProgressBar = true }: PollOptionProps) => {
const { latestVotesByOption, maxVotedOptionIds, voteCountsByOption } = usePollState();
export const PollOption = ({ option, showProgressBar = true, forceIncoming }: PollOptionProps) => {
const { latestVotesByOption, voteCountsByOption, voteCount } = usePollState();
const styles = useStyles();

const relevantVotes = useMemo(
() => latestVotesByOption?.[option.id] || [],
[latestVotesByOption, option.id],
);
const maxVotes = useMemo(
() =>
maxVotedOptionIds?.[0] && voteCountsByOption ? voteCountsByOption[maxVotedOptionIds[0]] : 0,
[maxVotedOptionIds, voteCountsByOption],
);

const votes = voteCountsByOption[option.id] || 0;

const {
Expand All @@ -105,13 +105,15 @@ export const PollOption = ({ option, showProgressBar = true }: PollOptionProps)
} = useTheme();
const isPollCreatedByClient = useIsPollCreatedByCurrentUser();

const unFilledColor = isPollCreatedByClient
? semantics.chatPollProgressTrackOutgoing
: semantics.chatPollProgressTrackIncoming;
const unFilledColor =
isPollCreatedByClient && !forceIncoming
? semantics.chatPollProgressTrackOutgoing
: semantics.chatPollProgressTrackIncoming;

const filledColor = isPollCreatedByClient
? semantics.chatPollProgressFillOutgoing
: semantics.chatPollProgressFillIncoming;
const filledColor =
isPollCreatedByClient && !forceIncoming
? semantics.chatPollProgressFillOutgoing
: semantics.chatPollProgressFillIncoming;

return (
<View style={[styles.container, container]}>
Expand All @@ -133,7 +135,7 @@ export const PollOption = ({ option, showProgressBar = true }: PollOptionProps)
{showProgressBar ? (
<View style={styles.progressBarContainer}>
<ProgressBar
progress={votes / maxVotes}
progress={votes / voteCount}
filledColor={filledColor}
emptyColor={unFilledColor}
/>
Expand Down Expand Up @@ -273,6 +275,13 @@ const useAllOptionStyles = () => {
lineHeight: primitives.typographyLineHeightRelaxed,
fontWeight: primitives.typographyFontWeightSemiBold,
color: semantics.textPrimary,
paddingTop: primitives.spacingXs,
},
allOptionsTitleMeta: {
fontSize: primitives.typographyFontSizeSm,
color: semantics.textTertiary,
lineHeight: primitives.typographyLineHeightNormal,
fontWeight: primitives.typographyFontWeightMedium,
},
allOptionsWrapper: {
flex: 1,
Expand Down
Loading
Loading