react-native#VirtualizedList TypeScript Examples

The following examples show how to use react-native#VirtualizedList. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: PostList.tsx    From lexicon with MIT License 6 votes vote down vote up
PostList = forwardRef<Ref, Props>((props, ref) => {
  const { navigate } = useNavigation();
  const styles = useStyles();
  const { colors } = useTheme();

  const { onStartScroll, onStopScroll } = useUserEvent();

  const {
    data,
    showLabel,
    currentUser,
    hasFooter = true,
    numberOfLines = 0,
    showImageRow = false,
    style,
    prevScreen,
    onPressReply,
    refreshing,
    onRefresh,
    likedTopic,
    progressViewOffset = 0,
    ...otherProps
  } = props;

  const onPressAuthor = (username: string) => {
    navigate('UserInformation', { username });
  };

  const getItem = (data: Array<Post>, index: number) => data[index];

  const getItemCount = (data: Array<Post>) => data.length;

  const keyExtractor = ({ id, topicId }: Post) =>
    `post-${id === 0 ? topicId : id}`;

  const renderItem = ({ item }: { item: Post }) => (
    <PostItem
      data={item}
      postList={true}
      showLabel={showLabel}
      currentUser={currentUser}
      hasFooter={hasFooter}
      showImageRow={showImageRow}
      style={styles.item}
      numberOfLines={numberOfLines}
      prevScreen={prevScreen}
      onPressReply={onPressReply}
      onPressAuthor={onPressAuthor}
      likedTopic={likedTopic}
    />
  );

  return (
    <VirtualizedList
      ref={ref}
      data={data}
      refreshControl={
        <RefreshControl
          refreshing={refreshing}
          onRefresh={onRefresh}
          tintColor={colors.primary}
          progressViewOffset={progressViewOffset}
        />
      }
      onScrollBeginDrag={onStartScroll}
      onScrollEndDrag={onStopScroll}
      keyExtractor={keyExtractor}
      getItem={getItem}
      getItemCount={getItemCount}
      renderItem={renderItem}
      initialNumToRender={5}
      maxToRenderPerBatch={7}
      windowSize={10}
      style={[styles.container, style]}
      {...otherProps}
    />
  );
})
Example #2
Source File: MessageDetail.tsx    From lexicon with MIT License 4 votes vote down vote up
export default function MessageDetail() {
  const styles = useStyles();
  const { colors } = useTheme();

  const storage = useStorage();
  const user = storage.getItem('user');

  const { authorizedExtensions } = useSiteSettings();
  const extensions = authorizedExtensions?.split('|');
  const normalizedExtensions = formatExtensions(extensions);

  const ios = Platform.OS === 'ios';
  const screen = Dimensions.get('screen');

  const { navigate } = useNavigation<StackNavProp<'MessageDetail'>>();

  const {
    params: {
      id,
      postPointer,
      emptied,
      hyperlinkUrl = '',
      hyperlinkTitle = '',
    },
  } = useRoute<StackRouteProp<'MessageDetail'>>();

  const [hasOlderMessages, setHasOlderMessages] = useState(true);
  const [hasNewerMessages, setHasNewerMessages] = useState(true);
  const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
  const [loadingNewerMessages, setLoadingNewerMessages] = useState(false);
  const [refetching, setRefetching] = useState(false);
  const [isInitialRequest, setIsInitialRequest] = useState(true);
  const [textInputFocused, setInputFocused] = useState(false);

  const [title, setTitle] = useState('');
  const [message, setMessage] = useState('');

  const [startIndex, setStartIndex] = useState(0);
  const [endIndex, setEndIndex] = useState(0);
  const [initialHeight, setInitialHeight] = useState<number>();

  const [data, setData] = useState<Message>();
  const [members, setMembers] = useState<Array<User>>([]);
  const [userWhoComment, setUserWhoComment] = useState<Array<User>>([]);
  const [stream, setStream] = useState<Array<number>>([]);
  const virtualListRef = useRef<VirtualizedList<MessageContent>>(null);

  const [showUserList, setShowUserList] = useState(false);
  const [mentionLoading, setMentionLoading] = useState(false);
  const [mentionKeyword, setMentionKeyword] = useState('');
  const [cursorPosition, setCursorPosition] = useState<CursorPosition>({
    start: 0,
    end: 0,
  });

  let contentHeight = initialHeight ? initialHeight : 0;

  const messageRef = useRef<TextInputType>(null);

  const { mentionMembers } = useMention(
    mentionKeyword,
    showUserList,
    setMentionLoading,
  );

  const {
    data: baseData,
    loading: messageDetailLoading,
    refetch,
    fetchMore,
  } = useTopicDetail({
    variables: {
      topicId: id,
      postPointer,
    },
    onCompleted: ({ topicDetail: result }) => {
      if (result) {
        setIsInitialRequest(true);
        setTitle(result.title || '');

        const tempParticipants: Array<User> = [];
        result.details?.allowedUsers?.forEach((allowedUser) =>
          tempParticipants.push({
            id: allowedUser.id,
            username: allowedUser.username,
            avatar: getImage(allowedUser.avatarTemplate),
          }),
        );
        setMembers(tempParticipants);
        let userWhoComment: Array<User> = [];
        result.details?.participants.forEach((user) => {
          userWhoComment.push({
            id: user.id,
            username: user.username,
            avatar: getImage(user.avatar),
          });
        });
        setUserWhoComment(userWhoComment);
      }
    },
    onError: (error) => {
      loadingOlderMessages && setLoadingOlderMessages(false);
      errorHandlerAlert(error);
    },
    fetchPolicy: 'cache-and-network',
  });

  useEffect(() => {
    if (emptied) {
      setMessage('');
    }
  }, [emptied]);

  useEffect(() => {
    if (!baseData) {
      return;
    }

    const {
      topicDetail: { postStream, details },
    } = baseData;

    const {
      data: tempData,
      hasNewerMessage: newMessage,
      hasOlderMessage: oldMessage,
      baseStream,
      firstPostIndex,
      lastPostIndex,
    } = messageDetailHandler({ postStream, details });

    setData(tempData);
    setStream(baseStream);
    setHasNewerMessages(newMessage);
    setHasOlderMessages(oldMessage);
    setStartIndex(firstPostIndex);
    setEndIndex(lastPostIndex);
  }, [baseData]);

  useEffect(() => {
    if (!refetching) {
      return;
    }
    virtualListRef.current?.scrollToEnd({ animated: false });
    setRefetching(false);
  }, [refetching]);

  useEffect(() => {
    if (!hyperlinkUrl) {
      return;
    }
    const { newUrl, newTitle } = getHyperlink(hyperlinkUrl, hyperlinkTitle);
    const result = insertHyperlink(message, newTitle, newUrl);
    setMessage(result);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [hyperlinkTitle, hyperlinkUrl]);

  const { reply, loading: replyLoading } = useReplyPost({
    onCompleted: () => {
      setMessage('');
      refetch({ postPointer: stream.length || 1 }).then(() => {
        if (textInputFocused && data?.contents.length) {
          if (ios) {
            virtualListRef.current?.scrollToIndex({
              index: data.contents.length,
              animated: true,
            });
          } else {
            setTimeout(() => {
              virtualListRef.current?.scrollToIndex({
                index: data.contents.length,
                animated: true,
              });
            }, 500);
          }
        }
        setRefetching(true);
      });
    },
  });

  useMessageTiming(id, startIndex, data?.contents);

  const loadStartMore = async () => {
    if (
      loadingOlderMessages ||
      !hasOlderMessages ||
      !stream ||
      messageDetailLoading
    ) {
      return;
    }
    setLoadingOlderMessages(true);
    let nextEndIndex = startIndex;
    let newDataCount = Math.min(10, stream.length - nextEndIndex);
    let nextStartIndex = Math.max(0, nextEndIndex - newDataCount);

    let nextPosts = stream.slice(nextStartIndex, nextEndIndex);
    if (!nextPosts.length) {
      return;
    }
    await fetchMore({
      variables: {
        topicId: id,
        posts: nextPosts,
      },
    }).then(() => {
      setStartIndex(nextStartIndex);
      setLoadingOlderMessages(false);
    });
  };

  const loadEndMore = async () => {
    if (
      loadingNewerMessages ||
      !hasNewerMessages ||
      !stream ||
      messageDetailLoading
    ) {
      return;
    }
    setLoadingNewerMessages(true);
    let nextStartIndex = endIndex + 1;
    let newDataCount = Math.min(10, stream.length - nextStartIndex);
    let nextEndIndex = nextStartIndex + newDataCount;

    let nextPosts = stream.slice(nextStartIndex, nextEndIndex);
    if (!nextPosts.length) {
      return;
    }
    await fetchMore({
      variables: {
        topicId: id,
        posts: nextPosts,
      },
    });
    setEndIndex(nextEndIndex - 1);
    setLoadingNewerMessages(false);
  };

  const onPressSend = (message: string) => {
    setShowUserList(false);
    if (message.trim() !== '') {
      reply({
        variables: {
          replyInput: {
            topicId: id,
            raw: message,
          },
        },
      });
    }
  };

  const onPressImage = async () => {
    try {
      let result = await imagePickerHandler(normalizedExtensions);
      if (!user || !result || !result.uri) {
        return;
      }
      let imageUri = result.uri;
      Keyboard.dismiss();
      navigate('ImagePreview', {
        topicId: id,
        imageUri,
        postPointer: stream.length,
        message,
      });
    } catch (error) {
      errorHandlerAlert(error);
    }
    return;
  };

  const compareTime = (currIndex: number) => {
    if (currIndex === 0) {
      return true;
    }
    const currContentTime = data
      ? data.contents[currIndex].time
      : new Date().toDateString();
    const prevContentTime = data
      ? data.contents[currIndex - 1].time
      : new Date().toDateString();

    const time = new Date(currContentTime);
    const prevTime = new Date(prevContentTime);

    return (time.getTime() - prevTime.getTime()) / (1000 * 60) > 15;
  };

  const isPrev = (currIndex: number) => {
    if (data) {
      if (currIndex === 0) {
        return;
      }
      const currUserId = data.contents[currIndex].userId;
      const prevUserId = data.contents[currIndex - 1].userId;

      return currUserId === prevUserId;
    }
    return false;
  };

  const settings = (operation: Operation, currIndex: number) => {
    if (currIndex === -1) {
      return operation === Operation.USER;
    }
    const isPrevUser = isPrev(currIndex);
    if (!isPrevUser) {
      return operation === Operation.USER;
    }
    if (isPrevUser) {
      return operation === Operation.USER
        ? compareTime(currIndex)
        : !compareTime(currIndex);
    }
    return false;
  };

  const onPressLink = () => {
    navigate('HyperLink', {
      id,
      postPointer,
      prevScreen: 'MessageDetail',
    });
  };

  const onPressAvatar = (username: string) => {
    navigate('UserInformation', { username });
  };

  const renderItem = ({ item, index }: MessageDetailRenderItem) => {
    let user;

    if (item.userId === 0) {
      user = members.find((member) => member.id === -1);
    } else {
      user = userWhoComment.find((member) => member.id === item.userId);
    }

    const newTimestamp = compareTime(index);
    const isPrevUser = isPrev(index);
    const currSettings = settings(Operation.USER, index);
    const senderUsername = user?.username || '';

    return (
      <MessageItem
        content={item}
        sender={user}
        newTimestamp={newTimestamp}
        isPrev={isPrevUser}
        settings={currSettings}
        onPressAvatar={() => onPressAvatar(senderUsername)}
      />
    );
  };

  const keyExtractor = ({ id }: MessageContent) => `message-${id}`;
  const getItem = (data: Array<MessageContent>, index: number) => data[index];
  const getItemCount = (data: Array<MessageContent>) => data?.length;

  const renderFooter = (
    <KeyboardAccessoryView
      androidAdjustResize
      inSafeAreaView
      hideBorder
      alwaysVisible
      style={styles.keyboardAcc}
    >
      <MentionList
        showUserList={showUserList}
        members={mentionMembers}
        mentionLoading={mentionLoading}
        rawText={message}
        textRef={messageRef}
        setRawText={setMessage}
        setShowUserList={setShowUserList}
      />
      <View style={styles.footerContainer}>
        <Icon
          name="Photo"
          style={styles.footerIcon}
          onPress={onPressImage}
          color={colors.textLighter}
        />
        <Icon
          name="Link"
          style={styles.footerIcon}
          onPress={onPressLink}
          color={colors.textLighter}
        />
        <ReplyInputField
          inputRef={messageRef}
          loading={replyLoading}
          onPressSend={onPressSend}
          style={styles.inputContainer}
          message={message}
          setMessage={setMessage}
          onSelectedChange={(cursor) => {
            setCursorPosition(cursor);
          }}
          onChangeValue={(message: string) => {
            mentionHelper(
              message,
              cursorPosition,
              setShowUserList,
              setMentionLoading,
              setMentionKeyword,
            );
            setMessage(message);
          }}
          onFocus={() => {
            setInputFocused(true);
            if (contentHeight) {
              setTimeout(() => {
                virtualListRef.current?.scrollToOffset({
                  offset: contentHeight + (37 * screen.height) / 100,
                });
              }, 50);
            }
          }}
          onBlur={() => {
            setInputFocused(false);
            if (contentHeight) {
              setTimeout(() => {
                virtualListRef.current?.scrollToOffset({
                  offset: contentHeight - (37 * screen.height) / 100,
                });
              }, 50);
            }
          }}
        />
      </View>
    </KeyboardAccessoryView>
  );

  const onMessageScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
    if (!initialHeight) {
      setInitialHeight(
        Math.round(
          event.nativeEvent.contentSize.height -
            event.nativeEvent.layoutMeasurement.height,
        ),
      );
    }
    contentHeight = event.nativeEvent.contentOffset.y;
  };

  const onMessageScrollHandler = ({ index }: OnScrollInfo) => {
    if (index) {
      setTimeout(
        () =>
          virtualListRef.current?.scrollToIndex({
            animated: true,
            index,
          }),
        ios ? 50 : 150,
      );
    }
  };

  const onContentSizeChange = () => {
    if (isInitialRequest) {
      let pointerToIndex = postPointer - 1 - startIndex;
      let index = Math.min(19, pointerToIndex);
      try {
        virtualListRef.current?.scrollToIndex({
          animated: true,
          index,
        });
      } catch {
        virtualListRef.current?.scrollToEnd();
      }
      setTimeout(() => setIsInitialRequest(false), 1000);
    }
  };

  if (messageDetailLoading && title === '') {
    return <LoadingOrError loading />;
  }

  return (
    <SafeAreaView style={styles.container}>
      {ios ? (
        <CustomHeader title={t('Message')} />
      ) : (
        <Divider style={styles.divider} />
      )}
      <AvatarRow
        title={title}
        posters={members}
        style={styles.participants}
        extended
      />
      <VirtualizedList
        ref={virtualListRef}
        refreshControl={
          <RefreshControl
            refreshing={refetching || loadingOlderMessages}
            onRefresh={loadStartMore}
            tintColor={colors.primary}
          />
        }
        data={data?.contents}
        getItem={getItem}
        getItemCount={getItemCount}
        renderItem={renderItem}
        keyExtractor={keyExtractor}
        contentInset={{
          bottom: textInputFocused ? (35 * screen.height) / 100 : 0,
          top: contentHeight ? ((5 * screen.height) / 100) * -1 : 0,
        }}
        onEndReachedThreshold={0.1}
        onEndReached={loadEndMore}
        onContentSizeChange={onContentSizeChange}
        contentContainerStyle={styles.messages}
        ListFooterComponent={
          <FooterLoadingIndicator isHidden={!hasNewerMessages} />
        }
        onScroll={onMessageScroll}
        onScrollToIndexFailed={onMessageScrollHandler}
      />
      {renderFooter}
    </SafeAreaView>
  );
}
Example #3
Source File: Messages.tsx    From lexicon with MIT License 4 votes vote down vote up
export default function Messages() {
  const styles = useStyles();
  const { colors } = useTheme();

  const { navigate } = useNavigation<StackNavProp<'Profile'>>();

  const storage = useStorage();
  const username = storage.getItem('user')?.username || '';

  const [messages, setMessages] = useState<Array<MessageType>>([]);
  const [participants, setParticipants] = useState<Array<MessageParticipants>>(
    [],
  );
  const [loading, setLoading] = useState(true);
  const [page, setPage] = useState(0);
  const [hasOlderMessages, setHasOlderMessages] = useState(false);

  const ios = Platform.OS === 'ios';

  const onPressNewMessage = () => {
    navigate('NewMessage', { users: [], listOfUser: [] });
  };

  const { error, refetch, fetchMore } = useMessageList(
    {
      variables: { username, page },
      onCompleted: (data) => {
        const allMessages = data.privateMessage.topicList.topics ?? [];
        const allUsers = data.privateMessage.users ?? [];

        const tempMessages = allMessages.map((item) => ({
          ...item,
          unseen:
            item.highestPostNumber - (item.lastReadPostNumber ?? 0) > 0 ||
            item.unseen,
        }));

        const tempParticipants = allMessages.map(
          ({ participants, lastPosterUsername }) => {
            let userIds: Array<number> =
              participants?.map(({ userId }, idx) => userId ?? idx) || [];
            return getParticipants(
              userIds,
              allUsers,
              username,
              lastPosterUsername ?? '',
            );
          },
        );

        const currentMessageIds = messages.map((topic) => topic.id);
        const incomingMessageIds = tempMessages.map((topic) => topic.id);
        if (
          JSON.stringify(currentMessageIds) ===
          JSON.stringify(incomingMessageIds)
        ) {
          setHasOlderMessages(false);
        } else {
          setHasOlderMessages(true);
        }
        setMessages(tempMessages);
        setParticipants(tempParticipants);
        setLoading(false);
      },
      fetchPolicy: 'network-only',
    },
    'HIDE_ALERT',
  );

  const onRefresh = () => {
    setLoading(true);
    refetch().then(() => setLoading(false));
  };

  const onEndReached = () => {
    if (!hasOlderMessages || loading) {
      return;
    }
    setLoading(true);
    const nextPage = page + 1;
    setPage(nextPage);
    fetchMore({ variables: { username, page: nextPage } }).then(() =>
      setLoading(false),
    );
  };

  const getItem = (messages: Array<MessageType>, index: number) =>
    messages[index];

  const getItemCount = (messages: Array<MessageType>) => messages.length;

  const getItemLayout = (data: MessageType, index: number) => ({
    length: 85,
    offset: 85 * index,
    index,
  });

  const keyExtractor = ({ id }: MessageType) => `message-${id}`;

  const renderItem = ({ item, index }: MessageRenderItem) => (
    <MessageCard
      id={item.id}
      message={item.title}
      messageParticipants={participants[index]}
      allowedUserCount={item.allowedUserCount}
      postPointer={item.lastReadPostNumber || 1}
      date={item.lastPostedAt || ''}
      seen={!item.unseen}
    />
  );

  let content;
  if (error) {
    content = <LoadingOrError message={errorHandler(error, true)} />;
  } else if (loading && messages.length < 1) {
    content = <LoadingOrError loading />;
  } else if (!loading && messages.length < 1) {
    content = <LoadingOrError message={t('You have no messages')} />;
  } else {
    content = (
      <VirtualizedList
        data={messages}
        refreshControl={
          <RefreshControl
            refreshing={loading}
            onRefresh={onRefresh}
            tintColor={colors.primary}
          />
        }
        getItem={getItem}
        getItemCount={getItemCount}
        renderItem={renderItem}
        keyExtractor={keyExtractor}
        getItemLayout={getItemLayout}
        onEndReachedThreshold={0.1}
        onEndReached={onEndReached}
        ListFooterComponent={
          <FooterLoadingIndicator isHidden={!hasOlderMessages} />
        }
        style={styles.messageContainer}
      />
    );
  }

  return (
    <SafeAreaView style={styles.container}>
      {ios && (
        <CustomHeader
          title={t('Messages')}
          rightIcon="Add"
          onPressRight={onPressNewMessage}
        />
      )}
      {content}
      {!ios && (
        <FloatingButton onPress={onPressNewMessage} style={styles.fab} />
      )}
    </SafeAreaView>
  );
}
Example #4
Source File: Notifications.tsx    From lexicon with MIT License 4 votes vote down vote up
export default function Notifications() {
  const styles = useStyles();
  const { colors } = useTheme();

  const ios = Platform.OS === 'ios';

  const { navigate } = useNavigation<StackNavProp<'Notifications'>>();

  const navToPostDetail = (postDetailParams: StackParamList['PostDetail']) => {
    navigate('PostDetail', postDetailParams);
  };

  const navToMessageDetail = (
    messageDetailParams: StackParamList['MessageDetail'],
  ) => {
    navigate('MessageDetail', messageDetailParams);
  };

  const navToUserInformation = (
    userInformationParams: StackParamList['UserInformation'],
  ) => {
    navigate('UserInformation', userInformationParams);
  };

  const [page, setPage] = useState<number>(1);
  const [isLoadMore, setIsLoadMore] = useState(false);
  const [errorMsg, setErrorMsg] = useState<string>('');
  const [showMore, setShowMore] = useState<boolean>(false);
  const [loadMorePolicy, setLoadMorePolicy] = useState<boolean>(false);

  const { data, loading, error, refetch, fetchMore } = useNotification({
    variables: { page },
    onError: (error) => {
      setErrorMsg(errorHandler(error, true));
    },
    nextFetchPolicy: loadMorePolicy ? 'cache-first' : 'no-cache',
  });

  const { markAsRead, loading: markAsReadLoading } = useMarkRead({
    onError: () => {},
  });

  const { singleBadge } = useSingleBadge({
    onCompleted: () => {
      refetch({ page: 1 });
    },
  });

  const onRefresh = () => {
    refetch();
  };

  const onPressMore = () => {
    if (ios) {
      ActionSheetIOS.showActionSheetWithOptions(
        {
          options: ['Mark All as Read', 'Cancel'],
          cancelButtonIndex: 1,
        },
        async (btnIndex) => {
          if (btnIndex === 0) {
            await markAsRead();
            refetch({ page: 1 });
          }
        },
      );
    } else {
      setShowMore(true);
    }
  };

  let rawNotifications = data?.notification.notifications ?? [];
  let handledNotifications = notificationHandler(
    rawNotifications,
    navToPostDetail,
    navToMessageDetail,
    navToUserInformation,
  );

  useEffect(() => {
    if (
      data?.notification.totalRowsNotifications === handledNotifications.length
    ) {
      setIsLoadMore(false);
    } else {
      setIsLoadMore(true);
    }
  }, [data, handledNotifications]);

  if (error) {
    return <LoadingOrError message={errorMsg} />;
  }

  if (handledNotifications.length < 1) {
    if (loading) {
      return <LoadingOrError loading />;
    }
    return <LoadingOrError message={t('No Notifications available')} />;
  }

  const loadMore = () => {
    setLoadMorePolicy(true);
    if (!isLoadMore || loading) {
      return;
    }
    const nextPage = page + 1;
    fetchMore({
      variables: { page: nextPage },
    }).then(() => {
      setLoadMorePolicy(false);
      setPage(nextPage);
    });
  };

  const getItem = (data: Array<NotificationDataType>, index: number) =>
    data[index];

  const getItemCount = (data: Array<NotificationDataType>) => data.length;

  const getItemLayout = (data: NotificationDataType, index: number) => ({
    length: 78.7,
    offset: 78.7 * index,
    index,
  });

  const keyExtractor = ({ id }: NotificationDataType) => `notif-${id}`;

  function renderItem({ item }: { item: NotificationDataType }) {
    const {
      id,
      topicId,
      name,
      message,
      createdAt,
      hasIcon,
      seen,
      onPress,
    } = item;

    return (
      <NotificationItem
        name={name}
        message={message}
        createdAt={createdAt}
        isMessage={hasIcon}
        seen={seen}
        onPress={() => {
          if (item.badgeId) {
            onPress(item.badgeId);
            singleBadge({ variables: { id: item.badgeId } });
          } else {
            onPress(topicId);
            markAsRead({ variables: { notificationId: id } }).then(() =>
              refetch({ page: 1 }),
            );
          }
        }}
      />
    );
  }

  return (
    <>
      <SafeAreaView style={styles.container}>
        <CustomHeader
          title={t('Notifications')}
          rightIcon="More"
          onPressRight={onPressMore}
          isLoading={markAsReadLoading}
        />
        <VirtualizedList
          data={handledNotifications}
          refreshControl={
            <RefreshControl
              refreshing={loading}
              onRefresh={onRefresh}
              tintColor={colors.primary}
            />
          }
          getItem={getItem}
          getItemCount={getItemCount}
          renderItem={renderItem}
          keyExtractor={keyExtractor}
          getItemLayout={getItemLayout}
          onEndReached={loadMore}
          ListFooterComponent={
            <FooterLoadingIndicator isHidden={!isLoadMore} />
          }
          style={styles.notificationContainer}
        />
      </SafeAreaView>
      <TouchableOpacity>
        {!ios && data && (
          <Modal visible={showMore} animationType="fade" transparent={true}>
            <TouchableWithoutFeedback onPressOut={() => setShowMore(false)}>
              <View style={styles.androidModalContainer}>
                <TouchableOpacity
                  style={styles.modalButtonContainer}
                  onPress={() => {
                    markAsRead().then(() => refetch({ page: 1 }));
                    setShowMore(false);
                  }}
                >
                  <Text style={styles.modalText} color="pureBlack" size="l">
                    {t('Mark All as Read')}
                  </Text>
                </TouchableOpacity>
              </View>
            </TouchableWithoutFeedback>
          </Modal>
        )}
      </TouchableOpacity>
    </>
  );
}
Example #5
Source File: PostDetail.tsx    From lexicon with MIT License 4 votes vote down vote up
export default function PostDetail() {
  const { topicsData } = usePost();
  const styles = useStyles();
  const { colors, spacing } = useTheme();

  const navigation = useNavigation<StackNavProp<'PostDetail'>>();
  const { navigate, goBack, setOptions, setParams } = navigation;

  const {
    params: {
      topicId,
      selectedChannelId = -1,
      postNumber = null,
      focusedPostNumber,
      prevScreen,
    },
  } = useRoute<StackRouteProp<'PostDetail'>>();

  const storage = useStorage();
  const currentUserId = storage.getItem('user')?.id;

  const channels = storage.getItem('channels');

  const virtualListRef = useRef<VirtualizedList<Post>>(null);

  const [hasOlderPost, setHasOlderPost] = useState(true);
  const [hasNewerPost, setHasNewerPost] = useState(true);
  const [loadingRefresh, setLoadingRefresh] = useState(false);
  const [loadingOlderPost, setLoadingOlderPost] = useState(false);
  const [loadingNewerPost, setLoadingNewerPost] = useState(false);
  const [loading, setLoading] = useState(true);
  const [canFlagFocusPost, setCanFlagFocusPost] = useState(false);
  const [canEditFocusPost, setCanEditFocusPost] = useState(false);
  const [showActionSheet, setShowActionSheet] = useState(false);
  const [replyLoading, setReplyLoading] = useState(false);
  const [postIdOnFocus, setPostIdOnFocus] = useState(0);
  const [startIndex, setStartIndex] = useState(0);
  const [endIndex, setEndIndex] = useState(0);
  const [stream, setStream] = useState<Array<number>>();
  const [fromPost, setFromPost] = useState(false);
  const [author, setAuthor] = useState('');
  const [showOptions, setShowOptions] = useState(false);
  const [flaggedByCommunity, setFlaggedByCommunity] = useState(false);
  const [content, setContent] = useState('');
  const [images, setImages] = useState<Array<string>>();
  const [mentionedUsers, setmentionedUsers] = useState<Array<string>>();
  const [isHidden, setHidden] = useState(false);

  const ios = Platform.OS === 'ios';

  useEffect(() => {
    if (selectedChannelId !== -1) {
      setOptions({
        headerLeft: () => (
          <HeaderBackButton
            onPress={goBack}
            style={{
              paddingBottom: ios ? spacing.l : spacing.s,
            }}
            tintColor={colors.primary}
          />
        ),
      });
    }
  }, [colors, goBack, ios, selectedChannelId, setOptions, spacing]);

  const {
    data,
    loading: topicDetailLoading,
    error,
    refetch,
    fetchMore,
  } = useTopicDetail(
    {
      variables: { topicId, postPointer: postNumber },
    },
    'HIDE_ALERT',
  );

  const { postRaw } = usePostRaw({
    onCompleted: ({ postRaw: { raw, listOfCooked, listOfMention } }) => {
      setContent(raw);
      setImages(listOfCooked);
      setmentionedUsers(listOfMention);
    },
    onError: () => {},
  });

  let postDetailContentHandlerResult = useMemo(() => {
    if (!data) {
      return;
    }
    return postDetailContentHandler({
      topicDetailData: data.topicDetail,
      channels,
    });
  }, [data, channels]);

  let topic = postDetailContentHandlerResult?.topic;

  let posts = postDetailContentHandlerResult?.posts;

  useEffect(() => {
    let isItemValid = false;
    if (!!(topic && topic.canEditTopic)) {
      isItemValid = true;
    }
    if (!!(posts && posts[0].canFlag && !posts[0].hidden)) {
      isItemValid = true;
    }
    setShowOptions(isItemValid);
  }, [topic, posts]);

  useEffect(() => {
    const topicData = topicsData.find(
      (topicData) => topicData?.topicId === topicId,
    );
    setContent(topicData?.content || '');
    setHidden(topicData?.hidden || false);
    if (posts) {
      postRaw({ variables: { postId: posts[0].id } });
      setHidden(posts[0].hidden || false);
    }
  }, [posts, topicsData, topicId, postRaw]);

  const onPressViewIgnoredContent = () => {
    setHidden(false);
  };

  useEffect(() => {
    if (data) {
      let topicDetailData = data.topicDetail;
      let {
        stream: tempStream,
        firstPostIndex,
        lastPostIndex,
      } = postDetailContentHandler({ topicDetailData, channels });
      setStream(tempStream || []);
      setStartIndex(firstPostIndex);
      setEndIndex(lastPostIndex);
      setLoading(false);
      setReplyLoading(false);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [data]);

  const refreshPost = async () => {
    setLoadingRefresh(true);

    refetch({ topicId }).then(({ data }) => {
      const {
        topicDetail: { postStream, title, categoryId, tags },
      } = data;
      const { stream, posts } = postStream;
      const [firstPostId] = stream ?? [0];
      const firstPost = posts.find(({ id }) => firstPostId === id);

      client.writeFragment({
        id: `Topic:${topicId}`,
        fragment: TOPIC_FRAGMENT,
        data: {
          title,
          excerpt: firstPost?.raw,
          imageUrl: firstPost?.listOfCooked || undefined,
          categoryId,
          tags,
        },
      });
      setLoadingRefresh(false);
    });
  };

  const loadOlderPost = async () => {
    if (loadingOlderPost || !hasOlderPost || !stream || topicDetailLoading) {
      return;
    }
    setLoadingOlderPost(true);
    let nextEndIndex = startIndex;
    let newDataCount = Math.min(10, stream.length - nextEndIndex);
    let nextStartIndex = Math.max(0, nextEndIndex - newDataCount);

    let nextPosts = stream.slice(nextStartIndex, nextEndIndex);
    if (!nextPosts.length) {
      return;
    }
    await fetchMore({
      variables: {
        topicId,
        posts: nextPosts,
      },
    });
    setStartIndex(nextStartIndex);
    setLoadingOlderPost(false);
  };

  const loadNewerPost = async () => {
    if (loadingNewerPost || !hasNewerPost || !stream || topicDetailLoading) {
      return;
    }
    setLoadingNewerPost(true);
    let nextStartIndex = endIndex + 1;
    let newDataCount = Math.min(10, stream.length - nextStartIndex);
    let nextEndIndex = nextStartIndex + newDataCount;

    let nextPosts = stream.slice(nextStartIndex, nextEndIndex);
    if (!nextPosts.length) {
      return;
    }
    await fetchMore({
      variables: {
        topicId,
        posts: nextPosts,
      },
    });
    setEndIndex(nextEndIndex - 1);
    setLoadingNewerPost(false);
  };

  useTopicTiming(topicId, startIndex, stream);

  useEffect(() => {
    if (!stream || !posts) {
      return;
    }
    setHasOlderPost(stream[0] !== posts[0].id);
    setHasNewerPost(stream[stream.length - 1] !== posts[posts.length - 1].id);
  }, [stream, topicId, posts]);

  useEffect(() => {
    async function refetchData() {
      let index = 0;
      if (focusedPostNumber === 1) {
        await refetch({ topicId });
      } else {
        const result = await refetch({
          topicId,
          postPointer: focusedPostNumber,
        });
        let {
          data: {
            topicDetail: { postStream },
          },
        } = result;
        const firstPostIndex =
          postStream.stream?.findIndex(
            (postId) => postId === postStream.posts[0].id,
          ) || 0;
        let pointerToIndex = focusedPostNumber
          ? focusedPostNumber - 1 - firstPostIndex
          : 0;
        index = Math.min(MAX_DEFAULT_LOADED_POST_COUNT, pointerToIndex);
      }
      setTimeout(() => {
        try {
          virtualListRef.current &&
            virtualListRef.current.scrollToIndex({
              index,
              animated: true,
            });
        } catch {
          virtualListRef.current && virtualListRef.current.scrollToEnd();
        }
      }, 500);
    }
    const unsubscribe = navigation.addListener('focus', () => {
      if (focusedPostNumber != null && prevScreen === 'PostPreview') {
        setParams({ prevScreen: '' });
        setReplyLoading(true);
        refetchData();
      }
    });
    return unsubscribe;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [prevScreen, focusedPostNumber]);

  useEffect(() => {
    if (virtualListRef.current && postNumber) {
      if (posts?.slice(1).length !== 0) {
        virtualListRef.current.scrollToIndex({
          animated: true,
          index: postNumber > 1 ? postNumber - 2 - startIndex : 0,
        });
      }
    }
  }, [virtualListRef.current?.state]); // eslint-disable-line

  if (error) {
    return <LoadingOrError message={errorHandler(error)} />;
  }

  const onPressAuthor = (username: string) => {
    navigate('UserInformation', { username });
  };

  const renderTopicFromCache = () => {
    const cacheTopic = client.readFragment({
      id: `Topic:${topicId}`,
      fragment: TOPIC_FRAGMENT,
    });

    if (cacheTopic) {
      const cacheUser = client.readFragment({
        id: `UserIcon:${cacheTopic.authorUserId}`,
        fragment: USER_FRAGMENT,
      });

      const cacheFreqPoster = cacheTopic.posters?.map(
        (item: { userId: number }) => {
          let user = client.readFragment({
            id: `UserIcon:${item.userId}`,
            fragment: USER_FRAGMENT,
          });
          const { id, username, avatar } = user;
          return {
            id,
            username,
            avatar: getImage(avatar),
          };
        },
      );

      const channels = storage.getItem('channels');
      const channel = channels?.find(
        (channel) => channel.id === cacheTopic.categoryId,
      );

      const { title, excerpt, hidden, imageUrl, tags } = cacheTopic;
      const { username, avatar } = cacheUser;
      let tempPost = {
        id: 0,
        topicId,
        title,
        content: excerpt,
        hidden,
        username,
        avatar: getImage(avatar),
        images: [imageUrl] ?? undefined,
        viewCount: 0,
        replyCount: 0,
        likeCount: 0,
        isLiked: false,
        channel: channel ?? DEFAULT_CHANNEL,
        tags,
        createdAt: '',
        freqPosters: cacheFreqPoster ?? [],
      };

      return (
        <PostItem
          data={tempPost}
          postList={false}
          nonclickable
          onPressAuthor={onPressAuthor}
          content={content}
          isHidden={isHidden}
        />
      );
    }
    return null;
  };

  if (!data || !topic) {
    if (loading) {
      return renderTopicFromCache() ?? <LoadingOrError loading />;
    }

    return <LoadingOrError message={t('Post is not available')} />;
  }

  if (replyLoading) {
    return <LoadingOrError message={t('Finishing your Reply')} loading />;
  }

  const navToFlag = (
    postId = postIdOnFocus,
    isPost = fromPost,
    flaggedAuthor = author,
  ) => {
    navigate('FlagPost', { postId, isPost, flaggedAuthor });
  };

  const navToPost = (postId = postIdOnFocus) => {
    if (!topic) {
      return;
    }
    const {
      firstPostId,
      id,
      title,
      selectedChanelId: selectedChannelId,
      selectedTag: selectedTagsIds,
    } = topic;
    if (stream && postId === stream[0]) {
      navigate('NewPost', {
        editPostId: firstPostId,
        editTopicId: id,
        selectedChannelId,
        selectedTagsIds,
        oldContent: getPost(firstPostId)?.content,
        oldTitle: title,
        oldChannel: selectedChannelId,
        oldTags: selectedTagsIds,
        editedUser: {
          username: getPost(postId)?.username || '',
          avatar: getPost(postId)?.avatar || '',
        },
      });
    } else {
      navigate('PostReply', {
        topicId,
        title,
        editPostId: postId,
        oldContent: getPost(postId)?.content,
        focusedPostNumber: (getPost(postId)?.postNumber || 2) - 1,
        editedUser: {
          username: getPost(postId)?.username || '',
          avatar: getPost(postId)?.avatar || '',
        },
      });
    }
  };

  const onPressMore = (
    id?: number,
    canFlag = !!(posts && posts[0].canFlag),
    canEdit = !!(topic && topic.canEditTopic),
    flaggedByCommunity = !!(posts && posts[0].hidden),
    fromPost = true,
    author?: string,
  ) => {
    if (currentUserId && topic) {
      if (!id || typeof id !== 'number') {
        id = topic.firstPostId;
      }
      setPostIdOnFocus(id);
      setCanEditFocusPost(canEdit);
      setCanFlagFocusPost(canFlag);
      setFlaggedByCommunity(flaggedByCommunity);
      setShowActionSheet(true);
      if (author) {
        setAuthor(author);
      }
      if (!fromPost) {
        setFromPost(false);
      } else {
        setFromPost(true);
      }
    } else {
      errorHandlerAlert(LoginError, navigate);
    }
  };

  const actionItemOptions = () => {
    let options: ActionSheetProps['options'] = [];
    ios && options.push({ label: t('Cancel') });
    canEditFocusPost && options.push({ label: t('Edit Post') });
    !flaggedByCommunity &&
      options.push({
        label: canFlagFocusPost ? t('Flag') : t('Flagged'),
        disabled: !canFlagFocusPost,
      });
    return options;
  };

  const actionItemOnPress = (btnIndex: number) => {
    switch (btnIndex) {
      case 0: {
        return canEditFocusPost ? navToPost() : navToFlag();
      }
      case 1: {
        return canEditFocusPost && !flaggedByCommunity && navToFlag();
      }
    }
  };

  const getPost = (postId?: number) => {
    if (!posts) {
      return;
    }
    return postId === -1 || postIdOnFocus === -1
      ? undefined
      : posts.find(({ id }) => id === (postId || postIdOnFocus)) || posts[0];
  };

  const getReplyPost = (postNumberReplied: number) => {
    if (!posts) {
      return;
    }
    return posts.find(({ postNumber }) => postNumberReplied === postNumber);
  };

  const onPressReply = (id = -1) => {
    if (currentUserId) {
      if (stream && topic) {
        navigate('PostReply', {
          topicId,
          title: topic.title,
          post: getPost(id),
          focusedPostNumber: stream.length,
        });
      }
    } else {
      errorHandlerAlert(LoginError, navigate);
    }
  };

  let arrayCommentLikeCount = posts
    ? posts.slice(1).map((val) => {
        return val.likeCount;
      })
    : [];

  let sumCommentLikeCount = arrayCommentLikeCount.reduce((a, b) => a + b, 0);

  let currentPost = topicsData.filter((val) => {
    return val.topicId === topicId;
  });

  const getItem = (data: Array<Post>, index: number) => data[index];

  const getItemCount = (data: Array<Post>) => data.length;

  const keyExtractor = ({ id }: Post) => `post-${id}`;

  const renderItem = ({ item }: PostReplyItem) => {
    const { replyToPostNumber, canEdit, canFlag, hidden, id, username } = item;
    const replyPost =
      replyToPostNumber !== -1
        ? getReplyPost(replyToPostNumber ?? 1)
        : undefined;
    let isItemValid = false;
    if (canEdit) {
      isItemValid = true;
    }
    if (canFlag && !hidden) {
      isItemValid = true;
    }

    return (
      <NestedComment
        data={item}
        replyTo={replyPost}
        key={id}
        style={styles.lowerContainer}
        showOptions={isItemValid}
        onPressReply={() => onPressReply(id)}
        onPressMore={() =>
          onPressMore(id, canFlag, canEdit, hidden, false, username)
        }
        onPressAuthor={onPressAuthor}
      />
    );
  };

  const onScrollHandler = ({ index }: OnScrollInfo) => {
    setTimeout(
      () =>
        virtualListRef.current?.scrollToIndex({
          animated: true,
          index,
        }),
      50,
    );
  };

  return (
    <>
      <SafeAreaView style={styles.container}>
        {showOptions && (
          <CustomHeader
            title=""
            rightIcon="More"
            onPressRight={onPressMore}
            noShadow
          />
        )}
        <VirtualizedList
          ref={virtualListRef}
          refreshControl={
            <RefreshControl
              refreshing={
                (loadingRefresh || topicDetailLoading) && !loadingNewerPost
              }
              onRefresh={refreshPost}
              tintColor={colors.primary}
            />
          }
          data={posts && posts.slice(1)}
          getItem={getItem}
          getItemCount={getItemCount}
          renderItem={renderItem}
          keyExtractor={keyExtractor}
          initialNumToRender={5}
          maxToRenderPerBatch={7}
          windowSize={10}
          ListHeaderComponent={
            posts && stream && posts[0].id === stream[0] && topic ? (
              <PostItem
                data={{
                  ...posts[0],
                  likeCount:
                    currentPost.length !== 0
                      ? currentPost[0].likeCount - sumCommentLikeCount
                      : 0,
                  content: handleSpecialMarkdown(posts[0].content),
                  title: topic.title,
                  tags: topic.selectedTag,
                  viewCount: topic.viewCount,
                  replyCount: topic.replyCount,
                }}
                postList={false}
                nonclickable
                onPressAuthor={onPressAuthor}
                content={content}
                images={images}
                mentionedUsers={mentionedUsers}
                isHidden={isHidden}
                onPressViewIgnoredContent={onPressViewIgnoredContent}
              />
            ) : (
              renderTopicFromCache()
            )
          }
          onRefresh={hasOlderPost ? loadOlderPost : refreshPost}
          refreshing={
            (loadingRefresh || loadingOlderPost || topicDetailLoading) &&
            !loadingNewerPost
          }
          onEndReachedThreshold={0.1}
          onEndReached={loadNewerPost}
          ListFooterComponent={
            <FooterLoadingIndicator isHidden={!hasNewerPost} />
          }
          style={styles.scrollViewContainer}
          initialScrollIndex={0}
          onScrollToIndexFailed={onScrollHandler}
        />
        <TouchableOpacity
          style={styles.inputCommentContainer}
          onPress={() => onPressReply(-1)}
        >
          <Text style={styles.inputComment}>{t('Write your reply here')}</Text>
        </TouchableOpacity>
      </SafeAreaView>
      <TouchableOpacity>
        {stream && (
          <ActionSheet
            visible={showActionSheet}
            options={actionItemOptions()}
            cancelButtonIndex={ios ? 0 : undefined}
            actionItemOnPress={actionItemOnPress}
            onClose={() => {
              setShowActionSheet(false);
            }}
            style={!ios && styles.androidModalContainer}
          />
        )}
      </TouchableOpacity>
    </>
  );
}