From f00a949686cc0fc3c710f35c60f5064d0bcdb7ad Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 21 Oct 2026 07:54:37 +0200 Subject: [PATCH 1/2] docs: tidy code comments in package/src Rewrite the code and doc comments of the SDK source and ai-components in the present tense and keep only what is non-obvious. Fix comments that disagreed with the code, add missing @param/@returns tags to documented public functions, and remove commented-out code. No behavior change. --- .../src/native/NativeAIDictation.ts | 17 +- .../src/native/NativeAIDictation.ts | 17 +- package/src/a11y/a11yUtils.ts | 26 +- package/src/a11y/hooks/useA11yLabel.ts | 14 +- .../hooks/useAccessibilityActivateAction.ts | 11 +- .../hooks/useAccessibilityServiceEnabled.ts | 21 +- package/src/a11y/hooks/useAnnounceOnShow.ts | 17 +- .../a11y/hooks/useAnnounceOnStateChange.ts | 9 +- .../a11y/hooks/useReducedMotionPreference.ts | 7 +- .../useResolvedModalAccessibilityProps.ts | 12 +- .../src/a11y/hooks/useScreenReaderEnabled.ts | 8 +- .../a11y/hooks/useScreenReaderMountFocus.ts | 7 +- .../a11y/hooks/useSetAccessibilityFocus.ts | 33 +-- .../ai-components/AITypingIndicatorView.tsx | 5 +- .../charts/components/PieLegend.tsx | 2 +- .../charts/components/VictoryChart.tsx | 21 +- package/src/ai-components/charts/index.ts | 4 - package/src/ai-components/charts/types.ts | 7 +- .../charts/utils/generate-pie-colors.ts | 8 +- .../charts/utils/parse-json-chart.ts | 2 +- .../ai-components/charts/vega-lite/schema.ts | 26 +- .../ai-components/components/BottomSheet.tsx | 2 +- package/src/ai-components/index.ts | 11 +- .../internal/hooks/useStableCallback.ts | 27 +- .../ai-components/internal/icons/SendUp.tsx | 1 - .../markdown/components/CodeBlock.tsx | 4 +- .../markdown/components/Table.tsx | 5 +- package/src/ai-components/markdown/rules.ts | 22 +- package/src/ai-components/markdown/styles.ts | 2 +- package/src/ai-components/markdown/types.ts | 2 +- .../markdown/utils/generate-markdown-text.ts | 39 +-- .../message-composer/SpeechToTextButton.tsx | 5 +- .../native-specs/NativeAIDictation.ts | 17 +- .../src/ai-components/native/AIDictation.ts | 9 +- .../services/clipboard-service/expo.ts | 2 +- .../services/clipboard-service/rncli.ts | 2 +- .../services/media-picker-service/expo.ts | 4 +- .../optional-modules.d.ts | 6 +- .../services/media-picker-service/rncli.ts | 8 +- package/src/ai-components/store/index.ts | 3 - .../syntax-highlighting/index.ts | 5 +- .../syntax-highlighting/types.ts | 5 +- .../syntax-highlighting/utils/converter.ts | 34 +-- .../utils/flatten-nodes.ts | 18 +- .../AITypingIndicatorView/hooks/useAIState.ts | 10 +- .../CompositeAccessibilityProbe.tsx | 26 +- .../Accessibility/HiddenA11yText.tsx | 30 +- .../Accessibility/NotificationAnnouncer.tsx | 13 +- .../Accessibility/OverlayA11yShield.tsx | 21 +- .../hooks/useIncomingMessageAnnouncements.ts | 17 +- .../useAccessibilityAnnouncer.ts | 7 +- .../src/components/Attachment/Attachment.tsx | 14 +- .../AttachmentFileUploadProgressIndicator.tsx | 4 +- .../Attachment/AttachmentUploadIndicator.tsx | 3 +- .../Attachment/Audio/AudioAttachment.tsx | 25 +- .../Attachment/Audio/PlayPauseButton.tsx | 12 +- .../Attachment/CircularProgressIndicator.tsx | 6 +- .../components/Attachment/FileAttachment.tsx | 2 +- .../src/components/Attachment/FileIcon.tsx | 13 +- .../src/components/Attachment/FilePreview.tsx | 5 +- package/src/components/Attachment/Gallery.tsx | 10 +- .../components/Attachment/GalleryImage.tsx | 4 +- .../src/components/Attachment/Giphy/Giphy.tsx | 4 +- .../Attachment/Giphy/GiphyImage.tsx | 8 +- .../ImageLoadingFailedIndicator.tsx | 5 +- .../Attachment/MediaUploadProgressOverlay.tsx | 4 +- .../Attachment/UnsupportedAttachment.tsx | 2 +- .../Attachment/UrlPreview/URLPreview.tsx | 6 +- .../UrlPreview/URLPreviewCompact.tsx | 6 +- .../components/Attachment/VideoThumbnail.tsx | 4 +- .../utils/buildGallery/buildGallery.ts | 30 +- .../buildGallery/buildGalleryOfSingleImage.ts | 8 - .../buildGallery/buildGalleryOfThreeImages.ts | 28 +- .../buildGallery/buildGalleryOfTwoImages.ts | 20 +- .../utils/buildGallery/buildThumbnail.ts | 4 +- .../utils/buildGallery/buildThumbnailGrid.ts | 138 +-------- .../Attachment/utils/buildGallery/types.ts | 2 +- .../Attachment/utils/getAspectRatio.ts | 7 +- .../Attachment/utils/getGiphyMimeType.ts | 5 +- .../Attachment/utils/openUrlSafely.ts | 4 +- .../AttachmentPicker/AttachmentPicker.tsx | 10 +- .../AttachmentMediaPicker.tsx | 11 +- .../AttachmentPickerItem.tsx | 12 +- .../components/AttachmentTypePickerButton.tsx | 2 +- .../AutoCompleteInput/AutoCompleteInput.tsx | 46 ++- .../AutoCompleteSuggestionItem.tsx | 11 +- .../AutoCompleteSuggestionList.tsx | 4 +- .../mentionItems/EnhancedMentionContent.tsx | 3 +- .../mentionItems/EnhancedMentionIcon.tsx | 18 +- .../mentionItems/MentionBroadcastItem.tsx | 4 +- .../mentionItems/MentionItem.tsx | 11 +- .../mentionItems/TokenizedSuggestionParts.tsx | 10 +- package/src/components/Channel/Channel.tsx | 38 +-- .../src/components/Channel/ChannelShell.tsx | 56 ++-- .../Channel/hooks/useChannelLifecycle.ts | 55 ++-- .../Channel/hooks/useCreateChannelContext.ts | 2 +- .../useCreateInputMessageInputContext.ts | 4 +- .../Channel/hooks/useCreateMessagesContext.ts | 4 +- .../hooks/useCreateOwnCapabilitiesContext.ts | 3 +- .../Channel/hooks/useSupersededChannelSwap.ts | 11 +- .../ChannelDetails/ChannelDetails.tsx | 4 +- .../ChannelDetailsActionsSection.tsx | 13 +- .../components/ChannelDetailsEditButton.tsx | 9 +- .../ChannelDetailsMemberSection.tsx | 4 +- .../components/ChannelDetailsNavHeader.tsx | 4 +- .../ChannelDetailsNavigationSection.tsx | 9 +- .../components/ChannelEditDetailsForm.tsx | 14 +- .../ChannelEditDetailsFormContent.tsx | 3 +- .../ChannelEditDetailsFormHeader.tsx | 4 +- .../members/ChannelAddMembersButton.tsx | 7 +- .../members/ChannelAddMembersForm.tsx | 13 +- .../members/ChannelAddMembersFormContent.tsx | 9 +- .../members/ChannelAddMembersFormHeader.tsx | 4 +- .../members/ChannelMemberActionsSheet.tsx | 7 +- .../components/members/ChannelMemberItem.tsx | 12 +- .../components/members/ChannelMemberList.tsx | 22 +- .../navigation-section/FileAttachmentItem.tsx | 12 +- .../navigation-section/FileAttachmentList.tsx | 15 +- .../FileAttachmentListSectionHeader.tsx | 2 +- .../navigation-section/MediaItem.tsx | 12 +- .../navigation-section/MediaList.tsx | 22 +- .../MediaListLoadingSkeleton.tsx | 6 +- .../navigation-section/PinnedMessageList.tsx | 15 +- .../components/roles/RoleItem.tsx | 8 +- .../components/roles/RoleList.tsx | 4 +- .../hooks/members/useMemberRoles.ts | 24 +- .../useChannelDetailsMemberStatusText.ts | 8 +- .../hooks/useChannelDetailsNavigationItems.ts | 44 ++- .../hooks/useEditChannelImage.ts | 22 +- .../hooks/useFileAttachmentListSections.ts | 23 +- .../ChannelDetails/hooks/useMediaList.ts | 17 +- .../hooks/useUserActivityStatus.ts | 12 +- .../components/ChannelList/ChannelList.tsx | 17 +- .../ChannelList/ChannelListView.tsx | 45 +-- .../hooks/useChannelMembersState.ts | 25 +- .../hooks/useChannelOnlineMemberCount.ts | 12 +- .../ChannelList/hooks/useMutedChannels.ts | 5 +- .../ChannelList/hooks/useMutedUsers.ts | 4 +- .../ChannelList/hooks/usePaginatedChannels.ts | 17 +- .../ChannelMessagePreviewDeliveryStatus.tsx | 11 +- .../ChannelPreview/ChannelPreview.tsx | 4 +- .../ChannelPreview/ChannelPreviewMessage.tsx | 4 +- .../ChannelPreviewMutedStatus.tsx | 4 +- .../ChannelPreviewPinnedStatus.tsx | 4 +- .../ChannelPreview/ChannelPreviewTitle.tsx | 4 +- .../ChannelPreviewUnreadCount.tsx | 4 +- .../ChannelPreview/ChannelPreviewView.tsx | 17 +- .../hooks/useChannelPreviewData.ts | 17 +- .../hooks/useChannelPreviewDisplayName.ts | 4 +- .../hooks/useChannelPreviewDisplayPresence.ts | 14 +- .../hooks/useChannelPreviewDraftMessage.ts | 7 +- .../hooks/useChannelPreviewPollLabel.ts | 7 +- .../hooks/useChannelTypingState.ts | 9 +- .../ChannelPreview/hooks/useIsChannelMuted.ts | 6 +- package/src/components/Chat/Chat.tsx | 122 +++----- .../Chat/hooks/useAppSettingsGetter.ts | 15 +- .../components/Chat/hooks/useClientUserId.ts | 13 +- .../Chat/hooks/useConnectionLifecycle.ts | 72 ++--- .../Chat/hooks/useCreateChatClient.ts | 29 +- .../Chat/hooks/useInitializeOfflineDb.ts | 54 ++-- .../Chat/hooks/useNetworkConnectionState.ts | 25 +- .../Chat/hooks/usePendingUploadsDefault.ts | 25 +- .../Chat/hooks/useWSConnectionState.ts | 65 ++--- .../components/ImageGallery/ImageGallery.tsx | 86 ++---- .../components/AnimatedGalleryImage.tsx | 18 +- .../components/AnimatedGalleryVideo.tsx | 11 +- .../components/ImageGalleryA11yProbe.tsx | 13 +- .../components/ImageGalleryFooter.tsx | 19 +- .../components/ImageGalleryHeader.tsx | 9 +- .../components/ImageGalleryVideoControl.tsx | 6 +- .../hooks/useAnimatedGalleryStyle.tsx | 17 +- .../hooks/useCurrentImageHeight.ts | 19 +- .../hooks/useImageGalleryGestures.tsx | 265 ++++-------------- .../hooks/useImageGalleryVideoPlayer.ts | 6 +- .../Indicators/LoadingIndicator.tsx | 4 +- .../KeyboardCompatibleView.tsx | 69 ++--- package/src/components/Message/Message.tsx | 78 ++---- .../Headers/SentToChannelHeader.tsx | 7 +- .../MessageItemView/MessageBlocked.tsx | 8 +- .../MessageItemView/MessageContent.tsx | 42 +-- .../Message/MessageItemView/MessageFooter.tsx | 6 +- .../MessageItemView/MessageItemView.tsx | 7 +- .../MessageItemView/MessageReplies.tsx | 2 +- .../MessageItemView/MessageTextContainer.tsx | 13 +- .../MessageItemView/MessageTimestamp.tsx | 12 +- .../MessageItemView/MessageWrapper.tsx | 30 +- .../ReactionList/ReactionListBottom.tsx | 2 +- .../ReactionList/ReactionListTop.tsx | 4 +- .../utils/generateMarkdownText.ts | 18 +- .../MessageItemView/utils/parseLinks.ts | 11 +- .../MessageItemView/utils/renderText.tsx | 41 ++- .../Message/MessageOverlayWrapper.tsx | 8 +- .../hooks/useCreateMessageActionHandlers.ts | 17 +- .../Message/hooks/useCreateMessageContext.ts | 6 +- .../hooks/useIsMessageDeliveredToOthers.ts | 13 +- .../Message/hooks/useIsMessageReadByOthers.ts | 17 +- .../Message/hooks/useMessageActionHandlers.ts | 4 +- .../Message/hooks/useMessageActions.tsx | 8 +- .../Message/hooks/useMessageOperations.ts | 47 ++-- .../Message/hooks/useProcessReactions.ts | 12 +- .../Message/hooks/useStreamingMessage.ts | 13 +- .../Message/hooks/useUserMuteActive.ts | 10 +- .../Message/utils/measureInWindow.ts | 38 +-- .../Message/utils/messageActions.ts | 1 - .../MessageInput/MessageComposer.tsx | 26 +- .../StopMessageStreamingButton.tsx | 2 +- .../AttachmentUploadPreviewList.tsx | 9 +- .../AudioRecorder/AudioRecorder.tsx | 8 +- .../AudioRecorder/AudioRecordingButton.tsx | 16 +- .../AudioRecordingInProgress.tsx | 10 +- .../AudioRecordingLockIndicator.tsx | 12 +- .../AudioRecorder/AudioRecordingPreview.tsx | 12 +- .../AudioRecorder/AudioRecordingWaveform.tsx | 8 +- .../components/InputButtons/AttachButton.tsx | 6 +- .../OutputButtons/CooldownTimer.tsx | 4 +- .../MessageInput/hooks/useAudioRecorder.tsx | 30 +- .../hooks/useCooldownRemaining.tsx | 6 +- .../MessageInput/utils/audioSampling.ts | 12 +- .../MessageInput/utils/normalizeAudioLevel.ts | 6 +- .../MessageList/InlineDateSeparator.tsx | 7 +- .../MessageList/MessageFlashList.tsx | 149 +++------- .../components/MessageList/MessageList.tsx | 206 ++++---------- .../components/MessageList/MessageSystem.tsx | 15 +- .../MessageList/ScrollToBottomButton.tsx | 4 +- .../components/MessageList/StickyHeader.tsx | 11 +- .../UnreadMessagesNotification.tsx | 8 +- .../MessageList/hooks/useMarkRead.ts | 50 ++-- .../hooks/useMessageDateSeparator.ts | 13 +- .../hooks/useMessageGroupStyles.ts | 13 +- .../MessageList/hooks/useMessageList.ts | 5 +- .../MessageList/hooks/useMessageListFocus.ts | 14 +- .../hooks/useMessageListLiveState.ts | 7 +- .../hooks/useMessageListPagination.ts | 11 +- .../MessageList/hooks/useOwnUnreadCount.ts | 3 + .../useScrollToBottomAccessibilityAction.ts | 7 +- .../MessageList/hooks/useStickyHeaderDate.ts | 6 +- .../MessageList/hooks/useTypingString.ts | 5 +- .../MessageList/hooks/useTypingUsers.ts | 19 +- .../hooks/useUnreadNotificationVisibility.ts | 12 +- .../utils/buildMessageListWithNeighbours.ts | 14 +- .../MessageList/utils/filterTypingUsers.ts | 4 +- .../MessageList/utils/getGroupStyles.ts | 29 +- .../MessageMenu/EmojiPickerList.tsx | 9 +- .../MessageMenu/MessageActionList.tsx | 9 +- .../MessageMenu/MessageActionListItem.tsx | 25 +- .../MessageActionListWithActions.tsx | 4 +- .../MessageMenu/MessageReactionPicker.tsx | 4 +- .../MessageMenu/MessageUserReactions.tsx | 8 +- .../MessageUserReactionsAvatar.tsx | 4 +- .../MessageMenu/MessageUserReactionsItem.tsx | 8 +- .../components/MessageMenu/ReactionButton.tsx | 18 +- .../components/Notifications/Notification.tsx | 11 +- .../Notifications/NotificationList.tsx | 2 +- .../NotificationTargetContext.tsx | 14 +- .../Notifications/hooks/useNotificationApi.ts | 17 +- .../Notifications/hooks/useNotifications.ts | 9 +- .../hooks/useSystemNotifications.ts | 7 +- .../Notifications/notificationFilters.ts | 20 +- .../Notifications/notificationTarget.ts | 146 ++++++++-- .../Notifications/notificationTranslations.ts | 27 +- .../src/components/Poll/CreatePollContent.tsx | 8 +- .../src/components/Poll/components/Button.tsx | 4 +- .../Poll/components/CreatePollHeader.tsx | 10 +- .../Poll/components/CreatePollOptions.tsx | 25 +- .../Poll/components/PollButtons.tsx | 4 +- .../components/Poll/components/PollOption.tsx | 4 +- .../hook/useIsPollCreatedByCurrentUser.ts | 5 +- .../src/components/Poll/hooks/useEndVote.ts | 7 +- .../Poll/hooks/usePollAnswersPagination.ts | 18 +- .../hooks/usePollOptionVotesPagination.ts | 22 +- .../Poll/hooks/usePollVoteToggle.ts | 9 +- package/src/components/Poll/index.ts | 2 - .../ProgressControl/ProgressBar.tsx | 1 - .../ProgressControl/ProgressControl.tsx | 31 +- .../ProgressControl/ProgressThumb.tsx | 8 +- .../ProgressControl/StableDurationLabel.tsx | 6 +- .../ProgressControl/WaveProgressBar.tsx | 28 +- package/src/components/Reply/Reply.tsx | 19 +- package/src/components/Thread/Thread.tsx | 49 ++-- .../components/ThreadFooterComponent.tsx | 13 +- .../ThreadMessagePreviewDeliveryStatus.tsx | 10 +- .../UIComponents/BottomSheetModal.tsx | 53 ++-- .../UIComponents/ClippingFadeBottom.tsx | 13 +- .../src/components/UIComponents/EmptyList.tsx | 12 +- .../GenericListLoadingSkeleton.tsx | 2 +- .../UIComponents/ImageBackground.tsx | 36 +-- .../UIComponents/PortalWhileClosingView.tsx | 48 +--- .../UIComponents/SafeAreaViewWrapper.tsx | 13 +- .../components/UIComponents/SvgAwareImage.tsx | 7 +- package/src/components/index.ts | 5 +- package/src/components/ui/Avatar/Avatar.tsx | 11 +- .../src/components/ui/Avatar/AvatarGroup.tsx | 18 +- .../components/ui/Avatar/ChannelAvatar.tsx | 11 +- package/src/components/ui/Avatar/constants.ts | 4 +- .../components/ui/Badge/BadgeNotification.tsx | 5 +- package/src/components/ui/Button/Button.tsx | 45 +-- .../ui/Button/hooks/useButtonStyles.ts | 9 +- package/src/components/ui/GiphyChip.tsx | 1 - package/src/components/ui/Input/Input.tsx | 4 +- .../src/components/ui/SpeedSettingsButton.tsx | 12 +- .../AccessibilityContext.tsx | 48 ++-- .../AttachmentPickerContext.tsx | 19 +- .../bottomSheetContext/BottomSheetContext.tsx | 25 +- .../ChannelAddMembersContext.tsx | 21 +- .../channelContext/ChannelContext.tsx | 42 +-- .../channelDetailsContext.tsx | 4 +- .../ChannelEditDetailsContext.tsx | 35 +-- .../ChannelFileAttachmentListContext.tsx | 2 +- .../ChannelMediaListContext.tsx | 2 +- .../channelsContext/ChannelsContext.tsx | 52 +--- .../chatConfigContext/ChatConfigContext.tsx | 4 +- .../src/contexts/chatContext/ChatContext.tsx | 4 +- .../componentsContext/ComponentsContext.tsx | 33 +-- .../componentsContext/defaultComponents.ts | 39 +-- .../LiveLocationManagerContext.tsx | 2 - .../hooks/useHandleLiveLocationEvents.ts | 21 +- .../MessageComposerContext.tsx | 4 +- .../messageContext/MessageContext.tsx | 109 +++---- .../MessageInputContext.tsx | 114 +++----- .../hooks/useMessageComposer.ts | 2 + .../useMessageComposerHasSendableData.ts | 2 +- .../hooks/usePendingUploadsEnabled.ts | 8 +- .../MessageListItemContext.tsx | 17 +- .../messagesContext/MessagesContext.tsx | 246 ++++------------ .../ClosingPortalHostsLayer.tsx | 9 +- .../MessageOverlayHostLayer.tsx | 34 +-- .../overlayContext/OverlayContext.tsx | 7 +- .../overlayContext/OverlayHostLayerContext.ts | 2 +- .../overlayContext/OverlayProvider.tsx | 19 +- .../pollContext/createPollContentContext.tsx | 6 +- .../ScreenReaderContext.tsx | 12 +- .../contexts/themeContext/ThemeContext.tsx | 8 +- .../src/contexts/themeContext/utils/theme.ts | 5 +- .../contexts/threadContext/ThreadContext.tsx | 13 +- .../translationContext/TranslationContext.tsx | 10 +- .../src/contexts/translationContext/types.ts | 19 +- .../hooks/actions/useChannelActionItems.tsx | 39 +-- .../actions/useChannelMemberActionItems.tsx | 9 +- .../messagePreview/useGroupedAttachments.ts | 10 +- .../useMessageDeliveryStatus.ts | 17 +- .../messagePreview/useMessagePreviewText.tsx | 6 +- .../src/hooks/useAfterKeyboardOpenCallback.ts | 10 +- package/src/hooks/useAppStateListener.ts | 16 +- .../hooks/useAttachmentPickerBottomSheet.ts | 9 +- package/src/hooks/useAudioPlayer.ts | 13 +- .../src/hooks/useCanAddMembersToChannel.ts | 3 + package/src/hooks/useCanEditChannel.ts | 5 +- package/src/hooks/useChannel.ts | 9 +- package/src/hooks/useChannelImage.ts | 6 +- package/src/hooks/useChannelMemberCount.ts | 6 +- .../src/hooks/useChannelMembershipState.ts | 6 +- package/src/hooks/useChannelName.ts | 6 +- .../src/hooks/useChannelOwnCapabilities.ts | 6 +- package/src/hooks/useClientNotifications.ts | 6 +- package/src/hooks/useHorizontalInsets.ts | 26 +- package/src/hooks/useIsChannelMember.ts | 6 +- package/src/hooks/useIsMountedRef.ts | 10 +- package/src/hooks/useIsSvg.ts | 17 +- package/src/hooks/useIsTargetedMessage.ts | 14 +- package/src/hooks/useKeyboardVisibility.ts | 5 +- package/src/hooks/useLazyRef.ts | 6 +- package/src/hooks/useLoadingImage.tsx | 4 +- package/src/hooks/useMessagePaginator.ts | 6 +- .../src/hooks/usePendingAttachmentUpload.ts | 12 +- package/src/hooks/usePortalSettledCallback.ts | 58 +--- package/src/hooks/usePrunableMessageList.ts | 27 +- package/src/hooks/useQueryReminders.ts | 20 +- package/src/hooks/useRAFCoalescedValue.ts | 52 +--- package/src/hooks/useScreenDimensions.ts | 15 +- package/src/hooks/useScreenOrientation.ts | 6 +- package/src/hooks/useStableCallback.ts | 30 +- package/src/hooks/useStreami18n.ts | 8 +- package/src/hooks/useTranslatedMessage.ts | 16 +- package/src/hooks/useViewport.ts | 8 +- package/src/hooks/useWindowContentWidth.ts | 20 +- package/src/i18n/runtimeDefaults.ts | 45 ++- package/src/i18n/types.ts | 24 +- package/src/i18n/utils.ts | 36 +-- package/src/index.ts | 2 +- package/src/middlewares/attachments.ts | 7 +- package/src/middlewares/emojiControl.ts | 23 +- package/src/native.ts | 15 +- package/src/nativeMultipartUpload.ts | 8 +- package/src/polyfills.ts | 4 - package/src/state-store/audio-player.ts | 20 +- .../src/state-store/audio-recorder-manager.ts | 25 +- .../state-store/edit-channel-details-store.ts | 67 +++-- .../state-store/image-gallery-state-store.ts | 19 +- .../state-store/in-app-notifications-store.ts | 1 - .../src/state-store/message-overlay-store.ts | 54 ++-- package/src/state-store/selection-store.ts | 22 +- package/src/store/OfflineDB.ts | 18 +- package/src/store/SqliteClient.ts | 97 +++---- package/src/store/apis/addPendingTask.ts | 14 +- package/src/store/apis/channelExists.ts | 9 +- package/src/store/apis/dropPendingTasks.ts | 13 +- .../store/apis/getChannelActiveLocations.ts | 2 +- package/src/store/apis/getChannelMessages.ts | 3 - package/src/store/apis/getChannels.ts | 12 +- .../store/apis/getChannelsForFilterSort.ts | 15 +- .../src/store/apis/getDraftsForChannels.ts | 5 +- package/src/store/apis/getReactions.ts | 1 - .../store/apis/getReactionsforFilterSort.ts | 12 +- package/src/store/apis/insertReaction.ts | 7 +- .../selectActiveLocationsForChannels.ts | 3 +- .../selectChannelQueryForFilterSort.ts | 26 +- .../queries/selectReactionsForMessages.ts | 13 +- package/src/store/apis/upsertChannels.ts | 1 - package/src/store/apis/upsertCidsForQuery.ts | 1 - package/src/store/apis/upsertDraft.ts | 4 +- package/src/store/constants.ts | 7 +- .../src/store/mappers/mapPollToStorable.ts | 2 +- .../src/store/mappers/mapReadToStorable.ts | 2 +- .../src/store/mappers/mapStorableToPoll.ts | 5 +- .../store/mappers/mapStorableToTimestamp.ts | 16 +- .../store/mappers/mapTimestampToStorable.ts | 13 +- package/src/store/schema.ts | 5 +- .../store/sqlite-utils/createDeleteQuery.ts | 11 +- .../store/sqlite-utils/createSelectQuery.ts | 15 +- .../store/sqlite-utils/createUpdateQuery.ts | 14 +- .../store/sqlite-utils/createUpsertQuery.ts | 25 +- .../createUpsertQueryIfParentExists.ts | 38 +-- package/src/theme/topologicalResolution.ts | 21 +- package/src/types/react-native-compat.ts | 57 ++-- package/src/types/types.ts | 146 +++------- package/src/utils/attachmentUrls.ts | 10 +- package/src/utils/compressImage.ts | 17 +- package/src/utils/getResizedImageUrl.ts | 25 +- .../src/utils/getTrimmedAttachmentTitle.ts | 4 +- package/src/utils/getUrlOfImageAttachment.ts | 10 +- package/src/utils/i18n/Streami18n.ts | 78 ++---- .../utils/installNativeMultipartAdapter.ts | 18 +- .../utils/isRNVersionGreaterThanOrEqualTo.ts | 10 +- package/src/utils/react-native-constants.ts | 6 +- package/src/utils/rtlMirrorSwitchStyle.ts | 6 +- package/src/utils/utils.ts | 110 ++++---- 436 files changed, 2935 insertions(+), 5015 deletions(-) diff --git a/package/expo-package/src/native/NativeAIDictation.ts b/package/expo-package/src/native/NativeAIDictation.ts index b36c97e284..5416928a21 100644 --- a/package/expo-package/src/native/NativeAIDictation.ts +++ b/package/expo-package/src/native/NativeAIDictation.ts @@ -4,13 +4,13 @@ import { TurboModuleRegistry } from 'react-native'; /** * Codegen spec of the native dictation module. * - * This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIDictation.ts` - * and `package/expo-package/src/native/NativeAIDictation.ts`, which are the copies codegen reads. - * `yarn validate-entry-isolation` fails if they drift apart. + * Duplicated byte for byte in `package/native-package/src/native/NativeAIDictation.ts` and + * `package/expo-package/src/native/NativeAIDictation.ts`, the copies codegen reads; + * `yarn validate-entry-isolation` fails when they drift apart. * - * Events are delivered through `addListener`/`removeListeners` and a `NativeEventEmitter` (see - * `../native/AIDictation.ts`) rather than codegen `EventEmitter` properties, since the latter are - * not understood by the codegen of every React Native version the SDK supports (>= 0.76). + * Events go through `addListener`/`removeListeners` and a `NativeEventEmitter` (see + * `../native/AIDictation.ts`), because codegen `EventEmitter` properties are not understood by + * every React Native version the SDK supports. */ export type DictationStartOptions = { @@ -42,7 +42,6 @@ export interface Spec extends TurboModule { removeListeners(count: number): void; } -// `get` rather than `getEnforcing`: the module is only compiled in when dictation is enabled -// (always on Android, opt-in on iOS), and importing the AI entry point -// must not throw when it is absent. +// `get` returns null when absent: the module is only compiled in when dictation is enabled (always +// on Android, opt-in on iOS), and importing the AI entry point must not throw without it. export default TurboModuleRegistry.get('AIDictation'); diff --git a/package/native-package/src/native/NativeAIDictation.ts b/package/native-package/src/native/NativeAIDictation.ts index b36c97e284..5416928a21 100644 --- a/package/native-package/src/native/NativeAIDictation.ts +++ b/package/native-package/src/native/NativeAIDictation.ts @@ -4,13 +4,13 @@ import { TurboModuleRegistry } from 'react-native'; /** * Codegen spec of the native dictation module. * - * This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIDictation.ts` - * and `package/expo-package/src/native/NativeAIDictation.ts`, which are the copies codegen reads. - * `yarn validate-entry-isolation` fails if they drift apart. + * Duplicated byte for byte in `package/native-package/src/native/NativeAIDictation.ts` and + * `package/expo-package/src/native/NativeAIDictation.ts`, the copies codegen reads; + * `yarn validate-entry-isolation` fails when they drift apart. * - * Events are delivered through `addListener`/`removeListeners` and a `NativeEventEmitter` (see - * `../native/AIDictation.ts`) rather than codegen `EventEmitter` properties, since the latter are - * not understood by the codegen of every React Native version the SDK supports (>= 0.76). + * Events go through `addListener`/`removeListeners` and a `NativeEventEmitter` (see + * `../native/AIDictation.ts`), because codegen `EventEmitter` properties are not understood by + * every React Native version the SDK supports. */ export type DictationStartOptions = { @@ -42,7 +42,6 @@ export interface Spec extends TurboModule { removeListeners(count: number): void; } -// `get` rather than `getEnforcing`: the module is only compiled in when dictation is enabled -// (always on Android, opt-in on iOS), and importing the AI entry point -// must not throw when it is absent. +// `get` returns null when absent: the module is only compiled in when dictation is enabled (always +// on Android, opt-in on iOS), and importing the AI entry point must not throw without it. export default TurboModuleRegistry.get('AIDictation'); diff --git a/package/src/a11y/a11yUtils.ts b/package/src/a11y/a11yUtils.ts index a6fdfb027d..25ec58930a 100644 --- a/package/src/a11y/a11yUtils.ts +++ b/package/src/a11y/a11yUtils.ts @@ -1,15 +1,22 @@ /** - * Compose a single accessibility label from multiple parts. - * Empty/null/undefined parts are filtered out, the remainder joined with a comma+space - * so screen readers add a brief pause between segments. + * Joins the non-empty `parts` into one accessibility label, separated by commas so screen readers + * pause between segments. + * + * @param parts Label segments; empty strings and non-strings are dropped. + * @returns The joined label, or an empty string when no part remains. */ export const composeAccessibilityLabel = ( ...parts: Array ): string => parts.filter((p): p is string => typeof p === 'string' && p.length > 0).join(', '); /** - * Build the value object passed to `accessibilityValue` for progress/seek surfaces. - * Mirrors the React SDK's `progressBarA11y.ts` shape. + * Builds the `accessibilityValue` object for progress and seek surfaces. + * + * @param params.max Upper bound of the range. + * @param params.min Lower bound of the range. Defaults to 0. + * @param params.now Current value, clamped to `[min, max]`. + * @param params.text Optional spoken description, omitted from the result when empty. + * @returns The `accessibilityValue` object. */ export const formatAccessibilityValue = ({ max, @@ -31,11 +38,14 @@ export const formatAccessibilityValue = ({ return value; }; -/** - * Merge two `accessibilityActions` arrays, deduplicating by `name` (later wins). - */ type A11yAction = Readonly<{ name: string; label?: string }>; +/** + * Merges `accessibilityActions` lists, deduplicating by `name` (the later list wins). + * + * @param actionLists Lists of actions; `undefined` entries are skipped. + * @returns The merged actions, in first-seen order of their `name`. + */ export const mergeAccessibilityActions = ( ...actionLists: Array | undefined> ): A11yAction[] => { diff --git a/package/src/a11y/hooks/useA11yLabel.ts b/package/src/a11y/hooks/useA11yLabel.ts index 1562724512..ba4608c4ec 100644 --- a/package/src/a11y/hooks/useA11yLabel.ts +++ b/package/src/a11y/hooks/useA11yLabel.ts @@ -5,20 +5,22 @@ import { TranslationContext } from '../../contexts/translationContext/Translatio import { asDynamicKey } from '../../i18n/utils'; /** - * Returns the translated `a11y/...` label when the AccessibilityContext is enabled, - * or `undefined` when disabled. Components pass the result straight to - * `accessibilityLabel` so the i18n lookup is skipped on hot list paths in the - * default disabled-state. + * Returns the translated accessibility label for `key`, or `undefined` when accessibility is + * disabled, so the i18n lookup is skipped on hot list paths. * * Example: * const labelParams = useMemo(() => ({ name }), [name]); - * const label = useA11yLabel('a11y/Avatar of {{name}}', labelParams); + * const label = useA11yLabel('avatar.accessibilityLabel', labelParams); * + * + * @param key Translation key, such as `avatar.accessibilityLabel`. + * @param params Interpolation values for the translation. + * @returns The label to pass to `accessibilityLabel`. */ export const useA11yLabel = (key: string, params?: Record): string | undefined => { const { enabled } = useAccessibilityContext(); const { t } = useContext(TranslationContext); if (!enabled || !key) return undefined; - // `key` is a caller-supplied string, so this is the deliberate runtime-key escape hatch. + // `key` is a caller-supplied string, so it bypasses the typed translation keys. return t(asDynamicKey(key), params); }; diff --git a/package/src/a11y/hooks/useAccessibilityActivateAction.ts b/package/src/a11y/hooks/useAccessibilityActivateAction.ts index df288cdc27..ae9a0ae0cc 100644 --- a/package/src/a11y/hooks/useAccessibilityActivateAction.ts +++ b/package/src/a11y/hooks/useAccessibilityActivateAction.ts @@ -19,10 +19,13 @@ const accessibilityActivateActions: NonNullable({ onPress, diff --git a/package/src/a11y/hooks/useAccessibilityServiceEnabled.ts b/package/src/a11y/hooks/useAccessibilityServiceEnabled.ts index 58753d03a4..3ef15e4eca 100644 --- a/package/src/a11y/hooks/useAccessibilityServiceEnabled.ts +++ b/package/src/a11y/hooks/useAccessibilityServiceEnabled.ts @@ -4,22 +4,13 @@ import { AccessibilityInfo, AppState, Platform } from 'react-native'; import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; /** - * Subscribes to Android accessibility service availability and returns the live state. + * Tracks whether any Android accessibility service is running (TalkBack, Switch Access, Voice + * Access and so on, not only screen readers). * - * `AccessibilityInfo.isAccessibilityServiceEnabled` reports whether ANY a11y service is - * running - TalkBack, Switch Access, Voice Access, Select to Speak, etc. not just - * screen readers. The signal is Android only; iOS doesn't expose it (and doesn't need to, - * since the SDK's iOS overlay trap uses `accessibilityViewIsModal`). + * React Native emits no change event for this signal, so the state is refreshed on + * `screenReaderChanged` and when the app returns to the foreground. * - * React Native does not emit a dedicated change event for this signal, so the hook - * re-polls when the app returns to the foreground - which is the realistic path for a - * user toggling Accessibility settings and coming back to the app. The most common - * single service change (TalkBack on/off) is also covered by the `screenReaderChanged` - * listener. - * - * Returns false on non-Android platforms without subscribing. - * Returns false when the `AccessibilityContext` is disabled so consumers don't pay the - * listener cost when the SDK's a11y is opted out. + * @returns Whether a service is running. Always false on iOS and when accessibility is disabled. */ export const useAccessibilityServiceEnabled = (): boolean => { const { enabled } = useAccessibilityContext(); @@ -39,7 +30,7 @@ export const useAccessibilityServiceEnabled = (): boolean => { if (!cancelled) setIsEnabled(value); }) .catch(() => { - // Older RN versions / certain environments may not implement this; fall back to false. + // The API may be unavailable in older RN versions; stay false. }); }; diff --git a/package/src/a11y/hooks/useAnnounceOnShow.ts b/package/src/a11y/hooks/useAnnounceOnShow.ts index d186c9af1b..1ac60f6e16 100644 --- a/package/src/a11y/hooks/useAnnounceOnShow.ts +++ b/package/src/a11y/hooks/useAnnounceOnShow.ts @@ -9,17 +9,14 @@ type Options = { }; /** - * Announces `message` once each time `visible` flips from false to true. - * Resets when `visible` flips back to false, so the next show re-announces — - * unlike `useAnnounceOnStateChange`, which announces on string change and - * dedupes consecutive identical strings. + * Announces `message` each time `visible` flips from false to true, for transient surfaces such as + * modals and bottom sheets. Hiding the surface re-arms the announcement. Does nothing while + * `message` is `undefined`. * - * Use this for transient surfaces that appear and disappear repeatedly within - * a session (modals, autocomplete pickers, bottom sheets) where the user - * benefits from hearing the affordance on every reappearance. - * - * When `message` is undefined (typically because `useA11yLabel` returned - * undefined — a11y disabled or key missing), the hook is a no-op. + * @param visible Whether the surface is shown. + * @param message Text to announce. + * @param params.delayMs Delay before announcing. Defaults to 500. + * @param params.priority Announcement priority. Defaults to `polite`. */ export const useAnnounceOnShow = ( visible: boolean, diff --git a/package/src/a11y/hooks/useAnnounceOnStateChange.ts b/package/src/a11y/hooks/useAnnounceOnStateChange.ts index 5c860494e9..c8160b4209 100644 --- a/package/src/a11y/hooks/useAnnounceOnStateChange.ts +++ b/package/src/a11y/hooks/useAnnounceOnStateChange.ts @@ -11,11 +11,12 @@ type UseAnnounceOnStateChangeOptions = { }; /** - * Announces `message` whenever it changes (and is non-empty), with a debounce - * to avoid spamming the screen reader on rapid transitions. + * Announces `message` whenever it changes to a new non-empty value, debounced so rapid transitions + * do not flood the screen reader. Repeating the last announced message is skipped. * - * Used by `AITypingIndicatorView` ("Thinking…" → "Generating…" → idle) and - * the `Indicators` family (loading → loaded → error). + * @param message Text to announce. + * @param options.debounceMs Debounce before announcing. Defaults to 250. + * @param options.priority Announcement priority. Defaults to `polite`. */ export const useAnnounceOnStateChange = ( message: string | null | undefined, diff --git a/package/src/a11y/hooks/useReducedMotionPreference.ts b/package/src/a11y/hooks/useReducedMotionPreference.ts index 8096d03f4b..7364c6cc51 100644 --- a/package/src/a11y/hooks/useReducedMotionPreference.ts +++ b/package/src/a11y/hooks/useReducedMotionPreference.ts @@ -4,8 +4,9 @@ import { AccessibilityInfo } from 'react-native'; import { useAccessibilityContext } from '../../contexts/accessibilityContext/AccessibilityContext'; /** - * Subscribes to AccessibilityInfo reduce-motion changes and returns the live state. - * Returns false when the AccessibilityContext is disabled. + * Tracks the OS reduce-motion setting. + * + * @returns Whether reduce motion is on. Always false when accessibility is disabled. */ export const useReducedMotionPreference = (): boolean => { const { enabled } = useAccessibilityContext(); @@ -23,7 +24,7 @@ export const useReducedMotionPreference = (): boolean => { if (!cancelled) setReduceMotion(value); }) .catch(() => { - // Older RN or platforms without the API. + // The API may be unavailable on some platforms; stay false. }); const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion); diff --git a/package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts b/package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts index b243cbe2e7..003a8a55cb 100644 --- a/package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts +++ b/package/src/a11y/hooks/useResolvedModalAccessibilityProps.ts @@ -9,15 +9,11 @@ export type ResolvedModalAccessibilityProps = { }; /** - * Returns the platform-appropriate set of a11y props for a modal/sheet root. - * Equivalent of stream-chat-react's `useResolvedModalAriaProps` — but aware of - * RN's iOS-vs-Android split: - * - iOS uses `accessibilityViewIsModal` to trap focus. - * - Android uses `importantForAccessibility="yes"` on the modal root and - * `"no-hide-descendants"` on background siblings (caller's responsibility). + * Returns the accessibility props for a modal or sheet root: `accessibilityViewIsModal` on iOS + * to trap focus, `importantForAccessibility="yes"` on Android. On Android the caller must also set + * `"no-hide-descendants"` on background siblings. * - * Returns an empty object when AccessibilityContext is disabled, so the modal - * stays a no-op for integrators that haven't opted in. + * @returns The props to spread on the root, or an empty object when accessibility is disabled. */ export const useResolvedModalAccessibilityProps = (): ResolvedModalAccessibilityProps => { const { enabled } = useAccessibilityContext(); diff --git a/package/src/a11y/hooks/useScreenReaderEnabled.ts b/package/src/a11y/hooks/useScreenReaderEnabled.ts index 23780f00b6..055ccbcdff 100644 --- a/package/src/a11y/hooks/useScreenReaderEnabled.ts +++ b/package/src/a11y/hooks/useScreenReaderEnabled.ts @@ -2,12 +2,10 @@ import { useAccessibilityContext } from '../../contexts/accessibilityContext/Acc import { useScreenReaderContext } from '../../contexts/screenReaderContext/ScreenReaderContext'; /** - * Returns the live screen-reader state from the app-wide {@link ScreenReaderContext}. - * Returns false when the AccessibilityContext is disabled, regardless of the OS state, - * so consumers don't pay the listener cost when the SDK's a11y is opted out. + * Reads the screen-reader state from the app-wide {@link ScreenReaderContext}. * - * `forceScreenReaderMode: true` in the config short-circuits to true (used in tests - * and for integrator preview). + * @returns Whether a screen reader is on. False when accessibility is disabled, whatever the OS + * reports; true when `forceScreenReaderMode` is set in the accessibility config. */ export const useScreenReaderEnabled = (): boolean => { const { enabled, forceScreenReaderMode } = useAccessibilityContext(); diff --git a/package/src/a11y/hooks/useScreenReaderMountFocus.ts b/package/src/a11y/hooks/useScreenReaderMountFocus.ts index 9ecceaf39f..c56ac8c8d2 100644 --- a/package/src/a11y/hooks/useScreenReaderMountFocus.ts +++ b/package/src/a11y/hooks/useScreenReaderMountFocus.ts @@ -7,9 +7,10 @@ import { } from './useSetAccessibilityFocus'; /** - * Moves the screen reader cursor onto `target` as it mounts (and refires once the - * screen reader state resolves, since that is detected asynchronously). Like - * {@link useSetAccessibilityFocus}, it only moves accessibility focus. + * Moves the screen reader cursor onto `target` on mount, and again once the asynchronously detected + * screen reader state resolves. Like {@link useSetAccessibilityFocus}, it only moves focus. + * + * @param target The ref or native node handle to focus. */ export const useScreenReaderMountFocus = (target: AccessibilityFocusTarget) => { const setAccessibilityFocus = useSetAccessibilityFocus(); diff --git a/package/src/a11y/hooks/useSetAccessibilityFocus.ts b/package/src/a11y/hooks/useSetAccessibilityFocus.ts index 1a10dbded2..bc5ffb5321 100644 --- a/package/src/a11y/hooks/useSetAccessibilityFocus.ts +++ b/package/src/a11y/hooks/useSetAccessibilityFocus.ts @@ -5,10 +5,7 @@ import { useScreenReaderEnabled } from './useScreenReaderEnabled'; type FindNodeHandleArg = Parameters[0]; -/** - * Something the screen reader cursor can be moved onto: a ref (whose `.current` is - * read at call time), a raw native node handle, or nothing. - */ +/** A ref (its `.current` is read at call time), a native node handle, or nothing. */ export type AccessibilityFocusTarget = RefObject | number | null | undefined; const resolveNode = (target: AccessibilityFocusTarget): number | null => { @@ -23,29 +20,19 @@ const resolveNode = (target: AccessibilityFocusTarget): number | null => { }; /** - * Returns a stable callback that moves the screen reader cursor onto a given - * target (a ref or a raw node handle). It ONLY moves accessibility focus and it - * does not activate the target: i.e no keyboard opens and no field becomes first - * responder. The user still double taps to act on whatever is focused. - * - * The target's `.current` is read at call time, so this is safe to wire into a - * deferred/event callback. + * Returns a stable callback that moves the screen reader cursor onto a target on the next frame. + * It only moves accessibility focus: it opens no keyboard and does not activate the target. + * Does nothing unless a screen reader is running (see {@link useScreenReaderEnabled}). * - * Noop unless a screen reader is running. {@link useScreenReaderEnabled} returns - * false whenever the SDK's accessibility config is disabled, so this costs nothing - * for the default (a11y opted out) configuration. + * Focus set on mount only sticks on Android forward navigation. On iOS forward navigation and on + * back navigation, set it after the screen transition completes (React Navigation's + * `transitionEnd`); {@link useScreenReaderMountFocus} covers the mount case. * - * Note: Navigation timing is platform nuanced: Android forward navigation lands a focus - * set on mount, whereas iOS forward navigation and back navigation on both - * platforms need it set after the screen transition completes (React Navigation's - * `transitionEnd`). {@link useScreenReaderMountFocus} covers the mount case; wire - * `transitionEnd` yourself for the rest (the SampleApp's - * `useScreenReaderComposerFocusEffect` is a reference implementation). + * @returns A callback taking the {@link AccessibilityFocusTarget} to focus. */ export const useSetAccessibilityFocus = () => { const screenReaderEnabled = useScreenReaderEnabled(); - // Track the latest value so the returned callback can stay referentially stable - // (it's typically wired into a focus effect and read much later). + // A ref keeps the returned callback referentially stable while reading the latest value. const screenReaderEnabledRef = useRef(screenReaderEnabled); screenReaderEnabledRef.current = screenReaderEnabled; @@ -71,7 +58,7 @@ export const useSetAccessibilityFocus = () => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); } - // Defer a frame so the target has laid out before we move the cursor onto it. + // Defer a frame so the target has laid out before the cursor moves onto it. rafRef.current = requestAnimationFrame(() => { rafRef.current = null; AccessibilityInfo.setAccessibilityFocus(node); diff --git a/package/src/ai-components/AITypingIndicatorView.tsx b/package/src/ai-components/AITypingIndicatorView.tsx index ac230bf6f8..f9bc401d10 100644 --- a/package/src/ai-components/AITypingIndicatorView.tsx +++ b/package/src/ai-components/AITypingIndicatorView.tsx @@ -13,8 +13,7 @@ export const AITypingIndicatorView = ({ text }: { text: string }) => { } = useTheme(); const styles = useStyles(); - // The native shimmer paints behind its children, so it is laid over the text instead: the - // highlight has to sweep across the glyphs, which a transparent background would otherwise hide. + // The native shimmer paints behind its children, so it overlays the text. return ( {text} @@ -35,7 +34,7 @@ const useStyles = () => { return useMemo( () => StyleSheet.create({ - // The native highlight band is soft and wide, so it is kept fairly strong over the text. + // The native highlight band is soft and wide, so the opacity stays high. shimmer: { opacity: 0.8 }, text: { fontSize: 16, diff --git a/package/src/ai-components/charts/components/PieLegend.tsx b/package/src/ai-components/charts/components/PieLegend.tsx index dcf8dd0cc6..9191674642 100644 --- a/package/src/ai-components/charts/components/PieLegend.tsx +++ b/package/src/ai-components/charts/components/PieLegend.tsx @@ -37,7 +37,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 6, - maxWidth: '48%', // lets two items sit per row, tweak as needed + maxWidth: '48%', // Two items per row. }, swatch: { borderRadius: 2, diff --git a/package/src/ai-components/charts/components/VictoryChart.tsx b/package/src/ai-components/charts/components/VictoryChart.tsx index fa728c7449..a4ba35674a 100644 --- a/package/src/ai-components/charts/components/VictoryChart.tsx +++ b/package/src/ai-components/charts/components/VictoryChart.tsx @@ -2,16 +2,7 @@ import React from 'react'; import { Platform, View } from 'react-native'; import { LinearGradient, matchFont, vec } from '@shopify/react-native-skia'; -import { - Area, - Bar, - CartesianChart, - Line, - Pie, - PolarChart, - Scatter, - // useChartTransformState, -} from 'victory-native'; +import { Area, Bar, CartesianChart, Line, Pie, PolarChart, Scatter } from 'victory-native'; import { PieLegend } from './PieLegend'; @@ -40,11 +31,6 @@ const DEFAULT_WIDTH = 225; export const VictoryChart = (props: VictoryChartProps) => { const { spec, height = DEFAULT_HEIGHT, width = DEFAULT_WIDTH } = props; - // const { state: transformState } = useChartTransformState({ - // scaleX: 1.5, - // scaleY: 1.0, - // }); - if (spec.type === 'pie') { if (!spec?.data.length) return null; return ( @@ -128,11 +114,6 @@ export const VictoryChart = (props: VictoryChartProps) => { }} yAxis={[{ font }]} padding={{ bottom: hasLongLabels ? 2 * maxTickCharCount + 12 : 0 }} - // transformState={transformState} - // transformConfig={{ - // pinch: { enabled: false }, - // pan: { enabled: true, dimensions: 'x', activateAfterLongPress: 10 }, - // }} > {({ points, chartBounds }) => ( <> diff --git a/package/src/ai-components/charts/index.ts b/package/src/ai-components/charts/index.ts index 2eb7939f6d..979b369c6b 100644 --- a/package/src/ai-components/charts/index.ts +++ b/package/src/ai-components/charts/index.ts @@ -1,13 +1,9 @@ -// components export * from './Chart'; export * from './components'; -// schemas and parsers export * from './vega-lite'; export * from './mermaid'; -// utils export * from './utils'; -// types export * from './types'; diff --git a/package/src/ai-components/charts/types.ts b/package/src/ai-components/charts/types.ts index 889599a389..4952ed4d7c 100644 --- a/package/src/ai-components/charts/types.ts +++ b/package/src/ai-components/charts/types.ts @@ -1,13 +1,12 @@ export type Dim = string; export type Datum = { - /** The categorical/temporal dimension (label, category, or time). */ + /** The categorical or temporal dimension: a label, category or time. */ dimension: Dim; - /** The numeric measure/value. */ value: number; - /** Color for this datum; the renderer will fall back to series color. */ + /** Color of this datum. In non-pie charts, datums sharing a color form one series. */ color: string; - /** Optional series key for multi-series charts (e.g., region). */ + /** Optional series key for multi-series charts. */ series?: string; }; diff --git a/package/src/ai-components/charts/utils/generate-pie-colors.ts b/package/src/ai-components/charts/utils/generate-pie-colors.ts index 8eccf723a5..7314fa2eac 100644 --- a/package/src/ai-components/charts/utils/generate-pie-colors.ts +++ b/package/src/ai-components/charts/utils/generate-pie-colors.ts @@ -1,5 +1,4 @@ /* eslint-disable no-bitwise -- FNV-1a hashing and xorshift PRNG are bitwise by definition */ -// hashing and rng export const hashLabel = (raw: string) => { const s = raw.trim().toLowerCase().normalize('NFKC'); let h = 2166136261 >>> 0; // FNV-1a @@ -20,7 +19,6 @@ export const xorshift32 = (seed: number) => { }; }; -// color utils export const hslToHex = (h: number, s = 68, l = 52) => { const S = s / 100, L = l / 100; @@ -51,7 +49,8 @@ export const hslToHex = (h: number, s = 68, l = 52) => { return `#${r}${g}${b}`; }; -// ensure hues not closer than MIN_SEP degrees within the same chart +// Derives a stable color from `label`. Its hue is nudged away from `usedHues` (by at least +// `minSep` degrees, over at most 48 attempts) and then appended to `usedHues`. export const colorFromLabel = ( label: string, usedHues: number[], @@ -61,10 +60,9 @@ export const colorFromLabel = ( const seed = hashLabel(label); const rand = xorshift32(seed); - // base hue from RNG (looks random, but stable) + // Seeded by the label, so the base hue is stable. let hue = Math.floor(rand() * 360); - // nudge away from already used hues in THIS chart let attempts = 0; const tooClose = (h: number) => usedHues.some((u) => { diff --git a/package/src/ai-components/charts/utils/parse-json-chart.ts b/package/src/ai-components/charts/utils/parse-json-chart.ts index 8c28bba2ec..94acd2d58a 100644 --- a/package/src/ai-components/charts/utils/parse-json-chart.ts +++ b/package/src/ai-components/charts/utils/parse-json-chart.ts @@ -8,7 +8,7 @@ export const parseJsonChart = (code: string): ChartSpec => { const parsedVegaLite = VegaLiteSchema.parse(json); return parseVegalite(parsedVegaLite); } catch (_error) { - /* do nothing */ + // Not a supported chart spec: fall through to the error below. } throw new Error('Unknown type of JSON formatted chart.'); diff --git a/package/src/ai-components/charts/vega-lite/schema.ts b/package/src/ai-components/charts/vega-lite/schema.ts index 2f86626e7b..17deb24db5 100644 --- a/package/src/ai-components/charts/vega-lite/schema.ts +++ b/package/src/ai-components/charts/vega-lite/schema.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -/* primitives */ export const VLType = z.enum(['quantitative', 'ordinal', 'nominal', 'temporal']); const FieldRef = z.object({ @@ -14,29 +13,13 @@ const ColorXY = z.object({ }); const ColorPie = z.object({ field: z.string(), type: VLType.optional() }); -/* we support only these currently */ -const MarkName = z.enum([ - 'bar', - 'line', - 'area', - 'point', - 'arc', - // 'rect', - // 'rule', - // 'text', - // 'tick', - // 'circle', - // 'square', - // 'trail', - // 'geoshape', - // 'image', -]); +// Supported mark types. +const MarkName = z.enum(['bar', 'line', 'area', 'point', 'arc']); const MarkDef = z.looseObject({ type: z.union([MarkName, z.string()]).optional(), }); -/* encodings */ const XyEncoding = z.object({ x: FieldRef, y: FieldRef, @@ -48,7 +31,6 @@ const PieEncoding = z.object({ color: ColorPie.optional(), }); -/* base spec */ const BaseVL = z.object({ archetype: z.literal('vegalite'), $schema: z.string().optional(), @@ -61,7 +43,6 @@ const BaseVL = z.object({ markName: MarkName, }); -/* branches (with explicit kind) */ const VL_XY = BaseVL.extend({ type: z.literal('xy'), encoding: XyEncoding, @@ -97,7 +78,8 @@ const deriveMarkName = (o: any): string | undefined => { export const VegaLiteCore = z.discriminatedUnion('type', [VL_XY, VL_PIE]); -/* final schema: preprocess to inject needed metadata, then discriminated union */ +// Injects `type` (`pie` for an `arc` mark or a `theta` encoding, else `xy`), `archetype` and +// `markName`, then validates against the discriminated union. export const VegaLiteSchema = z.preprocess((raw) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped JSON input, validated by the zod schema below const o: any = raw ?? {}; diff --git a/package/src/ai-components/components/BottomSheet.tsx b/package/src/ai-components/components/BottomSheet.tsx index 4268db2e95..65c4390b67 100644 --- a/package/src/ai-components/components/BottomSheet.tsx +++ b/package/src/ai-components/components/BottomSheet.tsx @@ -75,7 +75,7 @@ export function BottomSheet({ children }: Props) { : next; }) .onEnd((e) => { - // very basic momentum bias + // Snap to the nearest end, biased by the release velocity. const projected = e.translationY + e.velocityY * 0.15; const nearest = projected >= bottomSheetHeight * 0.5 ? maxTranslateY : bottomSheetY; translateY.value = withTiming(nearest, SPRING, (finished) => { diff --git a/package/src/ai-components/index.ts b/package/src/ai-components/index.ts index c0eec23884..0333071a83 100644 --- a/package/src/ai-components/index.ts +++ b/package/src/ai-components/index.ts @@ -1,4 +1,3 @@ -// primitives export * from './markdown'; export * from './syntax-highlighting'; export * from './charts'; @@ -6,24 +5,18 @@ export * from './message-composer'; export * from './StreamingMessageView'; export * from './AITypingIndicatorView'; -// components export * from './components'; -// contexts export * from './contexts'; -// services export * from './services'; export * from './transcription/useDictation'; export { isDictationAvailable } from './native/AIDictation'; -// state-stores export * from './store'; -// theme export * from './theme'; -// Native handler registration, used by the `stream-chat-react-native/ai-components` and -// `stream-chat-expo/ai-components` entry points to register only the native pieces this entry -// point needs (without importing, and therefore bundling, the main SDK entry point). +// Lets the `stream-chat-react-native/ai-components` and `stream-chat-expo/ai-components` entry +// points register only the native pieces needed here, without bundling the main SDK entry point. export { registerNativeHandlers } from '../native'; diff --git a/package/src/ai-components/internal/hooks/useStableCallback.ts b/package/src/ai-components/internal/hooks/useStableCallback.ts index 8ef86ffb05..3056b167df 100644 --- a/package/src/ai-components/internal/hooks/useStableCallback.ts +++ b/package/src/ai-components/internal/hooks/useStableCallback.ts @@ -3,27 +3,14 @@ import { useCallback, useRef } from 'react'; export type StableCallback = (...args: A) => R; /** - * A utility hook implementing a stable callback. It takes in an unstable method that - * is supposed to be invoked somewhere deeper in the DOM tree without making it - * change its reference every time the parent component rerenders. It will also return - * the value of the callback if it does return one. - * A common use-case would be having a function whose invocation depends on state - * somewhere high up in the DOM tree and wanting to use the same function deeper - * down, for example in a leaf node and simply using useCallback results in - * cascading dependency hell. If we wrap it in useStableCallback, we would be able - * to: - * - Use the same function as a dependency of another hook (since it is stable) - * - Still invoke it and get the latest state + * Returns a function with a stable identity that always calls the latest `callback`, so it is safe + * as a hook dependency without stale closures. * - * **Caveats:** - * - Never wrap a function that is supposed to return a React.ReactElement in - * useStableCallback, since React will not know that the DOM needs to be updated - * whenever the callback value changes (for example, renderItem from FlatList must - * never be wrapped in this hook) - * - Always prefer using a standard useCallback/stable function wherever possible - * (the purpose of useStableCallback is to bridge the gap between top level contexts - * and cascading rereders in downstream components - **not** as an escape hatch) - * @param callback - the callback we want to stabilize + * Do not wrap functions that return elements (such as a list render function): React cannot tell + * when their output should change. Prefer a plain `useCallback` where it suffices. + * + * @param callback The function to stabilize. + * @returns The stable function, returning whatever `callback` returns. */ export const useStableCallback = ( callback: StableCallback, diff --git a/package/src/ai-components/internal/icons/SendUp.tsx b/package/src/ai-components/internal/icons/SendUp.tsx index 5bd09033b9..d412120505 100644 --- a/package/src/ai-components/internal/icons/SendUp.tsx +++ b/package/src/ai-components/internal/icons/SendUp.tsx @@ -10,7 +10,6 @@ type Props = IconProps & { export const SendUp = ({ size, ...rest }: Props) => ( - {/**/} ; } } catch (_e) { - /* do nothing */ + // Not a renderable chart: fall back to the code block below. } } @@ -75,7 +75,7 @@ export const CodeBlock = React.memo(({ styles, node }: MarkdownComponentProps) = return ; } } catch (_e) { - /* do nothing */ + // Not a renderable chart: fall back to the code block below. } } diff --git a/package/src/ai-components/markdown/components/Table.tsx b/package/src/ai-components/markdown/components/Table.tsx index def5c82a62..9cb4987cdb 100644 --- a/package/src/ai-components/markdown/components/Table.tsx +++ b/package/src/ai-components/markdown/components/Table.tsx @@ -18,9 +18,8 @@ export const Table = ({ node, output, styles, state }: MarkdownComponentProps) = ); const transpose = (matrix: SingleASTNode[][]) => - // TS gets confused because it considers the matrix to be potentially ragged, - // while the markdown parser can never output a ragged matrix of AST nodes - // when parsing a table. Hence, we use the forced type coercion. + // TS treats the matrix as potentially ragged, but the markdown parser never outputs a ragged + // table, so the non-null assertion is safe. (matrix[0] ?? []).map((_, colIndex) => matrix.map((row) => row[colIndex]!)); const MarkdownTable = ({ node, output, state, styles }: MarkdownOutputProps) => { diff --git a/package/src/ai-components/markdown/rules.ts b/package/src/ai-components/markdown/rules.ts index 6e39f7c85b..72ed13d94b 100644 --- a/package/src/ai-components/markdown/rules.ts +++ b/package/src/ai-components/markdown/rules.ts @@ -40,10 +40,7 @@ import type { } from './types'; const LINK_INSIDE = '(?:\\[(?:\\\\.|[^\\\\\\[\\]])*\\]|\\\\.|[^\\[\\]\\\\])*'; -/** - * Href + optional title (escape-aware). Group 1 === href (angle or bare). - * The title is in groups 2, 3 or 4. - */ +/** Href (group 1, angle-bracketed or bare) and an optional escape-aware title (groups 2 to 4). */ const LINK_HREF_AND_TITLE = '\\s*]*?)>?\\s*' + // 1: href '(?:' + @@ -61,7 +58,7 @@ export const getLocalRules = ( const { onLink, paragraphNumberOfLines } = opts; const openLinkHandler = (target: string) => { if (onLink) { - // user-supplied handler may be async; we keep the behavior + // `onLink` may be async: a rejection is logged and rethrown. Promise.resolve(onLink(target)).catch((error: unknown) => { const msg = error && typeof error === 'object' && 'toString' in error @@ -98,9 +95,8 @@ export const getLocalRules = ( let lang = (capture[2] || '').trim() || undefined; let content = capture[3] || ''; - // If we've found no lang on the fence line (the opening fence) but the first code line - // is a lone token, treat it as lang and strip it from content. Helps with some specific - // use-cases such as "```sql...". + // Without a language on the opening fence line, a lone-token first code line is taken as the + // language and stripped from the content, as in "```sql...". if (!lang) { const nl = content.indexOf('\n'); const firstLine = (nl === -1 ? content : content.slice(0, nl)).trim(); @@ -110,7 +106,7 @@ export const getLocalRules = ( } } - // Mirror your trimming behavior + // Drop trailing newlines. content = content.replace(/\n+$/, ''); return { type: 'codeBlock', lang, content }; @@ -139,7 +135,7 @@ export const getLocalRules = ( fence: { match: SimpleMarkdown.blockRegex( // 1: fence (``` or ~~~) - // 2: info string (lang etc.) - SAME CAPTURE INDEX AS YOURS + // 2: info string (lang etc.) // 3: code content /^ {0,3}(`{3,}|~{3,})[ \t]*(\S+)?[ \t]*\r?\n([\s\S]*?)\r?\n?(?: {0,3})\1[ \t]*(?:\r?\n+|$)/, ), @@ -159,7 +155,7 @@ export const getLocalRules = ( react: enrichedRenderFunction(renderHorizontalRule), }, image: { - // We intentionally disable parsing images; keep the shape + // Images are not parsed. match: () => null, }, inlineCode: { @@ -201,7 +197,7 @@ export const getLocalRules = ( react: enrichedRenderFunction(renderText), }, u: { - // no support for underlines yet + // Underlines are not supported. match: () => null, }, url: { @@ -209,7 +205,7 @@ export const getLocalRules = ( onLink: openLinkHandler, }), }, - // no support for reflinks + // Reference links and images are not supported. reflink: { match: () => null }, refimage: { match: () => null }, } as unknown as OutputRules; diff --git a/package/src/ai-components/markdown/styles.ts b/package/src/ai-components/markdown/styles.ts index d3517f36f4..a23706b896 100644 --- a/package/src/ai-components/markdown/styles.ts +++ b/package/src/ai-components/markdown/styles.ts @@ -58,7 +58,7 @@ export const getLocalStyles = ({ theme }: { theme: Theme }) => { codeBlockLineNumberCell: { fontFamily: Platform.OS === 'ios' ? 'Courier' : 'Monospace', color: colors.grey, - paddingVertical: 1, // should be (codeBlock.lineHeight - this.fontSize) / 2 + paddingVertical: 1, // (codeBlock.lineHeight - fontSize) / 2 fontSize: 12, }, codeBlockHeaderContainer: { diff --git a/package/src/ai-components/markdown/types.ts b/package/src/ai-components/markdown/types.ts index 276fa43fc8..42ddec10a1 100644 --- a/package/src/ai-components/markdown/types.ts +++ b/package/src/ai-components/markdown/types.ts @@ -71,7 +71,7 @@ export type TableNode = SingleASTNode & { }; export type TargetNode = SingleASTNode & { target: string }; -// Allow dynamic heading style access like styles["heading1"] +// Heading levels that index the `heading1` to `heading6` styles. export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6 | undefined; export type MarkdownStyle = Partial<{ diff --git a/package/src/ai-components/markdown/utils/generate-markdown-text.ts b/package/src/ai-components/markdown/utils/generate-markdown-text.ts index 28944b57b4..02b1e3dc93 100644 --- a/package/src/ai-components/markdown/utils/generate-markdown-text.ts +++ b/package/src/ai-components/markdown/utils/generate-markdown-text.ts @@ -7,15 +7,10 @@ export function escapeRegExp(text: string) { return text.replace(/[-[\]{}()*+?.,/\\^$|#]/g, '\\$&'); } -/** - * This is done to remove all markdown formatted links. - * eg: [google.com](https://www.google.com), [Google](https://www.google.com), [https://www.google.com](https://www.google.com) - * */ +/** Removes markdown links such as `[Google](https://www.google.com)` from `input`. */ const removeMarkdownLinksFromText = (input: string) => input.replace(/\[.*\]\(.*\)/g, ''); -/** - * This is done to avoid parsing usernames with dot as well as an email address in it. - */ +/** Removes `@name@domain.tld` mentions so a username with an email is not parsed as a link. */ const removeUserNamesWithEmailFromText = (input: string) => input.replace(/@(\w+(\.\w+)?)(@\w+\.\w+)/g, ''); @@ -33,7 +28,8 @@ export const parseLinksFromText = (input?: string): LinkInfo[] => { const result: LinkInfo[] = [...links, ...emails].map(({ href, value }) => { let hrefWithProtocol = href; - // Matching these: https://reactnative.dev/docs/0.73/linking?syntax=ios#built-in-url-schemes + // Hrefs without a built-in scheme get `http://`: + // https://reactnative.dev/docs/0.73/linking?syntax=ios#built-in-url-schemes const pattern = new RegExp(/^(mailto:|tel:|sms:|\S+:\/\/)/); if (!pattern.test(hrefWithProtocol)) { hrefWithProtocol = 'http://' + hrefWithProtocol; @@ -53,10 +49,8 @@ export const generateMarkdownText = (text?: string) => { return null; } - // Trim the extra spaces from the text. let resultText = text; - // List of all the links present in the text. const linkInfos = parseLinksFromText(resultText); for (const linkInfo of linkInfos) { @@ -64,14 +58,13 @@ export const generateMarkdownText = (text?: string) => { length: 200, omission: '...', }); - // Convert raw links/emails in the text to respective markdown syntax. - // Eg: Hi @getstream.io -> Hi @[getstream.io](getstream.io). + // Wrap each raw link or email in markdown link syntax. const normalRegEx = new RegExp(escapeRegExp(linkInfo.raw), 'g'); const markdown = `[${displayLink}](${linkInfo.url})`; resultText = resultText.replace(normalRegEx, markdown); - // After previous step, in some cases, the mentioned user after `@` might have a link/email so we convert it back to normal raw text. - // Eg: Hi, @[test.user@gmail.com](mailto:test.user@gmail.com) to @test.user@gmail.com. + // Undo it for a link right after `@`, so a mention stays plain text: + // `@[test.user@gmail.com](mailto:test.user@gmail.com)` becomes `@test.user@gmail.com`. const mentionsRegex = new RegExp( `@\\[${escapeRegExp(displayLink)}\\]\\(${escapeRegExp(linkInfo.url)}\\)`, 'g', @@ -79,33 +72,27 @@ export const generateMarkdownText = (text?: string) => { resultText = resultText.replace(mentionsRegex, `@${displayLink}`); } - // Remove whitespaces that come directly after newlines except in code blocks where we deem this allowed. - // A line starts a (possibly quoted) list item if, after indentation, it has - // - unordered bullet: -, *, + - // - or ordered marker: 1. 2. 10. - // and at least one space after the marker. + // Collapses indentation after a newline, except in code and before a list item (a `-`, `*`, `+` + // or `1.` marker followed by a space, at any nesting depth, optionally quoted with `>`). const LIST_START_RE = /^[ \t]*(?:> ?)*?(?:[*+-]|\d+\.)(?: |\t)(?:\[(?: |x|X)\] )?/; resultText = resultText.replace( // 1) fenced code blocks (``` ```) // 2) inline code (`…`) - // 3) newline + 2 + spaces/tabs + // 3) newline followed by 2 or more spaces/tabs /(```[\s\S]*?```|`[^`]*?`)|\n([ \t]{2,})/g, (match, code, spaces, offset, full) => { - // Preserve code spans / fences exactly as they are + // Code spans and fences are returned untouched. if (code) return code; - // Peek what follows this newline + the indent on the same line const after = full.slice(offset + match.length); - // If that line begins with a list item (any nesting depth, optional > quotes), - // keep the indentation; otherwise collapse to a single newline. return LIST_START_RE.test(after) ? `\n${spaces}` : '\n'; }, ); - // Always replace \n``` with \n\n``` to force the markdown state machine to treat it as a separate block. Otherwise, code blocks inside of list - // items for example were broken. We clean up the code block closing state within the rendering itself. + // A blank line before each fence makes the parser treat it as a separate block, which code blocks + // inside list items need. The renderer cleans up the closing state of the code block. resultText = resultText.replace(/\n```/g, '\n\n```\n'); const codeBlockCounts = (resultText.match(/```/g) || []).length; diff --git a/package/src/ai-components/message-composer/SpeechToTextButton.tsx b/package/src/ai-components/message-composer/SpeechToTextButton.tsx index 99336f7e35..1c359d5846 100644 --- a/package/src/ai-components/message-composer/SpeechToTextButton.tsx +++ b/package/src/ai-components/message-composer/SpeechToTextButton.tsx @@ -20,10 +20,7 @@ type SpeechToTextButtonProps = { options?: DictationStartOptions; }; -/** - * Renders nothing when the native dictation module is not part of the build (see - * `isDictationAvailable`), so that pressing the button can never reach a missing module. - */ +/** Renders nothing when the native dictation module is not part of the build. */ export const SpeechToTextButton = (props: SpeechToTextButtonProps) => { if (!isDictationAvailable()) { warnIfDictationUnavailable(); diff --git a/package/src/ai-components/native-specs/NativeAIDictation.ts b/package/src/ai-components/native-specs/NativeAIDictation.ts index b36c97e284..5416928a21 100644 --- a/package/src/ai-components/native-specs/NativeAIDictation.ts +++ b/package/src/ai-components/native-specs/NativeAIDictation.ts @@ -4,13 +4,13 @@ import { TurboModuleRegistry } from 'react-native'; /** * Codegen spec of the native dictation module. * - * This file is duplicated (byte for byte) in `package/native-package/src/native/NativeAIDictation.ts` - * and `package/expo-package/src/native/NativeAIDictation.ts`, which are the copies codegen reads. - * `yarn validate-entry-isolation` fails if they drift apart. + * Duplicated byte for byte in `package/native-package/src/native/NativeAIDictation.ts` and + * `package/expo-package/src/native/NativeAIDictation.ts`, the copies codegen reads; + * `yarn validate-entry-isolation` fails when they drift apart. * - * Events are delivered through `addListener`/`removeListeners` and a `NativeEventEmitter` (see - * `../native/AIDictation.ts`) rather than codegen `EventEmitter` properties, since the latter are - * not understood by the codegen of every React Native version the SDK supports (>= 0.76). + * Events go through `addListener`/`removeListeners` and a `NativeEventEmitter` (see + * `../native/AIDictation.ts`), because codegen `EventEmitter` properties are not understood by + * every React Native version the SDK supports. */ export type DictationStartOptions = { @@ -42,7 +42,6 @@ export interface Spec extends TurboModule { removeListeners(count: number): void; } -// `get` rather than `getEnforcing`: the module is only compiled in when dictation is enabled -// (always on Android, opt-in on iOS), and importing the AI entry point -// must not throw when it is absent. +// `get` returns null when absent: the module is only compiled in when dictation is enabled (always +// on Android, opt-in on iOS), and importing the AI entry point must not throw without it. export default TurboModuleRegistry.get('AIDictation'); diff --git a/package/src/ai-components/native/AIDictation.ts b/package/src/ai-components/native/AIDictation.ts index 450abae7c8..3d81c3cea4 100644 --- a/package/src/ai-components/native/AIDictation.ts +++ b/package/src/ai-components/native/AIDictation.ts @@ -23,9 +23,11 @@ const getEventEmitter = () => { const noop = () => {}; /** - * Whether the native dictation module is part of this build. It is always compiled in on Android, - * while on iOS it is opt-in: add `$StreamChatEnableDictation = true` to your - * `Podfile` (or use the `stream-chat-expo` config plugin) and run `pod install`. + * Whether the native dictation module is part of this build. It is always compiled in on Android; + * on iOS add `$StreamChatEnableDictation = true` to the `Podfile` (or use the `stream-chat-expo` + * config plugin) and run `pod install`. + * + * @returns True when the native module is present. */ export const isDictationAvailable = () => NativeAIDictation != null; @@ -42,7 +44,6 @@ export const warnIfDictationUnavailable = () => { ); }; -// Low-level API export const AIDictation = { start(options?: DictationStartOptions) { NativeAIDictation?.start(options); diff --git a/package/src/ai-components/services/clipboard-service/expo.ts b/package/src/ai-components/services/clipboard-service/expo.ts index 4d72eee31f..755027e699 100644 --- a/package/src/ai-components/services/clipboard-service/expo.ts +++ b/package/src/ai-components/services/clipboard-service/expo.ts @@ -3,7 +3,7 @@ let Clipboard: { setString: (string: string) => void } | undefined; try { Clipboard = require('expo-clipboard'); } catch (_) { - // do nothing + // `expo-clipboard` is not installed. } export const setClipboardString = Clipboard diff --git a/package/src/ai-components/services/clipboard-service/rncli.ts b/package/src/ai-components/services/clipboard-service/rncli.ts index a7d7ff705d..624b8e640f 100644 --- a/package/src/ai-components/services/clipboard-service/rncli.ts +++ b/package/src/ai-components/services/clipboard-service/rncli.ts @@ -3,7 +3,7 @@ let Clipboard: { setString: (string: string) => void } | undefined; try { Clipboard = require('@react-native-clipboard/clipboard').default; } catch (_) { - // do nothing + // `@react-native-clipboard/clipboard` is not installed. } export const setClipboardString = Clipboard diff --git a/package/src/ai-components/services/media-picker-service/expo.ts b/package/src/ai-components/services/media-picker-service/expo.ts index 53a99ff975..bf1425ab08 100644 --- a/package/src/ai-components/services/media-picker-service/expo.ts +++ b/package/src/ai-components/services/media-picker-service/expo.ts @@ -13,7 +13,7 @@ let internalService = undefined; try { internalService = require('expo-image-picker'); } catch (_error) { - /* do nothing */ + // `expo-image-picker` is not installed. } type Size = { @@ -91,7 +91,7 @@ class ExpoMediaPickerService extends AbstractMediaPickerService { if (!result || !result.assets || !result.assets.length || result.canceled) { return { cancelled: true }; } - // since we only support single photo upload for now we will only be focusing on 0'th element. + // Only a single photo is supported, so only the first asset is read. const photo = result.assets[0]; if (!photo) { return { cancelled: true }; diff --git a/package/src/ai-components/services/media-picker-service/optional-modules.d.ts b/package/src/ai-components/services/media-picker-service/optional-modules.d.ts index 03823285c8..632c43ece6 100644 --- a/package/src/ai-components/services/media-picker-service/optional-modules.d.ts +++ b/package/src/ai-components/services/media-picker-service/optional-modules.d.ts @@ -1,6 +1,6 @@ -// The image picker libraries are optional peer dependencies (`react-native-image-picker` for React -// Native CLI apps, `expo-image-picker` for Expo apps) and are not installed in this package. Only -// the asset shapes the media picker services read are declared here. +// Optional peer dependencies that are not installed in this package: `react-native-image-picker` +// (React Native CLI) and `expo-image-picker` (Expo). Only the asset shapes the media picker +// services read are declared. declare module 'react-native-image-picker' { export type Asset = { diff --git a/package/src/ai-components/services/media-picker-service/rncli.ts b/package/src/ai-components/services/media-picker-service/rncli.ts index 0d89b9efae..39f2db85e5 100644 --- a/package/src/ai-components/services/media-picker-service/rncli.ts +++ b/package/src/ai-components/services/media-picker-service/rncli.ts @@ -12,7 +12,7 @@ let internalService = undefined; try { internalService = require('react-native-image-picker'); } catch (_error) { - /* do nothing */ + // `react-native-image-picker` is not installed. } class RNCLIMediaPickerService extends AbstractMediaPickerService { @@ -96,7 +96,6 @@ class RNCLIMediaPickerService extends AbstractMediaPickerService { size.height = height; size.width = width; } catch (e) { - // do nothing console.warn('Error while getting image size of picture captured from camera ', e); } } else { @@ -122,10 +121,11 @@ class RNCLIMediaPickerService extends AbstractMediaPickerService { } } catch (e: unknown) { if (e instanceof Error) { - // on iOS: if it was in inactive state, then the user had just denied the permissions + // On iOS the permission dialog makes the app inactive, so an inactive state means the user + // just denied it; settings are offered only when the app is active. if (Platform.OS === 'ios' && AppState.currentState === 'active') { const cameraPermissionDeniedMsg = 'User did not grant camera permission.'; - // Open settings when the user did not allow camera permissions + // Offer to open settings when camera permission was denied. if (e.message === cameraPermissionDeniedMsg) { return { askToOpenSettings: true, cancelled: true }; } diff --git a/package/src/ai-components/store/index.ts b/package/src/ai-components/store/index.ts index f737510a79..1b8077634a 100644 --- a/package/src/ai-components/store/index.ts +++ b/package/src/ai-components/store/index.ts @@ -1,11 +1,8 @@ -// bottom sheet export * from './bottom-sheet/store'; export * from './bottom-sheet/useBottomSheetState'; -// message composer export * from './composer/store'; export * from './composer/useComposerHasText'; export * from './composer/useComposerText'; -// dictation export * from './dictation/store'; diff --git a/package/src/ai-components/syntax-highlighting/index.ts b/package/src/ai-components/syntax-highlighting/index.ts index f48e59c232..c19a70813e 100644 --- a/package/src/ai-components/syntax-highlighting/index.ts +++ b/package/src/ai-components/syntax-highlighting/index.ts @@ -1,7 +1,6 @@ /** - * This sub-library is a port/took inspiration from https://github.com/conorhastings/react-native-syntax-highlighter. - * We create our own since we don't want the version of https://github.com/react-syntax-highlighter/react-syntax-highlighter - * to be pinned to version 6 but rather use whatever we see fit. + * Adapted from https://github.com/conorhastings/react-native-syntax-highlighter. + * Maintained here so the `react-syntax-highlighter` dependency can be upgraded independently. */ export * from './SyntaxHighlighter'; diff --git a/package/src/ai-components/syntax-highlighting/types.ts b/package/src/ai-components/syntax-highlighting/types.ts index 0cce244fe6..e18a6c271e 100644 --- a/package/src/ai-components/syntax-highlighting/types.ts +++ b/package/src/ai-components/syntax-highlighting/types.ts @@ -17,10 +17,9 @@ export type NativeSyntaxHighlighterProps = SyntaxHighlighterProps & ExtraSyntaxH export type CSSP = React.CSSProperties; -/** Options for unit conversion */ export type ConvertOpts = { - /** base for `em` (defaults to 16) */ + /** Base for `em` units. Defaults to 16. */ baseFontSize?: number; - /** root base for `rem` (defaults to baseFontSize) */ + /** Base for `rem` units. Defaults to `baseFontSize`. */ rootFontSize?: number; }; diff --git a/package/src/ai-components/syntax-highlighting/utils/converter.ts b/package/src/ai-components/syntax-highlighting/utils/converter.ts index b211d82d81..c75486d201 100644 --- a/package/src/ai-components/syntax-highlighting/utils/converter.ts +++ b/package/src/ai-components/syntax-highlighting/utils/converter.ts @@ -4,10 +4,10 @@ import type { ConvertOpts, CSSP } from '../types'; const EM_DEFAULT = 16; -// React Native's strict TypeScript API declares style properties as readonly. +// Style properties are readonly in React Native's strict TypeScript API. type MutableTextStyle = { -readonly [K in keyof TextStyle]: TextStyle[K] }; -/** Parse CSS lengths like 14, "14", "14px", "1.25em", "0.875rem" */ +/** Parses a CSS length such as 14, "14px", "1.25em" or "0.875rem" into a number. */ const len = (v: unknown, { baseFontSize, rootFontSize }: ConvertOpts): number | undefined => { if (v == null) return undefined; if (typeof v === 'number') return v; @@ -27,43 +27,35 @@ const len = (v: unknown, { baseFontSize, rootFontSize }: ConvertOpts): number | const root = rootFontSize ?? baseFontSize ?? EM_DEFAULT; return Number.isFinite(n) ? n * root : undefined; } - // plain number-like string const n = Number(s); return Number.isFinite(n) ? n : undefined; }; -/** Font shorthand: e.g. "italic small-caps 700 14px/20px Menlo, monospace" */ +/** Parses a CSS `font` shorthand such as "italic small-caps 700 14px/20px Menlo, monospace". */ const parseFontShorthand = (v: string, opts: ConvertOpts) => { const out: MutableTextStyle = {}; const parts = v.split(/\s+/); - // Very light parser: pick out style, weight, size[/lineHeight], and family - // Strategy: - // - style: "normal|italic" - // - weight: "normal|bold|100..900" - // - when we hit a token like "14px" or "14px/20px" → size & optional lineHeight - // - everything after size token is font family (may contain commas/spaces) + // Reads in order: style, small-caps, weight, size[/lineHeight], then the family (everything after + // the size token, which may contain commas and spaces). let i = 0; - // style if (parts[i] === 'italic' || parts[i] === 'normal') { if (parts[i] !== 'normal') out.fontStyle = 'italic'; i++; } - // (skip small-caps if present; RN uses fontVariant which is separate) + // RN models `small-caps` as `fontVariant`. if (parts[i] === 'small-caps') { out.fontVariant = ['small-caps']; i++; } - // weight if (/^(normal|bold|[1-9]00)$/.test(parts[i] ?? '')) { const token = parts[i]!; out.fontWeight = (token === 'normal' ? undefined : token) as TextStyle['fontWeight']; i++; } - // size[/lineHeight] const sizeToken = parts[i]; if ( sizeToken && @@ -79,10 +71,9 @@ const parseFontShorthand = (v: string, opts: ConvertOpts) => { i++; } - // family const family = parts.slice(i).join(' ').replace(/^,|,$/g, '').trim(); if (family) { - // basic cleanup: take first family; strip quotes + // Only the first family is kept, without quotes. const first = family .split(',')[0] ?.trim() @@ -93,7 +84,13 @@ const parseFontShorthand = (v: string, opts: ConvertOpts) => { return out; }; -/** map CSS (web) to RN TextStyle */ +/** + * Converts web CSS properties to an RN `TextStyle`. + * + * @param css The CSS properties to convert. + * @param options Font size bases for `em` and `rem` units. + * @returns The equivalent `TextStyle`. + */ export const cssToRNTextStyle = (css: Partial, options: ConvertOpts = {}): TextStyle => { const opts: ConvertOpts = { baseFontSize: options.baseFontSize ?? EM_DEFAULT, @@ -102,7 +99,6 @@ export const cssToRNTextStyle = (css: Partial, options: ConvertOpts = {}): const out: MutableTextStyle = {}; - // Simple 1:1 or unit-converted mappings if (css.color) out.color = String(css.color); if (css.fontFamily) out.fontFamily = String(css.fontFamily); @@ -130,7 +126,7 @@ export const cssToRNTextStyle = (css: Partial, options: ConvertOpts = {}): Object.assign(out, parseFontShorthand(css.font, opts)); } - // Simulating the closest we can get to a CSS overflow-like functionality + // Maps the CSS overflow values to the closest RN `overflow`. const overflowLike = css.overflow ?? css.overflowX ?? css.overflowY; if (typeof overflowLike === 'string') { out.overflow = diff --git a/package/src/ai-components/syntax-highlighting/utils/flatten-nodes.ts b/package/src/ai-components/syntax-highlighting/utils/flatten-nodes.ts index 0fa33f1e06..c49e9dd9f2 100644 --- a/package/src/ai-components/syntax-highlighting/utils/flatten-nodes.ts +++ b/package/src/ai-components/syntax-highlighting/utils/flatten-nodes.ts @@ -31,7 +31,6 @@ export const flattenNodesToTextAndColorRanges = ({ const { type, tagName, value, properties } = node; - // leaf node if (type === 'text') { if (value == null || value === '') return; const chunk = String(value); @@ -45,7 +44,6 @@ export const flattenNodesToTextAndColorRanges = ({ return; } - // node with children if (tagName) { const baseStyle = properties && properties.className @@ -56,9 +54,7 @@ export const flattenNodesToTextAndColorRanges = ({ ) : { ...startingStyle, color: defaultColor }; - // we take the node's color if present, then its inherited color - // if present and finally the block's default color if all else - // fails. + // Color precedence: the node's own, then the inherited one, then the block's default. const effectiveColor = (baseStyle as { color?: string | number }).color ?? inheritedColor ?? defaultColor; @@ -70,7 +66,6 @@ export const flattenNodesToTextAndColorRanges = ({ return; } - // fallback if (value != null) { const chunk = String(value); const start = text.length; @@ -82,20 +77,17 @@ export const flattenNodesToTextAndColorRanges = ({ } }; - // Recursively flatten nodes const nodeArray = Array.isArray(nodes) ? nodes : [nodes]; for (let i = 0; i < nodeArray.length; i++) { walk(nodeArray[i], defaultColor); } - // Merge adjacent ranges with same color and convert them to integers + // Merges adjacent ranges of the same color and converts colors to integers. rawRanges.sort((a, b) => a.start - b.start); const merged: ColorRange[] = []; for (let i = 0; i < rawRanges.length; i++) { const r = rawRanges[i]!; - // in case the color is malformed for some reason, use the defaultColor - // as this one should always be non-nullish. - const colorInt = processColor(r.color)!; // ?? processColor(defaultColor))!; + const colorInt = processColor(r.color)!; if (!merged.length) { merged.push({ start: r.start, end: r.end, color: colorInt }); @@ -131,12 +123,12 @@ export const flattenRowsToTextAndColorRanges = ( const offset = fullText.length; fullText += text; - // Add newline between rows (no color) + // Rows are joined by an uncolored newline. if (rowIndex < rows.length - 1) { fullText += '\n'; } - // Shift ranges by current offset & push + // Shift the row ranges by its offset in the full text. for (const r of ranges) { fullRanges.push({ start: r.start + offset, diff --git a/package/src/components/AITypingIndicatorView/hooks/useAIState.ts b/package/src/components/AITypingIndicatorView/hooks/useAIState.ts index 9afe39c560..99406cb2bc 100644 --- a/package/src/components/AITypingIndicatorView/hooks/useAIState.ts +++ b/package/src/components/AITypingIndicatorView/hooks/useAIState.ts @@ -2,16 +2,16 @@ import { AIState, AIStates, Channel } from 'stream-chat'; import { useStateStore } from '../../../hooks/useStateStore'; -// Re-export the canonical `AIStates` from the LLC so existing imports keep working -// (`import { AIStates } from '.../AITypingIndicatorView'`). +// Re-exports `AIStates` from `stream-chat` so it can be imported from this module. export { AIStates }; const selector = (state: { aiState: AIState }) => ({ aiState: state.aiState }); /** - * A hook that returns the current state of the AI. - * @param {Channel} channel - The channel for which we want to know the AI state. - * @returns {{ aiState: AIState }} The current AI state for the given channel. + * Returns the AI state of `channel`, or the idle state when there is no channel state. + * + * @param channel The channel to read the AI state of. + * @returns The current AI state. */ export const useAIState = (channel?: Channel): { aiState: AIState } => useStateStore(channel?.state, selector) ?? { aiState: AIStates.Idle }; diff --git a/package/src/components/Accessibility/CompositeAccessibilityProbe.tsx b/package/src/components/Accessibility/CompositeAccessibilityProbe.tsx index 27acd8364c..e93d469689 100644 --- a/package/src/components/Accessibility/CompositeAccessibilityProbe.tsx +++ b/package/src/components/Accessibility/CompositeAccessibilityProbe.tsx @@ -5,31 +5,19 @@ import { HiddenA11yText } from './HiddenA11yText'; export type CompositeAccessibilityProbeProps = { /** - * The accessibility label that VoiceOver / TalkBack should announce for the - * wrapped content. When `undefined`, the probe is a no-op and the children - * render with no a11y modifications — use this to skip the wrapper when the - * SDK's a11y opt-in is off. + * Label that VoiceOver and TalkBack announce for the wrapped content. `undefined` renders the + * children unchanged. */ label: string | undefined; }; /** - * Wraps decorative visual content with a single, cross platform stable - * accessibility focus stop carrying the provided `label`. + * Gives decorative visual content (avatars, icons, composed graphics) a single screen reader focus + * stop that announces `label`, and hides the visual subtree from screen readers. * - * iOS auto collapses descendants when a parent View is `accessible`, but on - * Android `importantForAccessibility='no-hide-descendants'` on the parent - * gets defeated by deeply nested descendants that set their own - * `accessible={true}` (our SDK's `` does this). A zero size accessible - * `` sidesteps that - Text is always accessible by default on both - * platforms and carries the label cleanly, while the visual subtree is - * marked decorative. More importantly, it's discoverable very very easily - * by screen readers. - * - * Use this anywhere you have non-Text visual content (avatars, icons, - * composed graphics) that should announce as a single semantic unit with a - * curated label, rather than letting screen readers walk the visual tree - * verbosely. + * The label lives on a visually hidden `` sibling because on Android a parent's + * `importantForAccessibility='no-hide-descendants'` is defeated by nested descendants that set + * `accessible` themselves, such as ``. */ export const CompositeAccessibilityProbe = ({ children, diff --git a/package/src/components/Accessibility/HiddenA11yText.tsx b/package/src/components/Accessibility/HiddenA11yText.tsx index 02200ccabd..a31df69b2f 100644 --- a/package/src/components/Accessibility/HiddenA11yText.tsx +++ b/package/src/components/Accessibility/HiddenA11yText.tsx @@ -3,34 +3,24 @@ import { StyleSheet, Text } from 'react-native'; export type HiddenA11yTextProps = { /** - * The text to inject into the accessibility tree. Rendered as actual Text - * content (not as `accessibilityLabel`) so the parent's compose loop on - * Android picks it up — Text without its own label isn't - * `isAccessibilityFocusable`, so it gets concatenated into the parent's - * announcement rather than being skipped as a drill-in target. - * - * Pass the result of `useA11yLabel(...)` directly: when SDK a11y is - * opt-out the value is `undefined` and the component renders nothing. + * Text added to the accessibility tree. It is rendered as `Text` content so that the parent's + * label composition on Android includes it. `undefined` (such as the result of `useA11yLabel` + * while accessibility is disabled) renders nothing. */ label: string | undefined; }; /** - * A visually invisible Text that exists only to contribute extra information - * to a screen reader's announcement. Use it inside a parent that auto-composes - * descendant labels (Pressable, or any View with `accessible` + `accessibilityRole`) - * to splice in supplementary state like "you reacted", "and N more", etc. - * - * Not for "this whole element should be one focus stop with a curated label" - - * use `CompositeAccessibilityProbe` for that. + * Visually hidden text that adds information, such as "you reacted", to a screen reader + * announcement. Use it inside a parent that composes its descendants' labels (a `Pressable`, or a + * `View` with `accessible` and `accessibilityRole`). For one focus stop with a curated label, use + * `CompositeAccessibilityProbe`. */ export const HiddenA11yText = ({ label }: HiddenA11yTextProps) => { if (!label) return null; - // Both content and accessibilityLabel are set to the same string. Content - // keeps the Text on the parent's compose loop (label-only would make it - // `isAccessibilityFocusable` and potentially skipped on Android — though - // the opacity:0 hidden style usually saves it). accessibilityLabel keeps - // testing-library `getByLabelText(...)` queries working. + // The label is both content and `accessibilityLabel`. Content keeps the Text in the parent's + // label composition on Android (a label-only Text is focusable and may be skipped); the label + // keeps testing-library `getByLabelText` queries working. return ( {label} diff --git a/package/src/components/Accessibility/NotificationAnnouncer.tsx b/package/src/components/Accessibility/NotificationAnnouncer.tsx index ab057cfda2..2c670c879b 100644 --- a/package/src/components/Accessibility/NotificationAnnouncer.tsx +++ b/package/src/components/Accessibility/NotificationAnnouncer.tsx @@ -8,21 +8,14 @@ import { useNetworkConnectionState } from '../Chat/hooks/useNetworkConnectionSta import { useSettledWSConnectionHealth } from '../Chat/hooks/useWSConnectionState'; /** - * Mirrors stream-chat-react's ``. RN does not yet have a - * unified Notification queue, so this component currently announces only - * connection-state transitions (offline → online and back) gated on - * `accessibility.announceConnectionState`. Per-channel error announcements can - * be wired in by a future PR via `useChannelContext().error`. - * - * Renders nothing. Mount once inside `` (or wherever the active chat - * surface lives). + * Announces connection state transitions (connected, offline, reconnecting) to screen readers when + * `accessibility.announceConnectionState` is on. Renders nothing; mount once inside ``. */ export const NotificationAnnouncer = () => { const { announceConnectionState, enabled } = useAccessibilityContext(); const isNetworkOnline = useNetworkConnectionState()?.isOnline; const isWSOnline = useSettledWSConnectionHealth(); - // The socket is what 'connected' means to a chat user; the device network only decides which - // of the two offline messages is truthful. + // Online means the WebSocket is healthy; the network state only picks the offline message. const isOnline = !!isWSOnline; const announce = useAccessibilityAnnouncer(); const { t } = useTranslationContext(); diff --git a/package/src/components/Accessibility/OverlayA11yShield.tsx b/package/src/components/Accessibility/OverlayA11yShield.tsx index f1f6f78879..0fae556fba 100644 --- a/package/src/components/Accessibility/OverlayA11yShield.tsx +++ b/package/src/components/Accessibility/OverlayA11yShield.tsx @@ -21,21 +21,14 @@ const useShouldActivateTrap = (): boolean => { }; /** - * Android only accessibility focus trap for the OverlayProvider's children - * tree. iOS handles modal focus traps natively via `accessibilityViewIsModal` - * on each overlay's root, but Android has no equivalent prop - the only - * JS side mechanism is to mark siblings as `'no-hide-descendants'`. + * Android accessibility focus trap for the overlay host's children. iOS traps focus natively with + * `accessibilityViewIsModal` on each overlay root; Android can only mark siblings as + * `'no-hide-descendants'`. * - * The shield wraps `{children}` in a single View whose `importantForAccessibility` - * flips to `'no-hide-descendants'` whenever any focus trapping overlay is - * active (the full screen image/video gallery or the message context menu). - * When closed, the wrapper is a transparent passthrough. - * - * In terms of rerendering, only the wrapper View commits new props on overlay state - * transitions. The `{children}` element reference is stable across renders, - * so React reconciliation does not rerender any component below the wrapper. - * - * On iOS the wrapper is skipped entirely. + * Wraps `{children}` in a View that sets `importantForAccessibility='no-hide-descendants'` while an + * overlay (the image or video gallery, or the message context menu) is open and an accessibility + * service runs. Only the wrapper re-renders on overlay changes, since the `children` element is + * stable. On iOS it renders only the children. */ export function OverlayA11yShield({ children }: PropsWithChildren) { const shouldActivateTrap = useShouldActivateTrap(); diff --git a/package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts b/package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts index 7f8a71a25d..d2b8cfff64 100644 --- a/package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts +++ b/package/src/components/Accessibility/hooks/useIncomingMessageAnnouncements.ts @@ -39,15 +39,16 @@ export type UseIncomingMessageAnnouncementsParams = { }; /** - * Mirrors stream-chat-react's `useIncomingMessageAnnouncements`: - * - 1 message → "New message from {{user}}" - * - >1 messages within throttle window → "{{count}} new messages" - * - Throttled to one announcement per second - * - Bounded `announcedMessageIds` set so a long-running session does not leak. + * Announces new messages from other users to screen readers: "New message from {{user}}" for one, + * "{{count}} new messages" for several within the one second throttle window. * - * Subscribes to `channel.on('message.new')`. When AccessibilityContext.enabled - * is false OR `announceNewMessages` is false, the hook is a no-op (no - * subscription is opened, no listener cost is paid). + * Does nothing, and opens no subscription, unless accessibility and `announceNewMessages` are + * enabled. Announced message IDs are tracked to skip duplicates and cleared past 500 entries. + * + * @param params.activeThreadId The open thread, if any. Only its replies are announced; without + * it only non-reply messages are. + * @param params.channel The channel whose `message.new` events are observed. + * @param params.ownUserId The current user's ID. Their own messages are not announced. */ export const useIncomingMessageAnnouncements = ({ activeThreadId, diff --git a/package/src/components/Accessibility/useAccessibilityAnnouncer.ts b/package/src/components/Accessibility/useAccessibilityAnnouncer.ts index 0adf9ed402..7f46619a2a 100644 --- a/package/src/components/Accessibility/useAccessibilityAnnouncer.ts +++ b/package/src/components/Accessibility/useAccessibilityAnnouncer.ts @@ -17,11 +17,10 @@ export const AccessibilityAnnouncerContext = createContext< >(undefined); /** - * Returns the imperative announcer. When called outside the AccessibilityProvider's - * announcer context (which happens any time the SDK's a11y is disabled), this - * returns a no-op. + * Returns the function that announces a message to the screen reader. Outside the announcer + * context, as when SDK accessibility is disabled, it is a no-op. * - * Mirrors the React SDK's `useAriaLiveAnnouncer` so cross-SDK code reads the same. + * @returns The `announce` function. */ export const useAccessibilityAnnouncer = (): AccessibilityAnnounce => { const contextValue = useContext(AccessibilityAnnouncerContext); diff --git a/package/src/components/Attachment/Attachment.tsx b/package/src/components/Attachment/Attachment.tsx index 3fef9be4f4..de05e18bf7 100644 --- a/package/src/components/Attachment/Attachment.tsx +++ b/package/src/components/Attachment/Attachment.tsx @@ -43,13 +43,9 @@ export type AttachmentPropsWithContext = Pick< 'isAttachmentEqual' | 'myMessageTheme' | 'urlPreviewType' > & Pick & { - /** - * The attachment to render - */ + /** The attachment to render. */ attachment: AttachmentType; - /** - * The index of the attachment in the message - */ + /** The index of the attachment in the message. */ index?: number; }; @@ -81,7 +77,7 @@ const AttachmentWithContext = (props: AttachmentPropsWithContext) => { return ; } - // The `!attachment.og_scrape_url` is added for cases, where the url preview is not an image but a video. + // URL previews (`og_scrape_url`) whose media is a video are excluded. if (isVideoAttachment(attachment) && !attachment.og_scrape_url) { return isVideoPlayerAvailable() ? ( @@ -151,9 +147,7 @@ const MemoizedAttachment = React.memo( export type AttachmentProps = Partial; -/** - * Attachment - The message attachment - */ +/** Renders a single message attachment according to its type. */ export const Attachment = (props: AttachmentProps) => { const { attachment } = props; diff --git a/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx b/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx index 208ac9c319..709b5e062d 100644 --- a/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx +++ b/package/src/components/Attachment/AttachmentFileUploadProgressIndicator.tsx @@ -36,9 +36,7 @@ const formatMegabytesOneDecimal = (bytes: number) => { return `${(bytes / (1000 * 1000)).toFixed(1)} MB`; }; -/** - * Circular progress plus `uploaded / total` for file and audio attachments during upload. - */ +/** Circular progress plus `uploaded / total` for file and audio attachments during upload. */ export const AttachmentFileUploadProgressIndicatorUI = ({ containerStyle, localId, diff --git a/package/src/components/Attachment/AttachmentUploadIndicator.tsx b/package/src/components/Attachment/AttachmentUploadIndicator.tsx index 093ec3566b..bf145615ea 100644 --- a/package/src/components/Attachment/AttachmentUploadIndicator.tsx +++ b/package/src/components/Attachment/AttachmentUploadIndicator.tsx @@ -19,7 +19,8 @@ export type AttachmentUploadIndicatorProps = { }; /** - * Upload state for attachment previews: determinate ring when progress is known, otherwise `ActivityIndicator`. + * Upload state for attachment previews: a determinate ring when progress is known, otherwise an + * `ActivityIndicator`. */ export const AttachmentUploadIndicatorUI = ({ containerStyle, diff --git a/package/src/components/Attachment/Audio/AudioAttachment.tsx b/package/src/components/Attachment/Audio/AudioAttachment.tsx index c4bed3cf47..85f26cbee2 100644 --- a/package/src/components/Attachment/Audio/AudioAttachment.tsx +++ b/package/src/components/Attachment/Audio/AudioAttachment.tsx @@ -61,14 +61,10 @@ export type AudioAttachmentProps = { message?: LocalMessage; hideProgressBar?: boolean; showTitle?: boolean; - /** - * If true, the speed settings button will be shown. - */ + /** Shows the speed settings button. Defaults to false. */ showSpeedSettings?: boolean; testID?: string; - /** - * If true, the audio attachment is in preview mode in the message input. - */ + /** Whether the attachment is shown as a preview in the message input. Defaults to false. */ isPreview?: boolean; containerStyle?: StyleProp; indicator?: React.ReactNode; @@ -89,10 +85,7 @@ const audioPlayerSelector = (state: AudioPlayerState) => ({ progress: state.progress, }); -/** - * AudioAttachment - * UI Component to preview the audio files - */ +/** Previews and plays audio and voice recording attachments. */ export const AudioAttachment = (props: AudioAttachmentProps) => { const soundRef = React.useRef(null); const styles = useStyles(); @@ -125,14 +118,14 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { audioPlayerSelector, ); - // Initialize the player for native cli apps + // Initializes the player for native CLI apps. useEffect(() => { if (soundRef.current) { audioPlayer.initPlayer({ playerRef: soundRef.current }); } }, [audioPlayer]); - // When a audio attachment in preview is removed, we need to remove the player from the pool + // A removed preview releases its player from the pool. useEffect( () => () => { if (isPreview) { @@ -142,22 +135,22 @@ export const AudioAttachment = (props: AudioAttachmentProps) => { [audioPlayer, isPreview], ); - /** This is for Native CLI Apps */ + /** Player callback for native CLI apps. */ const handleLoad = (payload: VideoPayloadData) => { - // Voice recordings already carry the canonical duration in the attachment payload. + // Voice recordings carry the canonical duration in the attachment payload. if (isVoiceRecording) { return; } audioPlayer.duration = payload.duration * ONE_SECOND_IN_MILLISECONDS; }; - /** This is for Native CLI Apps */ + /** Player callback for native CLI apps. */ const handleProgress = (data: VideoProgressData) => { const { currentTime } = data; audioPlayer.position = currentTime * ONE_SECOND_IN_MILLISECONDS; }; - /** This is for Native CLI Apps */ + /** Player callback for native CLI apps. */ const onSeek = (seekResponse: VideoSeekResponse) => { audioPlayer.position = seekResponse.currentTime * ONE_SECOND_IN_MILLISECONDS; }; diff --git a/package/src/components/Attachment/Audio/PlayPauseButton.tsx b/package/src/components/Attachment/Audio/PlayPauseButton.tsx index a54fa0db26..4759705396 100644 --- a/package/src/components/Attachment/Audio/PlayPauseButton.tsx +++ b/package/src/components/Attachment/Audio/PlayPauseButton.tsx @@ -7,17 +7,11 @@ import { primitives } from '../../../theme'; import { buttonSizes } from '../../ui/Button/constants'; export type PlayPauseButtonProps = PressableProps & { - /** - * If true, the button will be playing. - */ + /** Whether the button shows the playing state. */ isPlaying: boolean; - /** - * The function to be called when the button is pressed. - */ + /** Called when the button is pressed. */ onPress: () => void; - /** - * The style of the container. - */ + /** Style of the container. */ containerStyle?: StyleProp; }; diff --git a/package/src/components/Attachment/CircularProgressIndicator.tsx b/package/src/components/Attachment/CircularProgressIndicator.tsx index 0a9f0caaa2..9939a4a335 100644 --- a/package/src/components/Attachment/CircularProgressIndicator.tsx +++ b/package/src/components/Attachment/CircularProgressIndicator.tsx @@ -16,9 +16,9 @@ const SPIN_DURATION_MS = 900; const PROGRESS_ANIMATION_DURATION_MS = 1200; export type CircularProgressIndicatorProps = { - /** Upload percent **0–100**. */ backgroundColor: ColorValue; filledColor: ColorValue; + /** Upload progress as a percentage from 0 to 100. */ progress: number; unfilledColor: ColorValue; size?: number; @@ -27,9 +27,7 @@ export type CircularProgressIndicatorProps = { testID?: string; }; -/** - * Circular upload progress ring (determinate) or rotating arc (indeterminate). - */ +/** Circular upload progress ring (determinate) or rotating arc (indeterminate). */ export const CircularProgressIndicator = ({ backgroundColor, filledColor, diff --git a/package/src/components/Attachment/FileAttachment.tsx b/package/src/components/Attachment/FileAttachment.tsx index 99b619cb90..6db1a0f77b 100644 --- a/package/src/components/Attachment/FileAttachment.tsx +++ b/package/src/components/Attachment/FileAttachment.tsx @@ -29,7 +29,7 @@ export type FileAttachmentPropsWithContext = Pick< 'onLongPress' | 'onPress' | 'onPressIn' | 'preventPress' > & Pick & { - /** The attachment to render */ + /** The attachment to render. */ attachment: Attachment; attachmentIconSize?: FileIconProps['size']; // TODO: Think we really need a way to style the file preview using props if we have theme. diff --git a/package/src/components/Attachment/FileIcon.tsx b/package/src/components/Attachment/FileIcon.tsx index 304ecde815..4d308d9bd6 100644 --- a/package/src/components/Attachment/FileIcon.tsx +++ b/package/src/components/Attachment/FileIcon.tsx @@ -164,8 +164,7 @@ const audioFileTypes = [ 'audio/vorbis-config', ]; -// Partially based on: -// https://stackoverflow.com/a/4212908/2570866 +// Partially based on https://stackoverflow.com/a/4212908/2570866 const docMimeTypes = [ // Microsoft Word @@ -181,8 +180,7 @@ const docMimeTypes = [ 'application/vnd.oasis.opendocument.text-template', // .fodt 'application/vnd.oasis.opendocument.text-flat-xml', - // .uot - // NOTE: firefox doesn't know mimetype so maybe ignore + // .uot has no entry: Firefox does not know its MIME type. // Microsoft Word // .docx @@ -231,19 +229,18 @@ const excelMimeTypes = [ ]; const powerpointMimeTypes = [ - // Microsoft Word + // Microsoft PowerPoint // .ppt .pot .pps .ppa (no test for .ppa) 'application/vnd.ms-powerpoint', - // LibreOffice/OpenOffice Writer + // LibreOffice/OpenOffice Impress // .odp 'application/vnd.oasis.opendocument.presentation', // .otp 'application/vnd.oasis.opendocument.presentation-template', // .fodp 'application/vnd.oasis.opendocument.presentation-flat-xml', - // .uop - // NOTE: firefox doesn't know mimetype so maybe ignore + // .uop has no entry: Firefox does not know its MIME type. // .pptx 'application/vnd.openxmlformats-officedocument.presentationml.presentation', diff --git a/package/src/components/Attachment/FilePreview.tsx b/package/src/components/Attachment/FilePreview.tsx index 46b050195f..8dabfc1ac7 100644 --- a/package/src/components/Attachment/FilePreview.tsx +++ b/package/src/components/Attachment/FilePreview.tsx @@ -10,7 +10,7 @@ import { primitives } from '../../theme'; import { getDurationLabelFromDuration, getFileSizeDisplayText } from '../../utils/utils'; export type FilePreviewProps = { - /** The attachment to render */ + /** The attachment to render. */ attachment: Attachment; attachmentIconSize?: FileIconProps['size']; // TODO: Think we really need a way to style the file preview using props if we have theme. @@ -44,8 +44,7 @@ export const FilePreview = (props: FilePreviewProps) => { }, } = useTheme(); - // `duration` is only populated for audio / voice-recording attachments, where it lives under - // `custom` (untyped on the base `Attachment`, hence the narrow field read). + // `duration` only exists on audio and voice recording attachments, under the untyped `custom`. const duration = (attachment.custom as { duration?: number } | undefined)?.duration; return ( diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 43c30c27d6..6b12241f01 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -262,11 +262,11 @@ const GalleryThumbnail = ({ setOverlay('gallery'); }; const defaultOnPress = () => { - // If the url is defined then only try to open the file. + // Only opens when the thumbnail has a URL. if (thumbnail.url) { if (thumbnail.type === FileTypes.Video && !isVideoPlayerAvailable()) { - // This condition is kinda unreachable, since we render videos as file attachment if the video - // library is not installed. But doesn't hurt to have extra safeguard, in case of some customizations. + // Normally unreachable, since videos render as file attachments without a video library; + // kept as a safeguard for customizations. openUrlSafely(thumbnail.url); } else { openImageViewer(); @@ -425,9 +425,7 @@ const areEqual = (prevProps: GalleryPropsWithContext, nextProps: GalleryPropsWit }; const MemoizedGallery = React.memo(GalleryWithContext, areEqual) as typeof GalleryWithContext; export type GalleryProps = Partial; -/** - * UI component for card in attachments. - */ +/** Renders image and video attachments as a grid of thumbnails. */ export const Gallery = (props: GalleryProps) => { const { alignment: propAlignment, diff --git a/package/src/components/Attachment/GalleryImage.tsx b/package/src/components/Attachment/GalleryImage.tsx index 0b7d0b7221..b9e3f76b13 100644 --- a/package/src/components/Attachment/GalleryImage.tsx +++ b/package/src/components/Attachment/GalleryImage.tsx @@ -29,8 +29,8 @@ export const GalleryImageWithContext = (props: GalleryImageWithContextProps) => ...rest } = props; - // Caching image components such as FastImage will not work with local images. - // This for the case of local uris, we use the default Image component. + // Caching image components such as FastImage do not work with local images, so local URIs use + // the default `Image`. if (!isLocalUrl(uri)) { return ( & { attachment: Attachment; }; -/** - * UI component for card in attachments. - */ +/** Renders a Giphy attachment. */ export const Giphy = (props: GiphyProps) => { const { handleAction, isMyMessage, onLongPress, onPress, onPressIn, preventPress } = useMessageContext(); diff --git a/package/src/components/Attachment/Giphy/GiphyImage.tsx b/package/src/components/Attachment/Giphy/GiphyImage.tsx index f5126b9ed5..ec939a0b36 100644 --- a/package/src/components/Attachment/Giphy/GiphyImage.tsx +++ b/package/src/components/Attachment/Giphy/GiphyImage.tsx @@ -19,9 +19,7 @@ export type GiphyImagePropsWithContext = Pick; } & { attachment: Attachment; - /** - * Whether to render the preview image or the full image - */ + /** Whether to render the preview image or the full image. Defaults to false. */ preview?: boolean; }; @@ -156,9 +154,7 @@ export type GiphyImageProps = Partial & { attachment: Attachment; }; -/** - * UI component for card in attachments. - */ +/** Renders a Giphy image, either the preview or the full image. */ export const GiphyImage = (props: GiphyImageProps) => { const { ImageComponent } = useComponentsContext(); const { giphyVersion } = useMessagesContext(); diff --git a/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx b/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx index 0c4da60f02..8f45694439 100644 --- a/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx +++ b/package/src/components/Attachment/ImageLoadingFailedIndicator.tsx @@ -7,10 +7,7 @@ import { useStableCallback } from '../../hooks'; import { RetryBadge } from '../ui/Badge/RetryBadge'; export type ImageLoadingFailedIndicatorProps = ViewProps & { - /** - * Callback to reload the image - * @returns Callback to reload the image - */ + /** Called to reload the image. */ onReloadImage: () => void; }; diff --git a/package/src/components/Attachment/MediaUploadProgressOverlay.tsx b/package/src/components/Attachment/MediaUploadProgressOverlay.tsx index f36a89b57f..2904956512 100644 --- a/package/src/components/Attachment/MediaUploadProgressOverlay.tsx +++ b/package/src/components/Attachment/MediaUploadProgressOverlay.tsx @@ -11,9 +11,7 @@ export type MediaUploadProgressOverlayProps = { testID?: string; }; -/** - * Full-cover upload overlay for image and video thumbnails. - */ +/** Full-cover upload overlay for image and video thumbnails. */ export const MediaUploadProgressOverlay = ({ progress, size = 18, diff --git a/package/src/components/Attachment/UnsupportedAttachment.tsx b/package/src/components/Attachment/UnsupportedAttachment.tsx index 4bcd5f189a..279471d2ff 100644 --- a/package/src/components/Attachment/UnsupportedAttachment.tsx +++ b/package/src/components/Attachment/UnsupportedAttachment.tsx @@ -15,7 +15,7 @@ import { useTranslationContext } from '../../contexts/translationContext/Transla import { primitives } from '../../theme'; export type UnsupportedAttachmentProps = Partial> & { - /** The attachment to render */ + /** The attachment to render. */ attachment: Attachment; attachmentIconSize?: FileIconProps['size']; }; diff --git a/package/src/components/Attachment/UrlPreview/URLPreview.tsx b/package/src/components/Attachment/UrlPreview/URLPreview.tsx index ef3d94c75b..f5f5e4b6b2 100644 --- a/package/src/components/Attachment/UrlPreview/URLPreview.tsx +++ b/package/src/components/Attachment/UrlPreview/URLPreview.tsx @@ -205,9 +205,7 @@ export type URLPreviewProps = Partial & { attachment: Attachment; }; -/** - * UI component for card in attachments. - */ +/** Renders a URL preview card for a scraped link attachment. */ export const URLPreview = (props: URLPreviewProps) => { const { ImageComponent } = useComponentsContext(); const { message, onLongPress, onPress, onPressIn, preventPress } = useMessageContext(); @@ -215,7 +213,7 @@ export const URLPreview = (props: URLPreviewProps) => { return ( attachment: Attachment; }; -/** - * UI component for card in attachments. - */ +/** Renders a compact URL preview for a scraped link attachment. */ export const URLPreviewCompact = (props: URLPreviewCompactProps) => { const { ImageComponent } = useComponentsContext(); const { message, onLongPress, onPress, onPressIn, preventPress } = useMessageContext(); @@ -213,7 +211,7 @@ export const URLPreviewCompact = (props: URLPreviewCompactProps) => { return ( ; - /** - * When set, upload state is read from `client.uploadManager` for this pending attachment id. - */ + /** ID of a pending attachment whose upload state is read from `client.uploadManager`. */ localId?: string; style?: StyleProp; thumb_url?: string; diff --git a/package/src/components/Attachment/utils/buildGallery/buildGallery.ts b/package/src/components/Attachment/utils/buildGallery/buildGallery.ts index ad2622b0fb..572e0056c7 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildGallery.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildGallery.ts @@ -14,20 +14,14 @@ import { } from '../../../../contexts/chatConfigContext/ChatConfigContext'; /** - * Builds and returns a gallery of optimized images to be rendered on UI. - * This function take a object parameter with following properties: + * Builds the gallery layout for image attachments, with a dedicated layout for one, two and three + * images. Four or more use a 2x2 grid of the first four. * - * @param {Attachment[]} images - Array of image attachments - * @param {GallerySizeConfig} sizeConfig - Theme config for the gallery - * - * The returned object contains following properties: - * - * - height {number[]} - Height of the gallery - * - width {number[]} - Width of the gallery - * - thumbnailGrid {number[][]} - Grid of thumbnail images - * - invertedDirections {boolean} - Whether to invert the direction of the grid. By default grid is rendered with column as primary direction and row as secondary direction. - * - * @return {GallerySizeAndThumbnailGrid} + * @param params.images Image attachments to lay out. + * @param params.resizableCDNHosts CDN hosts whose image URLs can be resized. Defaults to the chat + * config default. + * @param params.sizeConfig Size limits of the gallery. + * @returns The gallery size, its thumbnails and the `invertedDirections` it was built with. */ export function buildGallery({ images, @@ -61,15 +55,7 @@ export function buildGallery({ }); } - /** - * ----------- - * | | | - * | | | - * ----------- - * | | | - * | | | - * ----------- - */ + // Four or more images: a 2x2 grid of the first four. return buildThumbnailGrid({ grid: [ [1, 1], diff --git a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts index 728bc9ad88..ec9e08a30e 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfSingleImage.ts @@ -8,14 +8,6 @@ import { ChatConfigContextValue } from '../../../../contexts/chatConfigContext/C import { getAspectRatio } from '../getAspectRatio'; -/** - * Bound a number to a range. - * @param number The number to bound. - * @param min The minimum value. - * @param max The maximum value. - * - * @returns The bounded number. - */ function clamp(number: number, min: number, max: number) { return Math.min(Math.max(number, min), max); } diff --git a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfThreeImages.ts b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfThreeImages.ts index 2f49e854d5..f42692c1ac 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfThreeImages.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfThreeImages.ts @@ -8,7 +8,7 @@ import { ChatConfigContextValue } from '../../../../contexts/chatConfigContext/C import { getAspectRatio } from '../getAspectRatio'; -/** function to move item to the front of the array */ +/** Returns a copy of `array` with `item` first, or `array` itself when `item` is absent. */ function moveToFront(array: T[], item: T): T[] { const index = array.indexOf(item); if (index === -1) { @@ -28,15 +28,13 @@ export function buildGalleryOfThreeImages({ images: Attachment[]; sizeConfig: GallerySizeConfig; }): GallerySizeAndThumbnailGrid { - // Find the most ladscape and most portrait image. + // Finds the most landscape and the most portrait image. const { landscapeImage, landscapeImageAspectRatio, portraitImage, portraitImageAspectRatio } = images.reduce( (acc, image) => { const aspectRatio = getAspectRatio(image); const o = { ...acc }; - // Following nested if conditions can be combined, but have been kept - // separate intentionally for the sake of readability. if (aspectRatio > 1) { if (!o.landscapeImage || aspectRatio > o.landscapeImageAspectRatio) { o.landscapeImage = image; @@ -62,17 +60,9 @@ export function buildGalleryOfThreeImages({ }, ); - // Check weather landscape image gets preference or portrait, by comparing aspect ratio. + // The more extreme of the two images, by aspect ratio, leads the layout. if (landscapeImageAspectRatio > 1 / portraitImageAspectRatio) { - /** - * ----------- - * | | - * | | - * |---------| - * | | | - * | | | - * ----------- - */ + // The landscape image on top, the other two side by side below. return buildThumbnailGrid({ grid: [[1], [1, 1]], images: landscapeImage ? moveToFront(images, landscapeImage) : images, @@ -81,15 +71,7 @@ export function buildGalleryOfThreeImages({ sizeConfig, }); } else { - /** - * ----------- - * | | | - * | | | - * | |----| - * | | | - * | | | - * ----------- - */ + // The portrait image on the left, the other two stacked on the right. return buildThumbnailGrid({ grid: [[1], [1, 1]], images: portraitImage ? moveToFront(images, portraitImage) : images, diff --git a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts index b8212ae8c1..57d6014dc5 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildGalleryOfTwoImages.ts @@ -19,19 +19,11 @@ export function buildGalleryOfTwoImages({ const aspectRatio1 = getAspectRatio(images[0]); const aspectRatio2 = getAspectRatio(images[1]); - // check if one image is landscape and other is portrait or vice versa const isLandscape1 = aspectRatio1 > 1; const isLandscape2 = aspectRatio2 > 1; - // Both the images are landscape + // Both landscape: stacked. if (isLandscape1 && isLandscape2) { - /** - * ---------- - * | | - * ---------- - * | | - * ---------- - */ return buildThumbnailGrid({ grid: [[1], [1]], images, @@ -41,16 +33,8 @@ export function buildGalleryOfTwoImages({ }); } - // Both the images are portrait + // Both portrait: side by side. if (!isLandscape1 && !isLandscape2) { - /** - * ----------- - * | | | - * | | | - * | | | - * | | | - * ----------- - */ return buildThumbnailGrid({ grid: [[1, 1]], images, diff --git a/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts b/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts index 763b5bbfef..d5214fc831 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildThumbnail.ts @@ -27,12 +27,12 @@ export function buildThumbnail({ }: BuildThumbnailProps): Thumbnail { const { original_height: originalImageHeight, original_width: originalImageWidth } = image; - // Only resize if the original image is larger than the thumbnail container size. + // Resizes only when the original image is larger than the thumbnail container. const shouldResize = originalImageHeight && originalImageWidth ? originalImageHeight + originalImageWidth > height + width : true; - // Falls back to the local preview while the upload is in flight — see `getUrlOfImageAttachment`. + // Falls back to the local preview while the upload is in flight (see `getUrlOfImageAttachment`). const imageUrl = getUrlOfImageAttachment(image) as string; const localId = isLocalUploadAttachment(image) ? image.localMetadata.id : undefined; diff --git a/package/src/components/Attachment/utils/buildGallery/buildThumbnailGrid.ts b/package/src/components/Attachment/utils/buildGallery/buildThumbnailGrid.ts index c916900756..8f5c5fc063 100644 --- a/package/src/components/Attachment/utils/buildGallery/buildThumbnailGrid.ts +++ b/package/src/components/Attachment/utils/buildGallery/buildThumbnailGrid.ts @@ -6,132 +6,18 @@ import type { GallerySizeAndThumbnailGrid, GallerySizeConfig, ThumbnailGrid } fr import { ChatConfigContextValue } from '../../../../contexts/chatConfigContext/ChatConfigContext'; /** - * Builds a grid of thumbnail images from image attachments. - * This function take a object parameter with following properties: - * - * @params - * - * - {number[][]} grid - Grid pattern of the gallery. Each numeric value in the array represents the flex value of corresponding image in grid. - * - {Attachment[]} images - Array of image attachments. - * - {GallerySizeConfig} sizeConfig - Theme config for the gallery. - * - {boolean} invertedDirections - Whether to invert the direction of the grid. By default grid is rendered with column as primary direction and row as secondary direction. - * - * @usage - * - * ``` - * const { thumbnailGrid, invertedDirection } = buildThumbnailGrid({ - * grid: [[1], [1]], - * images: [image1, image2], - * sizeConfig: { - * gridHeight: 200, - * gridWidth: 200, - * maxHeight: 200, - * maxWidth: 200, - * minHeight: 200, - * minWidth: 200, - * }, - * }) - * - * Rendering logic on UI: - * - * ``` - * - * { - * thumbnailGrid.forEach(rows => { - * return ( - * - * { - * rows.forEach(thumbnail => ( - * - * ))} - * - * )}); - * } - * - * ``` - * - * Lets look at different examples of grid and invertedDirections param: - * - * EXAMPLE 1: - * - * ``` - * { - * grid: [[2, 1], [2, 1]] - * invertedDirections: false - * } - * ``` - * - * Resulting thumbnail grid on rendered UI: - * - * __________________ - * | | | - * | | | - * |------------------| - * | | | - * | | | - * ------------------ - * - * EXAMPLE 2: - * - * ``` - * { - * grid: [[2, 1], [2, 1]] - * invertedDirections: true - * } - * ``` - * Resulting thumbnail grid on rendered UI: - * __________________ - * | | | - * | | | - * | | | - * | | | - * |------------------| - * | | | - * | | | - * ------------------ - * - * EXAMPLE 3: - * - * ``` - * { - * grid: [[2, 1]] - * invertedDirections: false - * } - * ``` - * Resulting thumbnail grid on rendered UI: - * __________________ - * | | - * | | - * | | - * | | - * |------------------| - * | | - * | | - * ------------------ - * - * EXAMPLE 4: - * - * ``` - * { - * grid: [[2, 1]] - * invertedDirections: true - * } - * ``` - * Resulting thumbnail grid on rendered UI: - * __________________ - * | | | - * | | | - * | | | - * | | | - * | | | - * ------------------ - * - * @return {GallerySizeAndThumbnailGrid} + * Builds the thumbnail grid for image attachments, sized to `gridWidth` x `gridHeight`. + * + * Each inner array of `grid` is a column of thumbnails stacked top to bottom, sized by flex weight: + * `[[2, 1]]` is one column whose first thumbnail is twice as tall as the second. With + * `invertedDirections` each inner array is a row, laid out left to right instead. + * + * @param params.grid Flex weight of every thumbnail, per column (per row when inverted). + * @param params.images Image attachments, consumed in grid order. + * @param params.invertedDirections Swaps the roles of columns and rows. Defaults to false. + * @param params.resizableCDNHosts CDN hosts whose image URLs can be resized. + * @param params.sizeConfig Overall gallery size; only `gridHeight` and `gridWidth` are read here. + * @returns The grid size, its thumbnails and the `invertedDirections` it was built with. */ export function buildThumbnailGrid({ grid, diff --git a/package/src/components/Attachment/utils/buildGallery/types.ts b/package/src/components/Attachment/utils/buildGallery/types.ts index ceefd60b5a..34e958cdc5 100644 --- a/package/src/components/Attachment/utils/buildGallery/types.ts +++ b/package/src/components/Attachment/utils/buildGallery/types.ts @@ -4,7 +4,7 @@ export type Thumbnail = { resizeMode: ImageResizeMode; url: string; id?: string; - /** Same as attachment `localId` for correlating with `client.uploadManager` */ + /** The attachment `localId`, which keys its upload in `client.uploadManager`. */ localId?: string; thumb_url?: string; type?: string; diff --git a/package/src/components/Attachment/utils/getAspectRatio.ts b/package/src/components/Attachment/utils/getAspectRatio.ts index 410a0d3cfc..2803972bd4 100644 --- a/package/src/components/Attachment/utils/getAspectRatio.ts +++ b/package/src/components/Attachment/utils/getAspectRatio.ts @@ -3,10 +3,11 @@ import type { Attachment } from 'stream-chat'; import { FileTypes } from '../../../types/types'; /** - * Returns the aspect ratio of an image attachment. + * Returns the aspect ratio (width / height) of an image attachment or video thumbnail. * - * @param image Image attachment. - * @returns {number} + * @param attachment Image attachment or video thumbnail. + * @returns The aspect ratio, or 1 when the original size is unknown. + * @throws When the attachment is not an image or a video. */ export function getAspectRatio(attachment: Attachment) { if (!(attachment.type === FileTypes.Image || attachment.type === FileTypes.Video)) { diff --git a/package/src/components/Attachment/utils/getGiphyMimeType.ts b/package/src/components/Attachment/utils/getGiphyMimeType.ts index 3a0914362d..4a8f386ef8 100644 --- a/package/src/components/Attachment/utils/getGiphyMimeType.ts +++ b/package/src/components/Attachment/utils/getGiphyMimeType.ts @@ -1,7 +1,8 @@ /** + * Infers the MIME type of a Giphy asset from its URL. * - * @param giphyUrl The giphy attachment url - * @returns mimeType for the giphy attachment + * @param giphyUrl The Giphy attachment URL. + * @returns `video/mp4` or `image/webp` for those extensions, `image/gif` otherwise. */ export function getGiphyMimeType(giphyUrl: string): string { if (giphyUrl.includes('.mp4')) { diff --git a/package/src/components/Attachment/utils/openUrlSafely.ts b/package/src/components/Attachment/utils/openUrlSafely.ts index c147475a18..a23026219e 100644 --- a/package/src/components/Attachment/utils/openUrlSafely.ts +++ b/package/src/components/Attachment/utils/openUrlSafely.ts @@ -1,8 +1,8 @@ import { Linking } from 'react-native'; export const openUrlSafely = async (url?: string) => { - // An attachment whose upload hasn't settled yet has no remote URL. Without this guard the - // scheme fallback below would turn it into `http://undefined`, which `canOpenURL` accepts. + // An attachment still uploading has no remote URL, and the scheme fallback below would turn + // it into `http://undefined`. if (!url) { return; } diff --git a/package/src/components/AttachmentPicker/AttachmentPicker.tsx b/package/src/components/AttachmentPicker/AttachmentPicker.tsx index 51ded7d630..633a8ad918 100644 --- a/package/src/components/AttachmentPicker/AttachmentPicker.tsx +++ b/package/src/components/AttachmentPicker/AttachmentPicker.tsx @@ -101,10 +101,7 @@ export const AttachmentPicker = () => { windowHeight - topInset - attachmentPickerBottomSheetHeight - bottomInset, ); - /** - * Snap points changing cause a rerender of the position, - * this is an issue if you are calling close on the bottom sheet. - */ + // A new snap points array re-renders the sheet position, which breaks closing the sheet. const snapPoints = useMemo(() => [initialSnapPoint], [initialSnapPoint]); const onAttachmentPickerSelectionBarLayout = useStableCallback((e: LayoutChangeEvent) => { @@ -124,10 +121,7 @@ export const AttachmentPicker = () => { const animatedIndex = useSharedValue(currentIndex); - // This is required to prevent the attachment picker from getting out of sync - // with the rest of the state. While there are more prudent fixes, this is about - // as much as we can do now without refactoring the entire state layer for the - // picker. When we do that, this can be removed completely. + // Keeps the picker store in sync with the sheet index (-1 closed, 0 open). const reactToIndex = useStableCallback((index: number) => { if (index === -1) { attachmentPickerStore.setSelectedPicker(undefined); diff --git a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx index 5500d99665..2a4055eb50 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentMediaPicker.tsx @@ -83,8 +83,6 @@ export const AttachmentMediaPicker = (props: AttachmentPickerContentProps) => { }); endCursorRef.current = results.endCursor; - // skip updating if the sheet closed in the meantime, to avoid - // confusing the bottom sheet internals setPhotos((prevPhotos) => { if (endCursor) { return [...prevPhotos, ...results.assets]; @@ -116,13 +114,11 @@ export const AttachmentMediaPicker = (props: AttachmentPickerContentProps) => { if (!NativeHandlers.oniOS14GalleryLibrarySelectionChange) { return; } - // ios 14 library selection change event is fired when user reselects the images that are permitted to be - // readable by the app + // iOS 14 fires this when the user changes which photos the app is permitted to read. const { unsubscribe } = NativeHandlers.oniOS14GalleryLibrarySelectionChange(() => { - // we reset the cursor and has next page to true to facilitate fetching of the first page of photos again + // Restart pagination from the first page. hasNextPageRef.current = true; endCursorRef.current = undefined; - // fetch the first page of photos again getMorePhotos(); }); return unsubscribe; @@ -144,8 +140,7 @@ export const AttachmentMediaPicker = (props: AttachmentPickerContentProps) => { !loadingPhotosRef.current ) { getMorePhotos(); - // we do this only once on open for avoiding to request permissions in rationale dialog again and again on - // Android + // Once per open, so Android does not show the permission rationale dialog repeatedly. attemptedToLoadPhotosOnOpenRef.current = true; } }, [getMorePhotos]); diff --git a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx index 38bce115f4..49bd8c82da 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx @@ -32,8 +32,8 @@ type AttachmentPickerItemType = { }; /** - * Where this cell's asset sits among the composer's attachments, or -1. Selected per cell, so an - * upload progressing re-renders no cell and a selection change only the cells whose index moved. + * Index of this cell's asset among the composer's attachments, or -1. Selected per cell, so upload + * progress re-renders no cell and a selection change only the cells whose index moved. */ const useSelectedIndex = (isAsset: (attachment: LocalAttachment) => boolean) => { const { attachmentManager } = useMessageComposer(); @@ -220,12 +220,8 @@ export const renderAttachmentPickerItem = ({ item }: { item: PhotoContentItemTyp if (isIosLimited(item)) { return ; } - /** - * Expo Media Library - Result of asset type - * Native Android - Gives mime type(Eg: image/jpeg, video/mp4, etc.) - * Native iOS - Gives `image` or `video` - * Expo Android/iOS - Gives `photo` or `video` - **/ + // `type` is a MIME type on native Android (such as `video/mp4`), `image` or `video` on native + // iOS, and `photo` or `video` with Expo. const isVideoType = item.type?.includes('video'); if (isVideoType) { diff --git a/package/src/components/AttachmentPicker/components/AttachmentTypePickerButton.tsx b/package/src/components/AttachmentPicker/components/AttachmentTypePickerButton.tsx index 5231ed3091..4e4b2c1340 100644 --- a/package/src/components/AttachmentPicker/components/AttachmentTypePickerButton.tsx +++ b/package/src/components/AttachmentPicker/components/AttachmentTypePickerButton.tsx @@ -173,7 +173,7 @@ export const PollPickerButton = () => { openPollCreationDialog?.({ sendMessage }); }); - return !thread && hasCreatePoll && ownCapabilities.sendPoll ? ( // do not allow poll creation in threads + return !thread && hasCreatePoll && ownCapabilities.sendPoll ? ( // No polls in threads. & Pick & Pick & { - /** - * This is currently passed in from MessageComposer to avoid rerenders - * that would happen if we put this in the MessageInputContext - */ + /** Passed in by `MessageComposer` rather than read from context, to avoid extra re-renders. */ cooldownRemainingSeconds?: number; TextInputComponent?: TextInputOverrideComponent; }; @@ -97,9 +94,8 @@ const commandPlaceHolders: Record = { const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) => { const styles = useStyles(); const { - // Destructured purely to keep it out of `...rest`, which is spread onto the TextInput below. It is - // no longer read here — the message length limit now comes from resolved composer configuration — - // but leaking a whole Channel into native props would be a large, pointless serialization. + // Unused: destructured only to keep the channel out of `...rest`, which is spread onto the + // TextInput, so it is not serialized into native props. channel: _channel, cooldownRemainingSeconds, setInputBoxRef, @@ -117,11 +113,8 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) configStateSelector, ); - // RN's onChangeText doesn't carry cursor info, and iOS / Android fire - // onChangeText vs onSelectionChange in different orders. Rather than derive - // the caret from a text-length delta (fragile — gets clobbered by re-renders - // and varies across platforms), we hold the latest values reported by native - // and call into the LLC once both have settled. + // `onChangeText` carries no cursor info and iOS and Android fire it and `onSelectionChange` in + // different orders, so the latest text and selection are held in refs and forwarded together. const latestTextRef = useRef(''); const latestSelectionRef = useRef<{ end: number; start: number }>({ end: 0, @@ -137,8 +130,7 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) }); }, [textComposer]); - // Defer to the next task so onChangeText and onSelectionChange both land - // before we forward to the LLC, regardless of platform ordering. + // Deferred a task so both `onChangeText` and `onSelectionChange` land first, whatever the order. const scheduleChange = useCallback(() => { if (flushHandleRef.current !== null) return; flushHandleRef.current = setTimeout(flushChange, 0); @@ -153,9 +145,8 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) }; }, []); - // The composer's resolved limit, not the raw `max_message_length` server flag. The LLC treats the - // channel type's value as an *upper bound* on `text.maxLengthOnSend`, so this is the tighter of the - // server's cap and anything registered through `client.config`, and it is reactive. + // The composer's resolved limit rather than the raw `max_message_length` channel flag: the flag + // is an upper bound on `text.maxLengthOnSend`, which also reflects `client.config`. const maxMessageLength = maxLengthOnSend; const numberOfLines = useMemo(() => { @@ -164,10 +155,9 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) useEffect(() => { setLocalText(text); - // Only resync the refs when the text change came from outside (clear after - // send, draft restore, programmatic setText). For changes we triggered - // ourselves, latestTextRef is already up to date and overwriting the - // selection would clobber what onSelectionChange just told us. + // Resync the refs only for changes from outside (clear after send, draft restore, `setText`). + // Our own changes already updated `latestTextRef`, and resetting the selection would discard + // what `onSelectionChange` just reported. if (text !== latestTextRef.current) { latestTextRef.current = text; latestSelectionRef.current = { end: text.length, start: text.length }; @@ -178,13 +168,9 @@ const AutoCompleteInputWithContext = (props: AutoCompleteInputPropsWithContext) const clearState = useCallback(() => { setLocalText(''); - // iOS UITextView caches its intrinsicContentSize while focused, so a - // controlled `value` change to '' after a multiline send doesn't shrink - // the input back to single line height and UIKit keeps rendering at the - // previously cached focused size until blur. Not particularly sure which - // RN version regressed this, but 0.85.3 for sure has the bug. Forcebly - // setting its native prop forces UITextView to reconcile its content size - // and update accordingly. + // iOS `UITextView` caches its intrinsic content size while focused, so clearing `value` after a + // multiline send keeps the tall size until blur (seen on RN 0.85.3). Setting the native prop + // forces it to recompute. if (Platform.OS === 'ios') { nativeInputRef.current?.setNativeProps({ text: '' }); } @@ -347,11 +333,11 @@ const useStyles = () => { color: semantics.inputTextDefault, flex: 1, fontSize: 16, - includeFontPadding: false, // for android vertical text centering + includeFontPadding: false, // Android vertical text centering lineHeight: 20, paddingLeft: 16, paddingVertical: 12, - textAlignVertical: 'center', // for android vertical text centering + textAlignVertical: 'center', // Android vertical text centering alignSelf: 'center', }, }); diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx index 65d8ecdbcb..a1abc2ddef 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionItem.tsx @@ -24,10 +24,8 @@ export type AutoCompleteSuggestionItemProps = { }; /** - * Default `@`-trigger row dispatcher. Routes a `MentionSuggestion` to the - * per type component. Each per type component is its own export and can be - * composed by integrators who override this dispatcher via - * `ComponentsContext.MentionSuggestionItem`. + * Default `@`-trigger row dispatcher that renders a `MentionSuggestion` with the component for its + * mention type. Replaceable through `ComponentsContext.MentionSuggestionItem`. */ export const MentionSuggestionItem = (item: MentionSuggestion) => { switch (item.mentionType) { @@ -116,9 +114,8 @@ const SuggestionItem = ({ item: TextComposerSuggestion; triggerType?: string; }) => { - // Resolve via context so integrators can swap the mention dispatcher alone - // (e.g. to render a custom @channel row) without re-implementing the - // emoji/command branches of AutoCompleteSuggestionItem. + // Read from context so the mention row can be replaced without touching the emoji and command + // rows. const { MentionSuggestionItem } = useComponentsContext(); switch (triggerType) { case 'mention': diff --git a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx index 1cb657ba6d..fbf58c32aa 100644 --- a/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx +++ b/package/src/components/AutoCompleteInput/AutoCompleteSuggestionList.tsx @@ -77,9 +77,7 @@ export const AutoCompleteSuggestionList = () => { const loadMore = useStableCallback(() => suggestions?.searchSource.search()); - // Polite announcement when the suggestion list appears. Different label per - // trigger type so the user knows whether they're looking at mentions, - // commands, or emoji without having to swipe in to find out. + // Polite announcement when the list appears, with a label per trigger type. const announceLabelKey = triggerType === 'command' ? 'autoCompleteInput.suggestions.commandsAvailable.accessibilityLabel' diff --git a/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionContent.tsx b/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionContent.tsx index 77aa84ae5e..978d09ce76 100644 --- a/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionContent.tsx +++ b/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionContent.tsx @@ -11,8 +11,7 @@ export type EnhancedMentionContentProps = { }; /** - * Title + optional subtitle pair used by every non-user mention row - * (channel / here / role / user_group). Override styling via + * Title and optional subtitle of a non-user mention row. Styled through * `theme.messageComposer.suggestions.mention.enhancedMention{Container,Title,Subtitle}`. */ export const EnhancedMentionContent = ({ diff --git a/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionIcon.tsx b/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionIcon.tsx index abc3b14a1f..e3cdd305ec 100644 --- a/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionIcon.tsx +++ b/package/src/components/AutoCompleteInput/mentionItems/EnhancedMentionIcon.tsx @@ -6,26 +6,16 @@ import type { IconProps } from '../../../icons/utils/base'; import { primitives } from '../../../theme'; export type EnhancedMentionIconProps = { - /** - * Any icon component from `package/src/icons` (or a custom one matching the - * same `IconProps` shape). The wrapper standardizes size + color and wraps - * the icon in a circular chip — per-type mention items don't have to know - * about any of that. - */ + /** Icon component, rendered at half the chip size inside a circular chip. */ Icon: React.ComponentType; - /** - * Icon size in px. Defaults to 16. The surrounding chip scales with this. - */ + /** Chip diameter in px. Defaults to 32. */ size?: IconProps['size']; - /** - * Stroke / fill color. Defaults to `semantics.textSecondary`. - */ + /** Icon fill color. Defaults to `semantics.textPrimary`. */ color?: IconProps['pathFill']; }; /** - * Universal wrapper for non-user mention-row icons. Renders the supplied - * `Icon` inside a circular chip. Override chip styling via + * Circular chip around the icon of a non-user mention row. Styled through * `theme.messageComposer.suggestions.mention.enhancedMentionIcon`. */ export const EnhancedMentionIcon = ({ color, Icon, size = 32 }: EnhancedMentionIconProps) => { diff --git a/package/src/components/AutoCompleteInput/mentionItems/MentionBroadcastItem.tsx b/package/src/components/AutoCompleteInput/mentionItems/MentionBroadcastItem.tsx index e71a6927b8..50fe4a55db 100644 --- a/package/src/components/AutoCompleteInput/mentionItems/MentionBroadcastItem.tsx +++ b/package/src/components/AutoCompleteInput/mentionItems/MentionBroadcastItem.tsx @@ -13,9 +13,7 @@ export type MentionBroadcastItemProps = { entity: ChannelMentionSuggestion | HereMentionSuggestion; }; -// @channel and @here are literal SDK command keywords (matching mentioned_channel -// and mentioned_here on the wire). The title is not localized; only the -// description below it is. +// `@channel` and `@here` are literal keywords, so only the description is localized. const TITLE = { channel: '@channel', here: '@here' } as const; const SUBTITLE_KEY = { channel: 'autoCompleteInput.mention.channel.description', diff --git a/package/src/components/AutoCompleteInput/mentionItems/MentionItem.tsx b/package/src/components/AutoCompleteInput/mentionItems/MentionItem.tsx index e720b51f36..882e870789 100644 --- a/package/src/components/AutoCompleteInput/mentionItems/MentionItem.tsx +++ b/package/src/components/AutoCompleteInput/mentionItems/MentionItem.tsx @@ -5,19 +5,14 @@ import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { primitives } from '../../../theme'; export type MentionItemProps = PropsWithChildren<{ - /** - * Leading visual rendered to the left of the row. UserAvatar for user - * mentions, an `EnhancedMentionIcon` for the rest. - */ + /** Visual rendered before the content: a user avatar or an `EnhancedMentionIcon`. */ leading?: ReactNode; testID?: string; }>; /** - * Layout primitive for every mention-suggestion row: `[leading | content]`. - * The per-type content (tokenized user name, or `EnhancedMentionContent` for - * channel/here/role/user_group) is passed as children. Container and column - * styles come from `theme.messageComposer.suggestions.mention`. + * Layout of a mention-suggestion row: the `leading` visual followed by the children as content. + * Styled through `theme.messageComposer.suggestions.mention`. */ export const MentionItem = ({ children, leading, testID }: MentionItemProps) => { const { diff --git a/package/src/components/AutoCompleteInput/mentionItems/TokenizedSuggestionParts.tsx b/package/src/components/AutoCompleteInput/mentionItems/TokenizedSuggestionParts.tsx index a61c84d516..d6c260a887 100644 --- a/package/src/components/AutoCompleteInput/mentionItems/TokenizedSuggestionParts.tsx +++ b/package/src/components/AutoCompleteInput/mentionItems/TokenizedSuggestionParts.tsx @@ -5,15 +5,11 @@ import type { TokenizationPayload } from 'stream-chat'; export type TokenizedSuggestionPartsProps = { /** - * Token + parts payload produced by the stream-chat-js text composer search - * source. When the consumer matches against a display name the source splits - * the name into substrings around the matched token; we render each part and - * bold whichever part case-insensitively equals the token. + * Display name split into parts around the matched token. Parts equal to the token, ignoring + * case, are rendered with `matchStyle`. */ tokenizedDisplayName?: TokenizationPayload['tokenizedDisplayName']; - /** - * Fallback string rendered when the tokenized payload is absent (or empty). - */ + /** Rendered when `tokenizedDisplayName` is absent or has no parts. */ fallback?: string; style?: StyleProp; matchStyle?: StyleProp; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index c83099acb5..62301814da 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -24,9 +24,8 @@ export { reactionData } from './ChannelShell'; export type MarkReadFunctionOptions = { /** - * Signal, whether the message paginator's unread snapshot should be updated. - * By default, the local state update is prevented when the Channel component is mounted. - * This is in order to keep the UI indicating the original unread state, when the user opens a channel. + * Whether the paginator's unread snapshot is reset to the latest message. Defaults to true. + * Marking read on mount passes false so the channel keeps showing its original unread state. */ updateChannelUnreadState?: boolean; }; @@ -35,23 +34,14 @@ export type ChannelPropsWithContext = Pick & Pick & Pick & ChannelConfigProps & { - /** - * When true, messageList will be scrolled at first unread message, when opened. - */ + /** Scrolls the message list to the first unread message on open. Defaults to false. */ initialScrollToFirstUnreadMessage?: boolean; - /** - * Boolean flag to enable/disable marking the channel as read on mount - */ + /** Marks the channel as read on mount. Defaults to true. */ markReadOnMount?: boolean; /** - * A boolean signifying whether the Channel component should run channel.watch() - * whenever it mounts up a new channel. If set to `false`, it is the integrator's - * responsibility to run channel.watch() if they wish to receive WebSocket events - * for that channel. - * - * Can be particularly useful whenever we are viewing channels in a read-only mode - * or perhaps want them in an ephemeral state (i.e not created until the first message - * is sent). + * Whether to call `channel.watch()` when a channel mounts. Defaults to true. When false, call + * it yourself to receive WebSocket events, such as for a read-only or ephemeral channel that is + * not created until its first message. */ initializeOnMount?: boolean; }; @@ -89,8 +79,8 @@ const ChannelWithContext = (props: PropsWithChildren) = }); if (!channel || blockingError) { - // Retry re-runs the query that failed. A new failure lands in the paginator's `lastQueryError`, - // which is the `error` this indicator renders off — so the warn is all the handling needed. + // A failed retry lands in the paginator's `lastQueryError`, which the indicator renders, so + // the warning is all the handling needed. const retry = () => channel?.messagePaginator ?.jumpToTheLatestMessage() @@ -124,17 +114,15 @@ export type ChannelProps = Partial> & Pick & { /** * The notification host this channel's notifications are routed to. Defaults to the channel's - * own host, derived from its cid. + * own host, derived from its `cid`. */ notificationHostId?: string; }; /** - * - * The wrapper component for a chat channel. Channel needs to be placed inside a Chat component - * to receive the StreamChat client instance. MessageList and MessageComposer must be children of - * the Channel component to receive the ChannelContext. A thread is rendered by ``, next to - * the channel rather than inside it. + * Wrapper component for a chat channel. It must be inside ``, and `MessageList` and + * `MessageInput` must be its children to receive the channel context. A thread is rendered by + * `` next to the channel, not inside it. * * @example ./Channel.md */ diff --git a/package/src/components/Channel/ChannelShell.tsx b/package/src/components/Channel/ChannelShell.tsx index 174e86af12..4eb44e7f37 100644 --- a/package/src/components/Channel/ChannelShell.tsx +++ b/package/src/components/Channel/ChannelShell.tsx @@ -91,8 +91,8 @@ export const reactionData: ReactionData[] = [ ]; /** - * The configuration `` and `` share: everything that shapes how the message list, - * the messages and the composer below them behave, as opposed to which conversation they show. + * Configuration shared by `` and `` for the message list, messages and composer + * below them. */ export type ChannelConfigProps = Partial< Pick< @@ -187,22 +187,17 @@ export type ChannelConfigProps = Partial< | 'hasCreatePoll' > > & { - /** - * Additional props passed to keyboard avoiding view - */ + /** Additional props passed to the keyboard avoiding view. */ additionalKeyboardAvoidingViewProps?: Partial; /** - * If true, multiple audio players will be allowed to play simultaneously + * If true, multiple audio players can play simultaneously. * @default true */ allowConcurrentAudioPlayback?: boolean; /** - * When true, disables the KeyboardCompatibleView wrapper - * - * Channel internally uses the [KeyboardCompatibleView](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx) - * component to adjust the height of Channel when the keyboard is opened or dismissed. This prop provides the ability to disable this functionality in case you - * want to use [KeyboardAvoidingView](https://facebook.github.io/react-native/docs/keyboardavoidingview) or handle dismissal yourself. - * KeyboardAvoidingView works well when your component occupies 100% of screen height, otherwise it may raise some issues. + * Disables the `KeyboardCompatibleView` that resizes the channel when the keyboard opens or + * closes, for apps that use React Native's `KeyboardAvoidingView` or handle it themselves. + * Defaults to false. */ disableKeyboardCompatibleView?: boolean; keyboardBehavior?: KeyboardCompatibleViewProps['behavior']; @@ -213,24 +208,17 @@ export type ChannelConfigProps = Partial< type ChannelShellProps = ChannelConfigProps & { channel: Channel; hasPendingInitialTargetLoad: () => boolean; - /** - * Set by ``: the list and the composer below show this thread's replies. - */ + /** Set by ``: the list and composer below show this thread's replies. */ thread?: Thread; }; -/** - * Poll composition, resolved: the channel type's `polls` flag already ANDed with anything registered - * through `client.config.set({ messageComposer: { polls } })`. Module scope keeps the reference stable. - */ +// Resolved poll composition: the channel type's `polls` flag combined with anything registered +// through `client.config.set({ messageComposer: { polls } })`. const composerPollsSelector = (state: MessageComposerConfig) => ({ pollsEnabled: state.polls.enabled, }); -/** - * The slash commands this channel type offers, as the server reports them. Lives on resolved channel - * configuration so there is one place to read from — see `ChannelConfig.availableCommands`. - */ +// The slash commands the channel type offers, as the server reports them. const availableCommandsSelector = (state: ChannelConfig) => ({ availableCommands: state.availableCommands, }); @@ -241,8 +229,8 @@ const channelStatusSelector = (state: ChannelDataState & ChannelLifecycleState) }); /** - * The providers a message list and a composer need, shared by `` and ``. Each of - * those owns its own lifecycle (watching, loading, marking read) and renders this around its children. + * The providers a message list and composer need, shared by `` and ``, which each + * own their lifecycle (watching, loading, marking read) and render this around their children. */ export const ChannelShell = (props: PropsWithChildren) => { const { @@ -296,7 +284,7 @@ export const ChannelShell = (props: PropsWithChildren) => { hasCameraPicker = isImagePickerAvailable(), hasCommands, hasCreatePoll, - // If pickDocument isn't available, default to hiding the file picker + // The file picker is hidden when no document picker is available. hasFilePicker = isDocumentPickerAvailable(), hasImagePicker = isImagePickerAvailable() || isImageMediaLibraryAvailable(), hasPendingInitialTargetLoad, @@ -355,8 +343,8 @@ export const ChannelShell = (props: PropsWithChildren) => { ) ?? { pollsEnabled: false }; const pollCreationEnabled = !pendingDisposal && !!channel?.id && pollsEnabled; - // Sent directly rather than through `channel.stopTyping()`, which does nothing once the socket is - // closed, and `` usually closes it on background before this runs. + // Sent as an event because `channel.stopTyping()` does nothing once the socket is closed, which + // `` usually does on background before this runs. const handleAppBackground = useCallback(() => { if (!channel.isTyping || !channel.data?.own_capabilities?.includes('send-typing-events')) { return; @@ -371,16 +359,8 @@ export const ChannelShell = (props: PropsWithChildren) => { useAppStateListener(undefined, handleAppBackground); - /** - * Channel configs for use in disabling local functionality. - * Nullish coalescing is used to give first priority to props to override - * the server settings. Then priority to server settings to override defaults. - * - * Read from the channel's *resolved* configuration, which carries the server's command list as - * `availableCommands`. That is reactive, so the list no longer has to be re-read imperatively on - * every render — and it no longer throws for a channel pending disposal, which is why the previous - * try/catch wrapper is gone. - */ + // Read from the channel's resolved configuration. The `hasCommands` prop, when set, takes + // priority over the server's command list. const { availableCommands } = useStateStore(channel?.configState, availableCommandsSelector) ?? { availableCommands: [], }; diff --git a/package/src/components/Channel/hooks/useChannelLifecycle.ts b/package/src/components/Channel/hooks/useChannelLifecycle.ts index 0943db6aea..a00c73251d 100644 --- a/package/src/components/Channel/hooks/useChannelLifecycle.ts +++ b/package/src/components/Channel/hooks/useChannelLifecycle.ts @@ -11,10 +11,9 @@ import { useNotificationApi } from '../../Notifications'; const DEFAULT_MESSAGE_LIST_PAGE_SIZE = 25; /** - * Whether the integrator registered a message page size through `client.config` — under - * `channel.messagePaginator` or the general `messagePaginator` key, the two the paginator resolves its - * own `pageSize` from. The default is applied only when neither did: the `pageSize` setter is a runtime - * patch, which outranks every declarative layer and would silently discard the registration. + * Whether a message page size is registered through `client.config`, under + * `channel.messagePaginator` or `messagePaginator`. The default page size is applied only when none + * is, as the `pageSize` setter outranks every registered config. */ const hasRegisteredMessagePageSize = (client: StreamChat) => client.config.getConfig('channel')?.messagePaginator?.pageSize !== undefined || @@ -29,12 +28,18 @@ export type UseChannelLifecycleParams = Pick & { }; /** - * A hook encapsulating everything `` does to a channel while it is mounted. The first load - * (watch, a cold open's first page, the unread snapshot, the jump to the first unread, mark read on - * open etc.), activation and mark read after a reconnect. + * Everything `` does to its channel while mounted: the first load (watch, first page, + * unread snapshot, jump to the first unread, mark read), activation, and mark read after a + * reconnect. * - * Returns whether the list is already aimed at a message, which the list reads to keep its initial - * anchor from overriding that jump. + * @param params.channel The channel to manage. + * @param params.client The chat client. + * @param params.initialScrollToFirstUnreadMessage Jumps to the first unread message on open. + * @param params.initializeOnMount Calls `channel.watch()` on mount when the channel needs it. + * @param params.markReadOnMount Marks the channel as read on mount. + * @param params.t The translation function. + * @returns `hasPendingInitialTargetLoad`, which tells the list whether a jump to a message is + * pending so its initial anchor does not override it. */ export const useChannelLifecycle = ({ channel, @@ -61,13 +66,9 @@ export const useChannelLifecycle = ({ }); }); - /** - * Whether something already jumped the list to a message, i.e an integrator calling - * `channel.messagePaginator.jumpToMessage()` before or while the channel mounts. A jump leaves a - * focus signal on the paginator, and while it is set the channel does not jump to the first unread. - * - * Read on demand rather than subscribed to, since only effects ask. - */ + // Whether something already jumped the list to a message, such as `jumpToMessage()` called + // before or while mounting. That leaves a focus signal on the paginator, which suppresses the + // jump to the first unread. Read on demand since only effects ask. const hasFocusTarget = useStableCallback( () => !!channel.messagePaginator.messageFocusSignal.getLatestValue().signal, ); @@ -84,9 +85,7 @@ export const useChannelLifecycle = ({ return hasFocusTarget() || shouldLoadInitialChannelAtFirstUnreadMessage(); }); - // markRead is not placed on the ChannelContext; the message lists own their own throttled - // instance via useMarkRead(channel). The lifecycle needs its own for mark-read on open and after a - // reconnect. + // The message lists own their own throttled `markRead`, so the lifecycle needs its own. const markRead = useMarkRead(channel); useEffect(() => { @@ -97,7 +96,7 @@ export const useChannelLifecycle = ({ return; } - // a draft has no id and is marked initialized and querying it would create it on the server + // A draft has no ID and is marked initialized, and querying it would create it on the server. if (channel.isProvisional && channel.initialized) { return; } @@ -119,14 +118,13 @@ export const useChannelLifecycle = ({ } } - // we seed the paginator on cold open (push/deep link for example) + // Cold open (push or deep link): seed the paginator. if (!channel.messagePaginator.state.getLatestValue().items?.length) { await channel.messagePaginator.reload(); } - // A reopened channel keeps its paginator and with it the unread snapshot from the first open. - // Seed it again from the current read state so the unread separator, the unread banner and - // the jump to the first unread match what is unread now. + // A reopened channel keeps its paginator and its first-open unread snapshot, so reseed it + // from the current read state. channel.messagePaginator.seedUnreadSnapshot(); if (shouldLoadAtFirstUnread) { @@ -140,7 +138,7 @@ export const useChannelLifecycle = ({ } if (unreadCount > 0 && markReadOnMount) { - // needed to keep the unread banner stable + // Keeps the unread banner stable. await markRead({ updateChannelUnreadState: false }); } }; @@ -152,10 +150,9 @@ export const useChannelLifecycle = ({ useEffect(() => channel?.activate(), [channel]); useEffect(() => { - // Mark read has to wait for `connection.recovered`, as it is dispatched once the reloads have - // landed, so `hasMoreHead` read here reflects the refreshed window. Channel view only, and only - // when that window is at the newest, only if the user has paginated up into older history so leave - // their read state alone. + // Waits for `connection.recovered`, which fires after the reloads land, so `hasMoreHead` + // reflects the refreshed window. Marks read only when that window is at the newest message, + // leaving the read state alone when the user has paginated up into older history. const { unsubscribe } = client.on('connection.recovered', () => { if (channel.messagePaginator.hasMoreHead) { return; diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index b666e95edd..b68f35f162 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -25,7 +25,7 @@ export const useCreateChannelContext = ({ maxTimeBetweenGroupedMessages, hasPendingInitialTargetLoad, }), - // Keyed on the instance as well, since a superseded instance shares its id with its successor + // Keyed on the instance too, as a superseded instance shares its ID with its successor. // eslint-disable-next-line react-hooks/exhaustive-deps [channel, disabled], ); diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts index dca429b52a..5dc85edb38 100644 --- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts +++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts @@ -26,9 +26,7 @@ export const useCreateInputMessageInputContext = ({ setInputRef, showPollCreationDialog, }: InputMessageInputContextValue & { - /** - * To ensure we allow re-render, when channel is changed - */ + /** Re-creates the context when the channel changes. */ channelId?: string; }) => { const inputMessageInputContext: InputMessageInputContextValue = useMemo( diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 42b32a72a4..0698f87a9d 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -48,9 +48,7 @@ export const useCreateMessagesContext = ({ supportedReactions, urlPreviewType, }: MessagesContextValue & { - /** - * To ensure we allow re-render, when channel is changed - */ + /** Re-creates the context when the channel changes. */ channelId?: string; }) => { const additionalTouchablePropsLength = Object.keys(additionalPressableProps || {}).length; diff --git a/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts b/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts index 05d93f88b1..d76303e30b 100644 --- a/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts +++ b/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts @@ -22,8 +22,7 @@ export const useCreateOwnCapabilitiesContext = ({ channel: Channel; overrideCapabilities?: Partial; }) => { - // Sourced reactively from channel.state (kept up to date by the client - // on watch/query and `capabilities.changed`). + // Updated by the client on watch, query and `capabilities.changed`. const { ownCapabilities } = useStateStore(channel.state, selector); const overrideCapabilitiesKey = overrideCapabilities diff --git a/package/src/components/Channel/hooks/useSupersededChannelSwap.ts b/package/src/components/Channel/hooks/useSupersededChannelSwap.ts index 851fe27c30..bf9b1c7443 100644 --- a/package/src/components/Channel/hooks/useSupersededChannelSwap.ts +++ b/package/src/components/Channel/hooks/useSupersededChannelSwap.ts @@ -7,16 +7,19 @@ const activeSelector = ({ active }: ChannelStateData) => ({ active }); const editingAuditStateSelector = (state: EditingAuditState) => state; /** - * The instance `` renders: the one that superseded `channel` (it gets the events from then - * on), as stream-chat-react's `SupersededChannelSwap` does. It waits while the successor is open - * elsewhere and this composer isn't empty, as stream-chat only moves a composer into an unused one. + * The instance `` renders: the one that superseded `channel`, which receives the events + * from then on. The swap waits while the successor is open elsewhere and this composer is not + * empty, as stream-chat only moves a composer into an unused instance. + * + * @param channel The channel, or `undefined` without one. + * @returns The successor when it supersedes `channel` and the swap is not waiting, else `channel`. */ export const useSupersededChannelSwap = ( channel: T, ): T | Channel => { const { supersededBy } = useStateStore(channel?.state, supersededBySelector) ?? {}; const successorActive = useStateStore(supersededBy?.state, activeSelector)?.active ?? false; - // re-rendered on every composer change, so the emptiness read below stays current + // Re-renders on every composer change so the emptiness read below stays current. useStateStore(channel?.messageComposer.editingAuditState, editingAuditStateSelector); const composerIsEmpty = channel?.messageComposer.compositionIsEmpty ?? true; diff --git a/package/src/components/ChannelDetails/ChannelDetails.tsx b/package/src/components/ChannelDetails/ChannelDetails.tsx index 0d4f5e11e6..da0ad8545b 100644 --- a/package/src/components/ChannelDetails/ChannelDetails.tsx +++ b/package/src/components/ChannelDetails/ChannelDetails.tsx @@ -10,9 +10,7 @@ import { NotificationList } from '../Notifications/NotificationList'; import { NotificationTargetProvider } from '../Notifications/NotificationTargetContext'; export type ChannelDetailsProps = { - /** - * Fired when the back button is pressed on the channel details header. - */ + /** Fired when the back button in the header is pressed. */ onBack?: () => void; }; diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsActionsSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsActionsSection.tsx index 4e2db5c359..570f349ac2 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsActionsSection.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsActionsSection.tsx @@ -106,17 +106,12 @@ const UserMuteToggleRow = ({ item }: { item: ChannelActionItem }) => { export type ChannelDetailsActionsSectionProps = { /** - * Customize the list of action items rendered in the channel details actions section. - * - * Receives the default items the SDK produces for the current channel and returns the - * final list to render. Use this to filter, reorder, replace, or add items. - * - * The SDK still wires `onChannelDismiss` into the resulting `leave`, `deleteChannel`, and - * `block` items (matched by `id`) after this callback runs, so those actions continue to - * dismiss the screen on success regardless of how the items are customized. + * Receives the default action items for the channel and returns the list to render, to filter, + * reorder, replace or add items. `onChannelDismiss` is wired into the resulting `leave`, + * `deleteChannel` and `block` items (matched by `id`) after this callback runs. */ getChannelActionItems?: GetChannelActionItems; - /** Fired after the channel is no longer available to the current user (delete, leave, or block actions). */ + /** Fired once the channel is gone for the user (after delete, leave or block). */ onChannelDismiss?: () => void; }; diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx index 9097b1cb66..72f61f544c 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsEditButton.tsx @@ -9,9 +9,9 @@ import { useCanEditChannel } from '../../../hooks/useCanEditChannel'; import { Button, ButtonProps } from '../../ui/Button/Button'; export type ChannelDetailsEditButtonProps = { - /** Override the default behavior, which opens the Edit modal. */ + /** Overrides the default behavior, which opens the edit modal. */ onPress?: () => void; - /** Style forwarded to the underlying Button. */ + /** Style forwarded to the underlying `Button`. */ style?: ButtonProps['style']; }; @@ -25,9 +25,8 @@ export const ChannelDetailsEditButton = ({ const isVisible = useCanEditChannel(channel); const [editModalVisible, setEditModalVisible] = useState(false); - // The built-in modal is only used by the default press behavior. When a custom handler - // (onPress prop) is provided, the consumer owns the navigation/modal, so we must not render - // the built-in one. + // With a custom `onPress` the consumer owns the navigation or modal, so the built-in modal is + // not rendered. const usesDefaultModal = !onPress; const handleEditPress = useCallback(() => { diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx index 26c2b76974..891214d49e 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsMemberSection.tsx @@ -14,9 +14,7 @@ import { primitives } from '../../../theme'; import { useChannelDetailsMembersPreview } from '../hooks/useChannelDetailsMembersPreview'; export type ChannelDetailsMemberSectionProps = { - /** - * Fired when the user taps the "view all members" button. By default it opens the members bottom sheet. - */ + /** Fired when the "view all members" button is pressed. Opens the built-in modal when unset. */ onViewAllMembersPress?: () => void; }; diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx index 4054381167..d98517fdc7 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsNavHeader.tsx @@ -12,11 +12,11 @@ import { primitives } from '../../../theme'; import { Button } from '../../ui/Button/Button'; export type ChannelDetailsNavHeaderProps = { - /** Content rendered on the trailing side of the header (e.g. the edit button). */ + /** Content on the trailing side of the header, such as the edit button. */ action?: React.ReactNode; /** Fired when the back button is pressed. The back button only renders when provided. */ onBack?: () => void; - /** Override the auto-resolved screen title (1:1 → "Contact Info", group → "Group Info"). */ + /** Overrides the default title ("Contact Info" for direct chats, "Group Info" for groups). */ title?: string; }; diff --git a/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx b/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx index afbfad283a..dc4c07136a 100644 --- a/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx +++ b/package/src/components/ChannelDetails/components/ChannelDetailsNavigationSection.tsx @@ -18,12 +18,9 @@ import { export type ChannelDetailsNavigationSectionProps = { /** - * Customize the navigation rows rendered in the channel details navigation section. - * - * Receives the built-in `defaultItems` (and a `context`) and returns the rows to render. - * Map over `defaultItems` to override a row's `onPress` (e.g. to push your own screen) or - * to add/remove rows. Any row whose `onPress` you leave untouched keeps its built-in - * behavior (opening the built-in modal), including sections added in future SDK versions. + * Receives the built-in `defaultItems` (and a `context`) and returns the rows to render. Override + * a row's `onPress` to push your own screen, or add and remove rows. A row whose `onPress` is + * untouched keeps opening the built-in modal. */ getNavigationItems?: GetChannelDetailsNavigationItems; }; diff --git a/package/src/components/ChannelDetails/components/ChannelEditDetailsForm.tsx b/package/src/components/ChannelDetails/components/ChannelEditDetailsForm.tsx index 8aabbcbfae..04e709c140 100644 --- a/package/src/components/ChannelDetails/components/ChannelEditDetailsForm.tsx +++ b/package/src/components/ChannelDetails/components/ChannelEditDetailsForm.tsx @@ -7,21 +7,17 @@ import type { GlobalFileUploadRequest } from '../../../types/types'; import { NotificationList } from '../../Notifications/NotificationList'; export type ChannelEditDetailsFormProps = { - /** Called when the form is dismissed via the close button or after a successful save. */ + /** Called when the form is dismissed with the close button or after a successful save. */ onClose: () => void; - /** - * Compress image with quality (from 0 to 1, where 1 is best quality) applied - * to the channel image picked during editing. - */ + /** Compression quality, from 0 to 1 (best), applied to the channel image picked while editing. */ compressImageQuality?: number; - /** Override the upload request used to upload the channel image. */ + /** Overrides the request that uploads the channel image. */ doFileUploadRequest?: GlobalFileUploadRequest; }; /** - * Renders the channel edit details form: a header with a confirm action, the - * editable fields, and the notification list. Wrap-free — mount it wherever the - * edit UI should appear (e.g. inside a modal or a navigation screen). + * The channel edit form: a header with a confirm action, the editable fields and the notification + * list. Mount it wherever the edit UI should appear, such as a modal or a screen. */ export const ChannelEditDetailsForm = ({ compressImageQuality, diff --git a/package/src/components/ChannelDetails/components/ChannelEditDetailsFormContent.tsx b/package/src/components/ChannelDetails/components/ChannelEditDetailsFormContent.tsx index fadc3acd77..e0a0fab75b 100644 --- a/package/src/components/ChannelDetails/components/ChannelEditDetailsFormContent.tsx +++ b/package/src/components/ChannelDetails/components/ChannelEditDetailsFormContent.tsx @@ -65,7 +65,8 @@ export const ChannelEditDetailsFormContent = () => { })(); }, [pendingAction, pickImageFromNativePicker, sheetVisible, store, takePhoto]); - // `undefined` = untouched (show live channel image), `File` = picked uri, `null` = reset. + // `undefined` is untouched (shows the live channel image), a `File` is the picked image and + // `null` is a reset. const isPreview = updatedImage !== undefined; const previewUri = updatedImage ? updatedImage.uri : null; diff --git a/package/src/components/ChannelDetails/components/ChannelEditDetailsFormHeader.tsx b/package/src/components/ChannelDetails/components/ChannelEditDetailsFormHeader.tsx index ad485216b8..51bdc97691 100644 --- a/package/src/components/ChannelDetails/components/ChannelEditDetailsFormHeader.tsx +++ b/package/src/components/ChannelDetails/components/ChannelEditDetailsFormHeader.tsx @@ -17,7 +17,7 @@ const LoadingButtonIcon = ({ height, width }: { height?: number; width?: number ); export type ChannelEditDetailsFormHeaderProps = { - /** Called when the form is dismissed via the close button or after a successful save. */ + /** Called when the form is dismissed with the close button or after a successful save. */ onClose: () => void; }; @@ -37,7 +37,7 @@ export const ChannelEditDetailsFormHeader = ({ onClose }: ChannelEditDetailsForm await submit(); onClose(); } catch { - // failure notification already surfaced by the channel action + // The channel action already surfaces a failure notification. } finally { setSaving(false); } diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembersButton.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembersButton.tsx index 06f878c37e..91853c76a0 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelAddMembersButton.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembersButton.tsx @@ -8,9 +8,9 @@ import { Button, ButtonProps } from '../../../ui/Button/Button'; import { ChannelDetailsModal } from '../modal/Modal'; export type ChannelAddMembersButtonProps = { - /** Override the default behavior, which opens the Add-members modal. */ + /** Overrides the default behavior, which opens the add-members modal. */ onPress?: () => void; - /** Style forwarded to the underlying Button. */ + /** Style forwarded to the underlying `Button`. */ style?: ButtonProps['style']; testID?: string; variant?: 'icon' | 'text'; @@ -28,8 +28,7 @@ export const ChannelAddMembersButton = ({ const isVisible = useCanAddMembersToChannel(channel); const [isAddMembersVisible, setAddMembersVisible] = useState(false); - // The built-in modal is only used by the default press behavior. When a custom handler - // (onPress prop) is provided, the consumer owns the modal, so we must not render the built-in one. + // With a custom `onPress` the consumer owns the modal, so the built-in one is not rendered. const usesDefaultModal = !onPress; const handlePress = useCallback(() => { diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembersForm.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembersForm.tsx index f2d819a3ae..e2e7f969c0 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelAddMembersForm.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembersForm.tsx @@ -8,20 +8,15 @@ import { useComponentsContext } from '../../../../contexts/componentsContext/Com import { NotificationList } from '../../../Notifications/NotificationList'; export type ChannelAddMembersFormProps = { - /** Called when the form is dismissed via the close button or after members are added. */ + /** Called when the form is dismissed with the close button or after members are added. */ onClose: () => void; - /** - * A custom `UserSearchSource` used to query and paginate the users to add. - * Overrides the source the provider creates by default (pre-configured to - * autocomplete by `name`). - */ + /** Overrides the default `UserSearchSource`, which autocompletes users by `name`. */ searchSource?: UserSearchSource; }; /** - * Renders the add-members form: a header with a confirm action, the user search - * list, and the notification list. Mount it wherever the add-members UI should - * appear (e.g. inside a modal or a navigation screen). + * The add-members form: a header with a confirm action, the user search list and the notification + * list. Mount it wherever the add-members UI should appear, such as a modal or a screen. */ export const ChannelAddMembersForm = ({ onClose, searchSource }: ChannelAddMembersFormProps) => { const { channel } = useChannelDetailsContext(); diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormContent.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormContent.tsx index 9399fccc4e..ae2be72daa 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormContent.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormContent.tsx @@ -18,12 +18,7 @@ import { EmptySearchResult } from '../../../UIComponents/EmptySearchResult'; import { SearchInput } from '../../../UIComponents/SearchInput'; export type ChannelAddMembersFormContentProps = { - /** - * Besides the existing default behavior of the user list, you can attach - * additional props to the underlying React Native FlatList. - * - * See https://reactnative.dev/docs/flatlist#props for the full list. - */ + /** Additional props passed to the underlying `FlatList`. */ additionalFlatListProps?: Partial>; }; @@ -93,7 +88,7 @@ export const ChannelAddMembersFormContent = ({ ); const loadMore = useCallback(() => { - // hasNext true by default, !!users prevents calling search on initial load + // `hasNext` starts true, so `users` is checked to avoid searching on the initial load. if (hasNext && !!users) { searchSource.search(); } diff --git a/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormHeader.tsx b/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormHeader.tsx index 72419e89ed..95b6c515b3 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormHeader.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelAddMembersFormHeader.tsx @@ -17,7 +17,7 @@ const LoadingButtonIcon = ({ height, width }: { height?: number; width?: number ); export type ChannelAddMembersFormHeaderProps = { - /** Called when the form is dismissed via the close button or after members are added. */ + /** Called when the form is dismissed with the close button or after members are added. */ onClose: () => void; }; @@ -46,7 +46,7 @@ export const ChannelAddMembersFormHeader = ({ onClose }: ChannelAddMembersFormHe await submit(); onClose(); } catch { - // failure notification already surfaced by the channel action + // The channel action already surfaces a failure notification. } finally { setAddingMembers(false); } diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx index 550bfb4219..83778152cf 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelMemberActionsSheet.tsx @@ -19,11 +19,8 @@ export type ChannelMemberActionsSheetProps = { onClose: () => void; visible: boolean; /** - * Customize the list of action items rendered in the per-member actions bottom sheet. - * - * Receives the default items the SDK produces for the tapped member (e.g. `muteUser`, - * `block`) and returns the final list to render. Use this to filter, reorder, replace, - * or add items — for example, to inject a "Send Direct Message" action in your app. + * Receives the default action items for the tapped member (such as `muteUser` and `block`) and + * returns the list to render, to filter, reorder, replace or add items. */ getChannelMemberActionItems?: GetChannelMemberActionItems; }; diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx index ba882b345b..c012ae4d3d 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx @@ -19,17 +19,15 @@ export type ChannelMemberItemSize = 'sm' | 'lg'; export type ChannelMemberItemProps = { member: ChannelMemberResponse; /** - * Override the roles shown next to the member. Receives `{ channel, defaultRoleLabels, member, t }` - * and returns an array of `{ key, label }` (or an empty array to render none). Defaults to the - * built-in Owner / Admin / Moderator logic, returning every role that applies. + * Overrides the roles shown next to the member. Receives + * `{ channel, defaultRoleLabels, member, t }` and returns `{ key, label }` roles, or an empty + * array for none. Defaults to the Owner, Admin and Moderator roles that apply. */ getMemberRoles?: GetMemberRoles; onPress?: (member: ChannelMemberResponse) => void; /** - * Visual size of the row. - * - `'sm'` (default) renders the compact list row with a small avatar, regular-weight name, and a trailing role label. - * - `'lg'` renders a profile-style header with a larger avatar, semibold name, larger status, and no role label. - * Useful for sheet / modal headers (e.g. the per-member actions bottom sheet). + * Size of the row. `'sm'` is the compact list row with a trailing role label. `'lg'` is a + * profile-style header with a larger avatar and no role label. Defaults to `'sm'`. */ size?: ChannelMemberItemSize; testID?: string; diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx index e48eb428b4..607c77ce58 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelMemberList.tsx @@ -38,19 +38,10 @@ const listStateSelector = (state: SearchSourceState) => ( }); export type ChannelMemberListProps = { - /** - * Besides the existing default behavior of the members list, you can attach - * additional props to the underlying React Native FlatList. - * - * See https://reactnative.dev/docs/flatlist#props for the full list. - */ + /** Additional props passed to the underlying `FlatList`. */ additionalFlatListProps?: Partial>; searchInputProps?: SearchInputProps; - /** - * A custom `ChannelMemberSearchSource` used to query and paginate the member - * list. Overrides the source the provider creates by default (pre-configured - * to autocomplete by `name`). - */ + /** Overrides the default `ChannelMemberSearchSource`, which autocompletes by `name`. */ searchSource?: ChannelMemberSearchSource; }; @@ -78,8 +69,7 @@ const ChannelMemberListContent = ({ const memberCount = useChannelMemberCount(channel); - // Fetch members on mount and when the member count changes - // Member count changes used when add member modal is opened above member list modal + // Refetches when the member count changes, as when the add-members modal opens over this list. useEffect(() => { if (!searchSource.state.getLatestValue().searchQuery) { searchSource.resetStateAndActivate(); @@ -117,7 +107,7 @@ const ChannelMemberListContent = ({ ); const loadMore = useCallback(() => { - // hasNext is true by default, !!members prevents calling search on initial load + // `hasNext` starts true, so `members` is checked to avoid searching on the initial load. if (hasNext && !!members) { searchSource.search(); } @@ -175,9 +165,7 @@ const ChannelMemberListContent = ({ ); }; -/** - * Lists all channel members with the ability to search them. - */ +/** Searchable list of the channel's members. */ export const ChannelMemberList = ({ searchSource, ...props }: ChannelMemberListProps = {}) => { const { channel } = useChannelDetailsContext(); const notificationHostId = channel?.cid ? `channel-member-list:${channel.cid}` : undefined; diff --git a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentItem.tsx b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentItem.tsx index dc9439247d..b202bf2e0c 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentItem.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentItem.tsx @@ -9,8 +9,7 @@ import { FilePreview } from '../../../Attachment/FilePreview'; import { openUrlSafely } from '../../../Attachment/utils/openUrlSafely'; /** - * The shape passed to `FileAttachmentItem`'s `onPress` callback, identifying the rendered - * attachment and the message it belongs to. + * Passed to the `onPress` callback of `FileAttachmentItem`: the attachment and its message. */ export type FileAttachmentItemPressParams = { attachment: Attachment; @@ -18,14 +17,11 @@ export type FileAttachmentItemPressParams = { }; export type FileAttachmentItemProps = { - /** The file/audio attachment to render. */ + /** The file or audio attachment to render. */ attachment: Attachment; /** The message the attachment belongs to. */ message: MessageResponse; - /** - * Fired with the pressed attachment and its message. When provided, this overrides the default - * behavior of opening the attachment's `asset_url`. - */ + /** Overrides the default behavior of opening the attachment's `asset_url`. */ onPress?: (params: FileAttachmentItemPressParams) => void; }; @@ -72,7 +68,7 @@ const useStyles = () => { paddingVertical: primitives.spacingXs, }, }), - // FilePreview's default container has a hardcoded width; stretch it to the row width. + // The default `FilePreview` container has a fixed width, so stretch it to the row. filePreview: StyleSheet.create({ container: { width: '100%' }, title: { diff --git a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx index 849b279e8a..5f8c608dd8 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentList.tsx @@ -34,18 +34,9 @@ import { } from '../../hooks/useFileAttachmentListSections'; export type FileAttachmentListProps = { - /** - * Besides the existing default behavior of the file attachment list, you can attach - * additional props to the underlying React Native SectionList. - * - * See https://reactnative.dev/docs/sectionlist#props for the full list. - */ + /** Additional props passed to the underlying `SectionList`. */ additionalSectionListProps?: Partial>; - /** - * A custom `MessageSearchSource` used to query and paginate the file/audio - * attachments. Overrides the source the provider creates by default - * (pre-configured to fetch file/audio attachments, newest first). - */ + /** Overrides the default `MessageSearchSource` (file and audio attachments, newest first). */ searchSource?: MessageSearchSource; }; @@ -129,7 +120,7 @@ const FileAttachmentListContent = ({ additionalSectionListProps }: FileAttachmen ); const loadMore = useCallback(() => { - // hasNext is true by default, !!messages prevents calling search on initial load + // `hasNext` starts true, so `messages` is checked to avoid searching on the initial load. if (hasNext && !!messages) { searchSource.search(); } diff --git a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentListSectionHeader.tsx b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentListSectionHeader.tsx index 18ded6a18e..a96858b414 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentListSectionHeader.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/FileAttachmentListSectionHeader.tsx @@ -5,7 +5,7 @@ import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { primitives } from '../../../../theme'; export type FileAttachmentListSectionHeaderProps = { - /** The already-formatted section title (e.g. "March 2026"). */ + /** The formatted section title, such as "March 2026". */ title: string; }; diff --git a/package/src/components/ChannelDetails/components/navigation-section/MediaItem.tsx b/package/src/components/ChannelDetails/components/navigation-section/MediaItem.tsx index 287c2c993c..0a6305cfc8 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/MediaItem.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/MediaItem.tsx @@ -15,9 +15,8 @@ import { getDurationLabelFromDuration } from '../../../../utils/utils'; import { UserAvatar } from '../../../ui/Avatar/UserAvatar'; /** - * The shape passed to `MediaItem`'s `onPress` callback, identifying the rendered attachment, the - * message it belongs to, and the tile's native node handle (used as the open-animation origin when - * launching the fullscreen image gallery). + * Passed to the `onPress` callback of `MediaItem`: the attachment, its message, and the tile's + * native node handle, which is the open-animation origin of the image gallery. */ export type MediaItemPressParams = { attachment: Attachment; @@ -26,16 +25,13 @@ export type MediaItemPressParams = { }; export type MediaItemProps = { - /** The image/video attachment rendered by this tile. */ + /** The image or video attachment rendered by this tile. */ attachment: Attachment; /** The message the attachment belongs to. */ message: MessageResponse; /** Side length of the square tile, in points. */ size: number; - /** - * Fired with the tile's attachment and message when the tile is pressed. The media list passes - * its gallery-opening handler here via `renderItem`. - */ + /** Fired when the tile is pressed. */ onPress?: (params: MediaItemPressParams) => void; }; diff --git a/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx b/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx index b80d68425d..929e4b7d14 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/MediaList.tsx @@ -36,18 +36,9 @@ import { EmptyList } from '../../../UIComponents/EmptyList'; import { type MediaTile, useMediaList } from '../../hooks/useMediaList'; export type MediaListProps = { - /** - * Besides the existing default behavior of the media list, you can attach additional props to - * the underlying React Native FlatList. - * - * See https://reactnative.dev/docs/flatlist#props for the full list. - */ + /** Additional props passed to the underlying `FlatList`. */ additionalFlatListProps?: Partial>; - /** - * A custom `MessageSearchSource` used to query and paginate the media grid. - * Overrides the source the provider creates by default (pre-configured to - * fetch image/video attachments, newest first). - */ + /** Overrides the default `MessageSearchSource` (image and video attachments, newest first). */ searchSource?: MessageSearchSource; }; @@ -127,9 +118,8 @@ const MediaListContent = ({ additionalFlatListProps }: MediaListProps) => { [width], ); - // Opens the fullscreen gallery over the whole loaded collection, selecting the tapped attachment. - // Mirrors the in-message gallery (`components/Attachment/Gallery.tsx`), but passes every loaded - // message so the viewer can swipe across all media in the list rather than a single message. + // Opens the gallery over every loaded message, so the viewer can swipe across all the media in + // the list, with the tapped attachment selected. const handlePressItem = useCallback( ({ attachment, requesterNode }: MediaItemPressParams) => { const url = getUrlOfImageAttachment(attachment); @@ -137,7 +127,7 @@ const MediaListContent = ({ additionalFlatListProps }: MediaListProps) => { return; } if (attachment.type === FileTypes.Video && !isVideoPlayerAvailable()) { - // Safeguard for customizations that render videos without a player installed. + // Without a video player (possible with customizations), fall back to opening the URL. openUrlSafely(url); return; } @@ -164,7 +154,7 @@ const MediaListContent = ({ additionalFlatListProps }: MediaListProps) => { ); const loadMore = useCallback(() => { - // hasNext is true by default, !!messages prevents calling search on initial load + // `hasNext` starts true, so `messages` is checked to avoid searching on the initial load. if (hasNext && !!messages) { searchSource.search(); } diff --git a/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx b/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx index b4451633b5..6f869998c8 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/MediaListLoadingSkeleton.tsx @@ -11,14 +11,12 @@ const NUMBER_OF_ROWS = 6; const GRID_GAP = primitives.spacingXxxs; const ANIMATION_TIME = 1000; -/** - * Grid of shimmering placeholder tiles shown while the media list is loading. - */ +/** Grid of shimmering placeholder tiles shown while the media list loads. */ export const MediaListLoadingSkeleton = () => { const { theme: { semantics }, } = useTheme(); - // Same derivation as `MediaList`, so the skeleton's tiles match the grid that replaces it. + // Derived like in `MediaList`, so the tiles match the loaded grid. const width = useWindowContentWidth(); const styles = useStyles(); diff --git a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx index 137880b80c..180fd8b896 100644 --- a/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx +++ b/package/src/components/ChannelDetails/components/navigation-section/PinnedMessageList.tsx @@ -24,19 +24,10 @@ import { EmptySearchResult } from '../../../UIComponents/EmptySearchResult'; import { SearchInput, SearchInputProps } from '../../../UIComponents/SearchInput'; export type PinnedMessageListProps = { - /** - * Besides the existing default behavior of the pinned message list, you can attach - * additional props to the underlying React Native FlatList. - * - * See https://reactnative.dev/docs/flatlist#props for the full list. - */ + /** Additional props passed to the underlying `FlatList`. */ additionalFlatListProps?: Partial>; searchInputProps?: SearchInputProps; - /** - * A custom `MessageSearchSource` used to query and paginate the pinned - * messages. Overrides the source the provider creates by default - * (pre-configured to fetch pinned messages and search by `text`). - */ + /** Overrides the default `MessageSearchSource` (pinned messages, searched by `text`). */ searchSource?: MessageSearchSource; }; @@ -109,7 +100,7 @@ const PinnedMessageListContent = ({ ); const loadMore = useCallback(() => { - // hasNext is true by default, !!messages prevents calling search on initial load + // `hasNext` starts true, so `messages` is checked to avoid searching on the initial load. if (hasNext && !!messages) { searchSource.search(); } diff --git a/package/src/components/ChannelDetails/components/roles/RoleItem.tsx b/package/src/components/ChannelDetails/components/roles/RoleItem.tsx index 8adcf67c27..691021b509 100644 --- a/package/src/components/ChannelDetails/components/roles/RoleItem.tsx +++ b/package/src/components/ChannelDetails/components/roles/RoleItem.tsx @@ -8,15 +8,15 @@ import type { RoleLabel } from '../../hooks/members/useMemberRoles'; export type RoleItemProps = { role: RoleLabel; - /** Style applied to the badge label, layered over the theme override. */ + /** Style of the badge label, applied after the theme override. */ textStyle?: StyleProp; - /** Style applied to the badge container, layered over the theme override. */ + /** Style of the badge container, applied after the theme override. */ viewStyle?: StyleProp; }; /** - * Renders a single role badge (pill) next to a member row. `owner` roles use the accent - * (blue) palette; every other role uses the neutral (grey) palette. + * A role badge shown next to a member row. The `owner` role uses the accent palette and every other + * role the neutral one. */ export const RoleItem = ({ role, textStyle, viewStyle }: RoleItemProps) => { const { diff --git a/package/src/components/ChannelDetails/components/roles/RoleList.tsx b/package/src/components/ChannelDetails/components/roles/RoleList.tsx index d7cabf3551..de3215ec14 100644 --- a/package/src/components/ChannelDetails/components/roles/RoleList.tsx +++ b/package/src/components/ChannelDetails/components/roles/RoleList.tsx @@ -11,8 +11,8 @@ export type RoleListProps = { }; /** - * Renders the horizontal list of role badges shown next to a member row. Each badge is - * rendered by the overridable `RoleItem` component. Returns `null` when there are no roles. + * The role badges shown next to a member row, each rendered by `RoleItem`. Renders nothing without + * roles. */ export const RoleList = ({ roles }: RoleListProps) => { const { RoleItem } = useComponentsContext(); diff --git a/package/src/components/ChannelDetails/hooks/members/useMemberRoles.ts b/package/src/components/ChannelDetails/hooks/members/useMemberRoles.ts index 75b180a530..64ff4f8e66 100644 --- a/package/src/components/ChannelDetails/hooks/members/useMemberRoles.ts +++ b/package/src/components/ChannelDetails/hooks/members/useMemberRoles.ts @@ -5,10 +5,8 @@ import type { TranslationContextValue } from '../../../../contexts/translationCo import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext'; /** - * A single role shown next to a member row. - * - * - `key` is the raw role identifier (`'owner'`, `'admin'`, `'moderator'`, …). - * - `label` is the translated, user-facing text. + * A role shown next to a member row. `key` is the role identifier (`'owner'`, `'admin'`, + * `'moderator'`, ...) and `label` the translated text. */ export type RoleLabel = { key: string; @@ -16,13 +14,10 @@ export type RoleLabel = { }; /** - * Override the roles shown next to each member in the channel details screen. - * - * Receives `defaultRoleLabels` — the roles the default implementation computed for this member — - * so a custom implementation can reuse or extend them (e.g. `[...defaultRoleLabels, customRole]`), - * and returns the list of roles to render (or an empty array to render none). The default - * implementation marks members as `owner` (channel creator), `admin` (`user.role === 'admin'`) - * and `moderator` (`channel_role === 'channel_moderator'`), returning every role that applies. + * Overrides the roles shown next to each member. Receives the `defaultRoleLabels` computed for the + * member, to reuse or extend, and returns the roles to render, or an empty array for none. The + * defaults are `owner` (channel creator), `admin` (`user.role === 'admin'`) and `moderator` + * (`channel_role === 'channel_moderator'`). */ export type GetMemberRoles = (params: { channel: Channel; @@ -32,10 +27,11 @@ export type GetMemberRoles = (params: { }) => RoleLabel[]; /** - * Resolves the trailing role badges for a channel member row in the channel details screen. + * Resolves the role badges of a channel member row, in the order Owner, Admin, Moderator. * - * Returns every applicable role in the order Owner > Admin > Moderator. When a member matches - * none of the rules (and no custom `getMemberRoles` is provided), returns an empty array. + * @param member The channel member. + * @param getMemberRoles Overrides the default roles. + * @returns The roles, or an empty array when none apply. */ export const useMemberRoles = ( member: ChannelMemberResponse, diff --git a/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts b/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts index b9d9e09464..31042ec5c1 100644 --- a/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts +++ b/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts @@ -9,9 +9,11 @@ import { useChannelMembersState } from '../../ChannelList/hooks/useChannelMember import { useChannelOnlineMemberCount } from '../../ChannelList/hooks/useChannelOnlineMemberCount'; /** - * Resolves the subtitle status line shown under the channel title. - * - Direct chats: `t('Online')` when the other member is online, otherwise an empty string. - * - Group chats: `t('{{memberCount}} members, {{onlineCount}} online')`. + * Resolves the status line shown under the channel title: "Online" for a direct chat whose other + * member is online, an empty string when offline, and the member and online counts for a group. + * + * @param channel The channel. + * @returns The translated status text. */ export const useChannelDetailsMemberStatusText = (channel: Channel): string => { const { t } = useTranslationContext(); diff --git a/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts b/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts index b711393df5..1b2b20c6e7 100644 --- a/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts +++ b/package/src/components/ChannelDetails/hooks/useChannelDetailsNavigationItems.ts @@ -7,9 +7,8 @@ import { asDynamicKey } from '../../../i18n/utils'; import type { IconProps } from '../../../icons/utils/base'; /** - * Identifies a navigation row. The literals are the built-in sections rendered by default; - * consumers can also add their own rows with arbitrary section identifiers via `getNavigationItems`, - * so any string is allowed. + * Identifies a navigation row: one of the built-in sections or, for rows added through + * `getNavigationItems`, any string. */ export type ChannelDetailsNavigationSectionType = | 'pinned-messages' @@ -17,27 +16,21 @@ export type ChannelDetailsNavigationSectionType = | 'files' | string; -/** - * A single row in the channel details navigation section. - */ +/** A row in the channel details navigation section. */ export type ChannelDetailsNavigationItem = { - /** Icon rendered at the start of the row and reused in the built-in modal header. */ + /** Icon at the start of the row, reused in the built-in modal header. */ Icon: React.ComponentType; - /** Already-translated label rendered for the row and its built-in modal header. */ + /** Translated label of the row, reused in the built-in modal header. */ label: string; - /** Identifies which built-in section this row represents. */ + /** The section this row represents. */ section: ChannelDetailsNavigationSectionType; - /** - * Fired when the user taps the row. Leave unset to keep the built-in behavior (opening the - * built-in modal for the section); set it to route the row somewhere else (e.g. your own screen). - */ + /** Fired when the row is tapped. When unset, the row opens the built-in modal for its section. */ onPress?: () => void; }; /** - * Maps each navigation section to the icon (by its key in the components-context icons map) and - * (translatable) label rendered for its row and, when opened, its modal header. The declaration - * order also drives the order of the rendered rows. + * Maps each built-in section to its icon (a key of the components-context icons map) and label key. + * The declaration order is the order of the rows. */ const SECTION_CONFIG: Record< 'pinned-messages' | 'photos-and-videos' | 'files', @@ -58,11 +51,9 @@ export type ChannelDetailsNavigationItemsContext = { }; /** - * Customizes the navigation rows rendered by `ChannelDetailsNavigationSection`. Receives the - * built-in `defaultItems` (and a `context`) and returns the items to render. Map over - * `defaultItems` to set a row's `onPress` (e.g. to push your own screen), or add/remove rows. Any - * row whose `onPress` you leave unset keeps its built-in behavior (opening the built-in modal) — - * including sections added in future SDK versions. + * Customizes the rows rendered by `ChannelDetailsNavigationSection`. Receives the built-in + * `defaultItems` (and a `context`) and returns the rows to render. Set a row's `onPress` to route + * it elsewhere, or add and remove rows. A row without `onPress` opens the built-in modal. */ export type GetChannelDetailsNavigationItems = (params: { context: ChannelDetailsNavigationItemsContext; @@ -74,10 +65,11 @@ export const getChannelDetailsNavigationItems: GetChannelDetailsNavigationItems }) => defaultItems; /** - * Builds the navigation rows rendered by `ChannelDetailsNavigationSection`. Returns the items as a - * plain array — the section component owns the built-in modal and supplies the default open-modal - * behavior for any row without a custom `onPress`. Customize the rows by passing `getNavigationItems` - * to `ChannelDetails` (see {@link GetChannelDetailsNavigationItems}). + * Builds the rows rendered by `ChannelDetailsNavigationSection`, customized by + * {@link GetChannelDetailsNavigationItems}. + * + * @param params.getNavigationItems Customizes the default rows. + * @returns The navigation rows. */ export const useChannelDetailsNavigationItems = ({ getNavigationItems = getChannelDetailsNavigationItems, @@ -93,7 +85,7 @@ export const useChannelDetailsNavigationItems = ({ () => SECTIONS.map((section) => { const { icon, label } = SECTION_CONFIG[section]; - // `label` comes from SECTION_CONFIG, so the key is a runtime value here. + // The label key comes from `SECTION_CONFIG`, so it is only known at runtime. return { Icon: icons[icon], label: t(asDynamicKey(label)), section }; }), [icons, t], diff --git a/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts b/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts index 9b2e93422a..2f243b7819 100644 --- a/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts +++ b/package/src/components/ChannelDetails/hooks/useEditChannelImage.ts @@ -9,29 +9,23 @@ import { compressedImageURI } from '../../../utils/compressImage'; export type UseEditChannelImageResult = { /** - * Open the device's native image picker and return a single picked image - * with the compression configured on `ChannelDetails` already applied - * to its `uri`. Returns `undefined` if the user cancels or denies access. + * Opens the native image picker and resolves to the picked image, with `compressImageQuality` + * applied to its `uri`, or `undefined` when the user cancels or denies access. */ pickImageFromNativePicker: () => Promise; /** - * Launch the device's camera and return the captured image. Returns - * `undefined` if the user cancels or denies access. The native camera - * handler already honors `compressImageQuality`, so no extra compression - * pass is needed for camera files. + * Opens the camera and resolves to the captured image, or `undefined` when the user cancels or + * denies access. The native camera handler applies `compressImageQuality` itself. */ takePhoto: () => Promise; }; /** - * Hook that exposes the two image-acquisition flows used by the channel edit - * screen — camera capture and native gallery pick — using the same control - * flow and compression behavior as the message composer (`takeAndUploadImage` - * and `pickAndUploadImageFromNativePicker` in `MessageInputContext`). The - * hook intentionally does NOT upload the picked file; the consumer receives a - * `File` and decides what to do with it. + * The image acquisition flows of the channel edit screen: camera capture and native gallery pick. + * Neither uploads the file; the caller receives it. Reads `compressImageQuality` from + * `ChannelEditDetailsContext`. * - * Reads `compressImageQuality` from `ChannelEditDetailsContext`. + * @returns The `takePhoto` and `pickImageFromNativePicker` functions. */ export const useEditChannelImage = (): UseEditChannelImageResult => { const { compressImageQuality } = useChannelEditDetailsContext(); diff --git a/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts b/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts index 5d235b5565..448b49f434 100644 --- a/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts +++ b/package/src/components/ChannelDetails/hooks/useFileAttachmentListSections.ts @@ -13,20 +13,19 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran import { getDateString } from '../../../i18n/utils'; /** - * A single file/audio attachment paired with the message it belongs to. The file attachment list - * renders one row per attachment, so a message with multiple file attachments yields multiple rows. + * A file or audio attachment with its message. A message with several file attachments yields one + * tile per attachment. */ export type FileAttachmentTile = { attachment: Attachment; message: MessageResponse }; export type FileAttachmentSection = { data: FileAttachmentTile[]; title: string }; -/** - * Gathers the file/audio attachments from a message, excluding scraped/OG link-preview content. - */ +// File and audio attachments of a message, excluding scraped link previews. const getFileAttachments = (message: MessageResponse): Attachment[] => (message.attachments ?? []).filter( (attachment) => - // We provide mime_type here to avoid attachments with mime_type being categorized as file + // The attachment's own `mime_type` is passed as a supported video format so that attachments + // carrying one are not classified as files. (isFileAttachment( attachment, attachment?.custom?.mime_type ? [attachment.custom.mime_type] : [], @@ -36,14 +35,12 @@ const getFileAttachments = (message: MessageResponse): Attachment[] => ); /** - * Gathers and filters the file/audio attachments from a list of messages, then groups them into - * newest-first month sections for the file attachment list. Each section's `data` is a flat list - * of `{ attachment, message }` tiles; messages without renderable file attachments are skipped. + * Groups the file and audio attachments of the messages into month sections, in message order. + * Messages without file attachments are skipped. The month title follows the configured locale and + * can be customized through the `timestamp.FileAttachmentListSection` translation key. * - * The month label is produced through the shared `getDateString` + translation-key - * pipeline used by message timestamps (see `useUserActivityStatus`), so the format - * follows the configured locale and can be customized via the - * `timestamp/FileAttachmentListSection` translation key. + * @param messages The messages to gather attachments from. + * @returns The sections, each with a title and its `{ attachment, message }` tiles. */ export const useFileAttachmentListSections = ( messages?: MessageResponse[], diff --git a/package/src/components/ChannelDetails/hooks/useMediaList.ts b/package/src/components/ChannelDetails/hooks/useMediaList.ts index d9fea3e027..82e35603f2 100644 --- a/package/src/components/ChannelDetails/hooks/useMediaList.ts +++ b/package/src/components/ChannelDetails/hooks/useMediaList.ts @@ -5,18 +5,15 @@ import { type Attachment, isScrapedContent, type MessageResponse } from 'stream- import { FileTypes } from '../../../types/types'; /** - * A single image/video attachment paired with the message it belongs to. The media grid renders - * one tile per attachment, so a message with multiple media attachments yields multiple tiles. + * An image or video attachment with its message. A message with several media attachments yields + * one tile per attachment. */ export type MediaTile = { attachment: Attachment; message: MessageResponse; }; -/** - * Flattens search-result messages into one tile per renderable image/video attachment, applying - * the same attachment rules the message list uses to decide what counts as gallery media. - */ +// One tile per image or video attachment, excluding scraped link previews. const getMediaTiles = (messages: MessageResponse[] | undefined): MediaTile[] => { if (!messages) { return []; @@ -39,9 +36,11 @@ const getMediaTiles = (messages: MessageResponse[] | undefined): MediaTile[] => }; /** - * Gathers and filters the image/video attachments from a list of messages into a flat list of - * media tiles, ready to render in the media grid. Scraped/OG link-preview attachments and - * non-media attachments are excluded. + * Flattens the messages into one tile per image or video attachment. Scraped link previews and + * other attachment types are excluded. + * + * @param messages The messages to gather attachments from. + * @returns The media tiles. */ export const useMediaList = (messages?: MessageResponse[]): MediaTile[] => useMemo(() => getMediaTiles(messages), [messages]); diff --git a/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts b/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts index 52a43acf5f..83f740c5ab 100644 --- a/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts +++ b/package/src/components/ChannelDetails/hooks/useUserActivityStatus.ts @@ -8,14 +8,12 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran import { getDateString } from '../../../i18n/utils'; /** - * Returns the localized presence status string for a user: - * - `t('Online')` when `user.online === true` - * - `t('timestamp/UserActivityStatus')` (e.g. "Last seen 10 minutes ago") when offline - * with a valid `last_active` - * - `t('Offline')` otherwise (including `user === undefined` or an unparseable date) + * Returns the localized presence status of a user: "Online", a last-seen time such as "Last seen + * 10 minutes ago" (the `timestamp.UserActivityStatus` translation key) when offline with a known + * `last_active`, and "Offline" otherwise. * - * The relative time is produced through the shared `getDateString` + translation-key - * pipeline used by message timestamps, so the format follows the configured locale. + * @param user The user. + * @returns The translated status text. */ export const useUserActivityStatus = (user?: UserResponse): string => { const { t, tDateTimeParser } = useTranslationContext(); diff --git a/package/src/components/ChannelList/ChannelList.tsx b/package/src/components/ChannelList/ChannelList.tsx index 930b2a8215..8def47a320 100644 --- a/package/src/components/ChannelList/ChannelList.tsx +++ b/package/src/components/ChannelList/ChannelList.tsx @@ -36,13 +36,14 @@ export type ChannelListProps = Partial< | 'pinnedStatusPosition' > > & { - /** Optional function to filter channels prior to rendering the list. Do not use any complex logic that would delay the loading of the ChannelList. We recommend using a pure function with array methods like filter/sort/reduce. */ + /** + * Filters the channels before they render. It runs on every render, so use a cheap pure function. + */ channelRenderFilterFn?: (channels: Array) => Array; /** - * The `ChannelPaginator` this list renders. The app creates it with the list's filters, sort and - * request options, and registers it on `client.channelManager` with `insertPaginator` so it receives - * events. The list loads its first page when it has none and never disposes it, so a list that - * mounts again shows the channels it already has. + * The `ChannelPaginator` this list renders. The app creates it with the filters, sort and request + * options, and registers it with `client.channelManager.insertPaginator` so it receives events. + * The list loads the first page when there is none and never disposes the paginator. */ paginator: ChannelPaginator; notificationHostId?: string; @@ -51,8 +52,8 @@ export type ChannelListProps = Partial< const DEFAULT_ADDITIONAL_FLAT_LIST_PROPS: Partial> = {}; /** - * Renders the channels of an app-owned `ChannelPaginator` and lets the user select one to open. - * The ChannelList renders a ChannelListView which provides the UI for the underlying React Native FlatList. + * Renders the channels of an app-owned `ChannelPaginator` and lets the user select one to open, + * through a `ChannelListView`. * * @example ./ChannelList.md */ @@ -80,7 +81,7 @@ export const ChannelList = (props: ChannelListProps) => { const channelManager = client.channelManager; const { NotificationList } = useComponentsContext(); - // Ref-counted on the shared manager: subscriptions live only while at least one ChannelList is mounted. + // Ref-counted on the shared manager: subscriptions live while at least one list is mounted. useEffect(() => channelManager.registerSubscriptions(), [channelManager]); const { diff --git a/package/src/components/ChannelList/ChannelListView.tsx b/package/src/components/ChannelList/ChannelListView.tsx index 718d7801f8..f2a70b796f 100644 --- a/package/src/components/ChannelList/ChannelListView.tsx +++ b/package/src/components/ChannelList/ChannelListView.tsx @@ -1,6 +1,5 @@ import React, { useMemo } from 'react'; -// RNGR's FlatList ist currently breaking the pull-to-refresh behaviour on Android -// See https://github.com/software-mansion/react-native-gesture-handler/issues/598 +// The gesture handler's FlatList breaks pull-to-refresh on Android. import { FlatList, StyleSheet, View } from 'react-native'; import type { Channel } from 'stream-chat'; @@ -17,38 +16,21 @@ import { ChannelPreview } from '../ChannelPreview/ChannelPreview'; import { useNetworkConnectionState } from '../Chat/hooks/useNetworkConnectionState'; import { useSettledWSConnectionHealth } from '../Chat/hooks/useWSConnectionState'; -/** - * The list's query state, passed down by `ChannelList`. - */ +/** The list's query state, passed down by `ChannelList`. */ export type ChannelListState = { - /** - * A control prop used to determine whether the first query of the channel list has succeeded. - */ + /** Whether the first page of channels has loaded. */ channelListInitialized: boolean; - /** - * The channels to render. - */ + /** The channels to render. */ channels: Channel[] | null; - /** - * Whether or not the FlatList has another page to render - */ + /** Whether another page of channels can be loaded. */ hasNextPage: boolean; - /** - * Initial channels query loading state, triggers the LoadingIndicator - */ + /** Whether the initial channels query is loading, which shows the loading indicator. */ loadingChannels: boolean; - /** - * Whether or not additional channels are being loaded, triggers the - * ChannelListFooterLoadingIndicator - */ + /** Whether the next page is loading, which shows `ChannelListFooterLoadingIndicator`. */ loadingNextPage: boolean; - /** - * Triggered when the channel list is refreshing, displays a loading spinner at the top of the list - */ + /** Whether a pull-to-refresh is in progress, shown as a spinner at the top of the list. */ refreshing: boolean; - /** - * Error in channels query, if any - */ + /** Error of the channels query, if any. */ error?: Error; }; @@ -73,8 +55,8 @@ const StatusIndicator = ({ return null; } - // `=== false` for the network (unknown must not read as offline), plain falsy for the socket - // (always a boolean). + // `=== false` for the network, as an unknown status must not read as offline. The socket status + // is always a boolean. if (isNetworkOnline === false || !isWSOnline) { return ( @@ -155,7 +137,7 @@ const ChannelListViewWithContext = (props: ChannelListViewPropsWithContext) => { ref={setFlatListRef} refreshing={refreshing} renderItem={renderItem} - // Rows subscribe to their own channel, so a list update re-renders only the cells it changed. + // Rows subscribe to their own channel, so a list update re-renders only the changed cells. strictMode style={styles.flatList} testID='channel-list-view' @@ -172,8 +154,7 @@ export type ChannelListViewProps = Partial< ChannelListState; /** - * This UI component displays the preview list of channels and handles Channel navigation. It - * receives all props from the ChannelList component. + * The preview list of channels, rendered by `ChannelList` with its state. * * @example ./ChannelListView.md */ diff --git a/package/src/components/ChannelList/hooks/useChannelMembersState.ts b/package/src/components/ChannelList/hooks/useChannelMembersState.ts index 7a54ea76ef..2d9945cd23 100644 --- a/package/src/components/ChannelList/hooks/useChannelMembersState.ts +++ b/package/src/components/ChannelList/hooks/useChannelMembersState.ts @@ -6,9 +6,7 @@ import { useStateStore } from '../../../hooks/useStateStore'; type MembersState = { members: Record }; -/** - * The fields of a member a caller reads. Define it at module scope, so it stays the same function. - */ +/** Picks the member fields a caller reads. Define it at module scope to keep it stable. */ export type ChannelMemberPick = (member: ChannelMemberResponse) => Record; const selector = (state: MembersState) => ({ @@ -20,7 +18,7 @@ const isShallowEqual = (a: Record, b: Record) return keys.length === Object.keys(b).length && keys.every((key) => Object.is(a[key], b[key])); }; -// keeps the previous members while every member's picked fields are unchanged +// Keeps the previous members while every member's picked fields are unchanged. const createPickedMembersSelector = (pick: ChannelMemberPick) => { let previous: { members: MembersState['members']; picked: Record[] } | undefined; return (state: MembersState) => { @@ -39,16 +37,27 @@ const createPickedMembersSelector = (pick: ChannelMemberPick) => { }; /** - * Returns the channel's members, sourced reactively from `channel.state`. + * Returns the channel's members from `channel.state`, updating reactively. + * + * With `pick`, the members update only when a picked field changes for some member, or when members + * join or leave. A presence change swaps in new member objects for that user, so a caller that does + * not read `online` should pick what it reads to skip those re-renders. * - * With `pick`, the members update only when one of the picked fields changes for some member, or - * when members join or leave. A presence change replaces every member object of that user, so a caller - * that does not read `online` should pick what it reads to skip those re-renders. + * @param channel The channel. + * @param pick Picks the member fields the caller reads. + * @returns The members by user ID. */ export function useChannelMembersState( channel: Channel, pick?: ChannelMemberPick, ): Record; +/** + * Same as above for an optional channel, returning `undefined` without one. + * + * @param channel The channel. + * @param pick Picks the member fields the caller reads. + * @returns The members by user ID, or `undefined` without a channel. + */ export function useChannelMembersState( channel?: Channel, pick?: ChannelMemberPick, diff --git a/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts b/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts index da75c724c6..6bbbce7fc1 100644 --- a/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts +++ b/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts @@ -5,10 +5,18 @@ import { useStateStore } from '../../../hooks/useStateStore'; const selector = (state: { watcherCount: number }) => ({ watcherCount: state.watcherCount }); /** - * Returns the channel's online (watcher) count, sourced reactively from - * `channel.state`. + * Returns the channel's online (watcher) count from `channel.state`, updating reactively. + * + * @param channel The channel. + * @returns The watcher count. */ export function useChannelOnlineMemberCount(channel: Channel): number; +/** + * Same as above for an optional channel, returning `undefined` without one. + * + * @param channel The channel. + * @returns The watcher count, or `undefined` without a channel. + */ export function useChannelOnlineMemberCount(channel?: Channel): number | undefined; export function useChannelOnlineMemberCount(channel?: Channel) { return useStateStore(channel?.state, selector)?.watcherCount; diff --git a/package/src/components/ChannelList/hooks/useMutedChannels.ts b/package/src/components/ChannelList/hooks/useMutedChannels.ts index ba6aeaf4d6..bfba6c0814 100644 --- a/package/src/components/ChannelList/hooks/useMutedChannels.ts +++ b/package/src/components/ChannelList/hooks/useMutedChannels.ts @@ -7,7 +7,10 @@ const selector = (client: StreamChat) => client.mutedChannels; const keys: EventType[] = ['health.check', 'notification.channel_mutes_updated']; /** - * Returns the current user's muted channels. + * Returns the current user's muted channels, updating on `health.check` and + * `notification.channel_mutes_updated`. + * + * @returns The channel mutes. */ export const useMutedChannels = (): Array => { const { client } = useChatContext(); diff --git a/package/src/components/ChannelList/hooks/useMutedUsers.ts b/package/src/components/ChannelList/hooks/useMutedUsers.ts index c75924abc7..20ecfa4ffb 100644 --- a/package/src/components/ChannelList/hooks/useMutedUsers.ts +++ b/package/src/components/ChannelList/hooks/useMutedUsers.ts @@ -6,7 +6,9 @@ import { useStateStore } from '../../../hooks/useStateStore'; const selector = (state: { mutedUsers: UserMuteResponse[] }) => ({ mutedUsers: state.mutedUsers }); /** - * Returns the current user's muted users, sourced reactively from `client.mutedUsersStore`. + * Returns the current user's muted users from `client.mutedUsersStore`, updating reactively. + * + * @returns The user mutes. */ export function useMutedUsers(): UserMuteResponse[] { const { client } = useChatContext(); diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index 10b0d36915..8e733457bd 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -49,10 +49,10 @@ export const usePaginatedChannels = ({ paginator }: { paginator: ChannelPaginato try { if (queryType === 'loadChannels') { - // Next page — append toward the tail, keeping the current list. + // Next page, appended toward the tail. await paginator.toTail(); } else if (queryType === 'reload') { - // First page from scratch (blanks to the skeleton). + // First page from scratch, which blanks the list to the skeleton. await paginator.reload(); } else { await paginator.toTail({ keepPreviousItems: true, reset: 'yes' }); @@ -70,10 +70,9 @@ export const usePaginatedChannels = ({ paginator }: { paginator: ChannelPaginato const refreshList = useStableCallback( async ({ isBackground = false }: { isBackground?: boolean } = {}) => { const now = Date.now(); - // Only allow pull-to-refresh 5 seconds after the last successful refresh. This throttle is now - // purely about the user pulling repeatedly: reconnect refreshes no longer come through here at - // all — `client.connectionRecovery` owns them and re-runs each list's own query directly on the - // paginator, so they can never be swallowed by this window. + // Throttles repeated pull-to-refresh to one per interval, unless the last query failed. + // Reconnect refreshes do not pass through here: `client.connectionRecovery` re-runs the + // paginator's query itself. if (now - lastRefresh.current < RETRY_INTERVAL_IN_MS && error === undefined) { return; } @@ -92,9 +91,9 @@ export const usePaginatedChannels = ({ paginator }: { paginator: ChannelPaginato await queryChannels('loadChannels'); }); - // Load the first page whenever the paginator has none: on the first mount, after the app swaps in a - // new paginator, and after `client.disconnectUser` resets it. A paginator that already has channels - // is shown as is, and a load already in flight is left alone. + // Loads the first page whenever the paginator has none: on first mount, when the app swaps in a + // new paginator, and after `client.disconnectUser` resets it. A paginator that already has + // channels is shown as is, and a load in flight is left alone. const userId = useClientUserId(client); const isUnloaded = channels === undefined; useEffect(() => { diff --git a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx index e28b7a0880..9e7d66f757 100644 --- a/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelMessagePreviewDeliveryStatus.tsx @@ -16,9 +16,6 @@ import { primitives } from '../../theme'; import { MessageStatusTypes } from '../../utils/utils'; import { CompositeAccessibilityProbe } from '../Accessibility/CompositeAccessibilityProbe'; -/** - * Module scope so the reference stays stable — an inline selector re-subscribes on every render. - */ const readEventsSelector = ({ readEvents }: ChannelConfig) => ({ readEventsEnabled: readEvents.enabled, }); @@ -34,9 +31,8 @@ export const ChannelMessagePreviewDeliveryStatus = ({ const { client } = useChatContext(); const { icons } = useComponentsContext(); const { t } = useTranslationContext(); - // `configState` is absent on the partial channel mocks some tests pass in; a real channel always - // has one. Resolved configuration, so `read_events` is already ANDed with anything registered - // through `client.config.set({ channel: { readEvents } })`. + // The resolved configuration, which combines `read_events` with anything registered through + // `client.config.set({ channel: { readEvents } })`. Absent on partial channel mocks. const configState = channel?.configState; const { readEventsEnabled } = useStateStore(configState, readEventsSelector) ?? {}; const styles = useStyles(); @@ -70,8 +66,7 @@ export const ChannelMessagePreviewDeliveryStatus = ({ return read_events; }, [configState, readEventsEnabled, channel]); - // `status` only exists on optimistic/local messages (`LocalMessage`); a delivered - // `MessageResponse` won't carry it. Read it through a guard instead of asserting the shape. + // `status` exists only on local messages (`LocalMessage`), so it is read through an `in` guard. const messageStatus = 'status' in message ? message.status : undefined; const { status } = useMessageDeliveryStatus({ diff --git a/package/src/components/ChannelPreview/ChannelPreview.tsx b/package/src/components/ChannelPreview/ChannelPreview.tsx index 4428e8dbe2..b19804c236 100644 --- a/package/src/components/ChannelPreview/ChannelPreview.tsx +++ b/package/src/components/ChannelPreview/ChannelPreview.tsx @@ -11,9 +11,7 @@ import { useComponentsContext } from '../../contexts/componentsContext/Component import { useTranslatedMessage } from '../../hooks/useTranslatedMessage'; export type ChannelPreviewProps = Partial> & { - /** - * Instance of Channel from stream-chat package. - */ + /** The channel to preview. */ channel: Channel; }; diff --git a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx index 3257b56036..e4777992ea 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMessage.tsx @@ -51,8 +51,7 @@ export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => { ); }, [channel.state.members, client.user?.id]); - // `status` only exists on optimistic/local messages (`LocalMessage`), so read it via a - // guard rather than asserting `lastMessage` (a `LocalMessage | MessageResponse`) is local. + // `status` exists only on local messages (`LocalMessage`), so it is read through an `in` guard. const lastMessageStatus = lastMessage && 'status' in lastMessage ? lastMessage.status : undefined; const isFailedMessage = lastMessageStatus === MessageStatusTypes.FAILED || lastMessage?.type === 'error'; @@ -71,7 +70,6 @@ export const ChannelPreviewMessage = (props: ChannelPreviewMessageProps) => { ); } - // If there are no messages yet, show a message saying "No messages yet" if (!lastMessage) { return ( diff --git a/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx index 35e6891c31..e7f9dc5b0a 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewMutedStatus.tsx @@ -5,9 +5,7 @@ import { useComponentsContext } from '../../contexts/componentsContext/Component import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { CompositeAccessibilityProbe } from '../Accessibility/CompositeAccessibilityProbe'; -/** - * This UI component displays an avatar for a particular channel. - */ +/** The muted indicator of a channel preview. */ export const ChannelPreviewMutedStatus = () => { const { theme: { diff --git a/package/src/components/ChannelPreview/ChannelPreviewPinnedStatus.tsx b/package/src/components/ChannelPreview/ChannelPreviewPinnedStatus.tsx index fcf22674bc..febd94d9a0 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewPinnedStatus.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewPinnedStatus.tsx @@ -5,9 +5,7 @@ import { useComponentsContext } from '../../contexts/componentsContext/Component import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { CompositeAccessibilityProbe } from '../Accessibility/CompositeAccessibilityProbe'; -/** - * This UI component displays a pinned indicator for a particular channel. - */ +/** The pinned indicator of a channel preview. */ export const ChannelPreviewPinnedStatus = () => { const { theme: { diff --git a/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx b/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx index e91d33b41f..0f407e5868 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewTitle.tsx @@ -9,9 +9,7 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; export type ChannelPreviewTitleProps = Pick & { - /** - * Formatted name for the previewed channel. - */ + /** Overrides the channel's display name. */ title?: string; }; diff --git a/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx b/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx index 3af25d9b50..cb66f7d4bc 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewUnreadCount.tsx @@ -8,9 +8,7 @@ import { BadgeNotification } from '../ui/Badge'; export type ChannelPreviewUnreadCountProps = Pick & Pick & { - /** - * Number of unread messages on the channel - */ + /** Number of unread messages on the channel. */ unread?: number; }; diff --git a/package/src/components/ChannelPreview/ChannelPreviewView.tsx b/package/src/components/ChannelPreview/ChannelPreviewView.tsx index 16065e6bf8..88ed182d08 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewView.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewView.tsx @@ -22,20 +22,18 @@ export type ChannelPreviewViewPropsWithContext = Pick & { /** - * Formatter function for date of latest message. - * @param date Message date - * @returns Formatted date string + * Formats the date of the latest message. Defaults to the `timestamp.ChannelPreviewStatus` + * translation: the time today, "Yesterday", the weekday within the last week, else the date. * - * By default today's date is shown in 'HH:mm A' format and other dates - * are displayed in 'DD/MM/YY' format. props.latestMessage.created_at is the - * default formatted date. This default logic is part of ChannelPreview component. + * @param date The date of the latest message. + * @returns The formatted date string. */ formatLatestMessageDate?: (date: Date) => string; /** If the channel is muted. */ muted?: boolean; /** If the channel is pinned for the current user. */ pinned?: boolean; - /** Number of unread messages on the channel */ + /** Number of unread messages on the channel. */ unread?: number; lastMessage?: LastMessageType; }; @@ -160,10 +158,7 @@ const MemoizedChannelPreviewViewWithContext = React.memo( ChannelPreviewViewWithContext, ) as typeof ChannelPreviewViewWithContext; -/** - * This UI component displays an individual preview item for each channel in a list. It also receives all props - * from the ChannelPreview component. - */ +/** Displays one channel row in the channel list. */ export const ChannelPreviewView = (props: ChannelPreviewViewProps) => { const { maxUnreadCount, onSelect, mutedStatusPosition, pinnedStatusPosition } = useChannelsContext(); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index c5aa72b5fe..b97f39947c 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -15,11 +15,8 @@ import { useStateStore } from '../../../hooks'; export type LastMessageType = LocalMessage | MessageResponse; -// The last message is sourced reactively from the paginator's `aggregateState` — a store kept -// separate from pagination `state` precisely so it stays reactive when a new message lands in the -// head interval while an older window is active (a `state`-derived latest would go stale off-window). -// The preview re-renders whenever the newest message changes (new/edited/deleted) with no manual -// event plumbing. +// The latest message comes from the paginator's `aggregateState`, which stays current when a new +// message lands in the head interval while an older window is active. const lastMessageSelector = (state: MessagePaginatorAggregateState) => ({ lastMessage: state.lastMessage ?? undefined, }); @@ -29,11 +26,8 @@ export const useChannelPreviewData = (channel: Channel, client: StreamChat) => { const { lastMessage } = useStateStore(channel.messagePaginator.aggregateState, lastMessageSelector) ?? {}; - // One `channel.state` subscription for the whole preview row — mute status, membership (for pin) - // and own unread — instead of one subscription per concern. Fewer subscribers means the - // `channel.state` notify fan-out runs fewer selectors per publish, and fewer `useSyncExternalStore` - // instances per mounted row. The public `useIsChannelMuted` / `useIsChannelPinned` hooks are - // unchanged for every other caller; this aggregator just reads the combined slice directly. + // One `channel.state` subscription per row covers mute status, membership (pin) and own unread, + // rather than one subscription per concern. const previewStateSelector = useCallback( (state: MembershipState & MuteStatusState & ReadState) => ({ membership: state.membership, @@ -50,10 +44,9 @@ export const useChannelPreviewData = (channel: Channel, client: StreamChat) => { const muted = muteStatus?.muted ?? false; const pinned = membership?.pinned_at != null; - // muted channels always render a zeroed unread count + // Muted channels show no unread count. const unread = muted ? 0 : (reactiveUnread ?? 0); - // drafts useEffect(() => { const unsubscribe = channel.messageComposer.registerDraftEventSubscriptions(); return () => unsubscribe(); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts index 9efd3ddc21..a470ceafeb 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts @@ -26,7 +26,7 @@ export const useChannelPreviewDisplayName = (channel?: Channel) => { if (channelName) { return channelName; } - // Get first name of the members without the current user. + // Without a channel name, the other members' names are used. const membersWithoutSelf = Object.values(members || {}).filter( (member) => member.user?.id !== currentUserId, ); @@ -38,7 +38,7 @@ export const useChannelPreviewDisplayName = (channel?: Channel) => { .map((member) => getMemberName(member)) .map((name) => name.split(' ')[0]); const sortedMembers = names.sort((a, b) => a.localeCompare(b)); - // Now show the first 2 members and the rest as +N. Don't show the +N if the remaining members are 0. + // The first two names, followed by a count of the others when more remain. const firstTwoMembers = sortedMembers.slice(0, 2); const remainingMembers = sortedMembers.slice(2); return `${firstTwoMembers.join(', ').concat(' ')}${remainingMembers.length > 0 ? t('channelPreview.displayName.others.label', 'and {{ count }} others', { count: remainingMembers.length }) : ''}`; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayPresence.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayPresence.ts index a37a4bb273..d2d09b5b38 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayPresence.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayPresence.ts @@ -5,12 +5,8 @@ import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useSyncClientEventsToChannel } from '../../../hooks/useSyncClientEvents'; /** - * Selector to get the display avatar presence for channel preview - * @param channel - * @param client - * @returns boolean - * - * NOTE: If you want to listen to the value changes where you call the hook, the selector should return primitive values instead of object. + * Whether the other member of a two-member channel is online. Returns a primitive so each read of + * the snapshot is stable. */ const selector = (channel: Channel, client: StreamChat) => { const members = channel.state.members; @@ -24,10 +20,10 @@ const selector = (channel: Channel, client: StreamChat) => { const keys: EventType[] = ['user.presence.changed', 'user.updated']; /** - * Hook to set the display avatar presence for channel preview - * @param {*} channel + * Whether the avatar of a channel preview shows the online presence indicator. * - * @returns {boolean} e.g., true + * @param channel The channel being previewed. + * @returns True when the channel has exactly two members and the other one is online. */ export function useChannelPreviewDisplayPresence(channel: Channel) { const { client } = useChatContext(); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts index 72d8f6d6b5..3f12319a25 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts @@ -17,9 +17,10 @@ export type UseChannelPreviewDraftMessageProps = { }; /** - * Hook to get the draft message for a channel preview. - * @param {UseChannelPreviewDraftMessageProps} param0 - * @returns {DraftMessage | undefined} + * The channel's message composer content as a draft message. + * + * @param params.channel The channel being previewed. + * @returns The draft, or `undefined` when the composer is empty or has no text and no attachments. */ export const useChannelPreviewDraftMessage = ({ channel }: UseChannelPreviewDraftMessageProps) => { const { text: draftText } = useStateStore( diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts index 9ea82337b8..07d1a55c84 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts @@ -16,9 +16,10 @@ const selector = (nextValue: PollState) => ({ }); /** - * Hook to get the label of the poll - * @param pollId - The id of the poll - * @returns The label of the poll + * The preview label for the latest vote on a poll, such as "You voted: Option". + * + * @param params.pollId ID of the poll. + * @returns The label, or `undefined` when the poll has no votes. */ export const useChannelPreviewPollLabel = ({ pollId }: UseChannelPreviewPollLabelProps) => { const { client } = useChatContext(); diff --git a/package/src/components/ChannelPreview/hooks/useChannelTypingState.ts b/package/src/components/ChannelPreview/hooks/useChannelTypingState.ts index 511f1d7d48..1a32e1f8e6 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelTypingState.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelTypingState.ts @@ -7,10 +7,11 @@ type UseChannelTypingStateProps = { }; /** - * Typing users for a channel-preview row. A thin wrapper over the shared `useChannelTypingUsers` - * core (the same reactive `channel.state.typing` source the in-channel indicator uses). The preview - * row isn't inside a `` provider, so the channel is passed as a prop rather than read from - * context. + * The users typing in a channel preview row. The channel is a parameter because the row renders + * outside a `` provider. + * + * @param params.channel The channel being previewed. + * @returns The typing users as `usersTyping`. */ export const useChannelTypingState = ({ channel }: UseChannelTypingStateProps) => { const usersTyping = useChannelTypingUsers(channel); diff --git a/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts b/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts index c3d0bb6061..1a584e9413 100644 --- a/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts +++ b/package/src/components/ChannelPreview/hooks/useIsChannelMuted.ts @@ -13,8 +13,10 @@ const selector = (state: { muteStatus: ChannelMuteStatus }) => ({ }); /** - * Returns this channel's mute status, sourced reactively from `channel.state.muteStatus` (kept in - * sync with `client.mutedChannels` on `notification.channel_mutes_updated` / `health.check`). + * The channel's mute status, read reactively from `channel.state.muteStatus`. + * + * @param channel The channel to read. + * @returns The mute status, which reads as unmuted until the state reports otherwise. */ export const useIsChannelMuted = (channel: Channel) => useStateStore(channel?.state, selector)?.muteStatus ?? defaultMuteStatus; diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index df776dbb53..547ea99cbc 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -33,94 +33,52 @@ init(); export type ChatProps = Pick & Partial> & { /** - * When false, ws connection won't be disconnection upon backgrounding the app. - * To receive push notifications, its necessary that user doesn't have active - * websocket connection. So by default, we disconnect websocket connection when - * app goes to background, and reconnect when app comes to foreground. + * Whether to close the WebSocket connection while the app is in the background and reopen it in + * the foreground. Push notifications are only delivered while no connection is active. Defaults + * to true. */ closeConnectionOnBackground?: boolean; - /** - * Enables offline storage and loading for chat data. - */ + /** Enables offline storage and loading for chat data. Defaults to false. */ enableOfflineSupport?: boolean; /** - * Encrypts the offline database at rest with SQLCipher, using the key this - * resolves to. Only relevant when `enableOfflineSupport` is enabled. Leaving it - * unset keeps the offline database unencrypted, which is the default. + * Resolves the key that encrypts the offline database at rest with SQLCipher. Only applies with + * `enableOfflineSupport`; when unset the database is unencrypted. * - * Requires a native build of `@op-engineering/op-sqlite` that includes SQLCipher. - * Add the following to your application's `package.json` and rebuild the native - * app - without the flag the key is accepted and then silently ignored: - * - * ```json - * { "op-sqlite": { "sqlcipher": true } } - * ``` + * Needs a native `@op-engineering/op-sqlite` build with SQLCipher + * (`{ "op-sqlite": { "sqlcipher": true } }` in the app's `package.json`). * - * **Wrap `` in an error boundary.** If the database cannot be opened with - * the encryption you asked for, `` throws a {@link SqliteClientError} - * from render instead of continuing without it. The SDK deliberately takes no - * recovery action of its own - it never deletes data, and never silently falls - * back to an unencrypted or absent cache. Discriminate on `code`: + * When the database cannot be opened with the requested encryption, `` throws a + * {@link SqliteClientError} from render, so wrap it in an error boundary. The SDK never deletes + * data or falls back to an unencrypted cache. Branch on `code`: + * - `OFFLINE_DB_UNREADABLE`: the existing file cannot be read with this key. Call + * `SqliteClient.deleteDatabase()` and re-mount; actions queued while offline are lost. + * - `ENCRYPTION_KEY_UNAVAILABLE`: the key could not be read (such as a locked keychain). The + * database is untouched; re-mount to retry. + * - `SQLCIPHER_BUILD_MISSING`: the native build lacks SQLCipher. Re-mount with + * `enableOfflineSupport={false}` so nothing is persisted unencrypted. * - * - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the - * key changed, or the database predates encryption). **Recommended recovery: - * `SqliteClient.deleteDatabase()`, then re-mount ``.** The contents are a - * cache and are refetched from the server; the exception is actions queued while - * offline, which are lost - prompt the user first if that matters to you. - * - `ENCRYPTION_KEY_UNAVAILABLE` - the key could not be read (a locked keychain, a - * launch before first unlock). The database is untouched. **Recommended - * recovery: re-mount to retry** once the key is readable - for example when the - * app next returns to the foreground. - * - `SQLCIPHER_BUILD_MISSING` - the native build has no SQLCipher, so the key - * would be ignored and the database written in plaintext. Not recoverable at - * runtime; it needs the build flag above and a new binary. **Recommended - * recovery: re-mount with `enableOfflineSupport={false}`** so nothing is - * persisted unencrypted. - * - * The key must be **stable for the lifetime of the database file**. There is no - * rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a - * rebuild. To rotate without paying that, rotate a key-encryption key and keep the - * database key it protects unchanged (envelope encryption). - * - * Switching encryption on, or back off, leaves a database from the other mode on - * disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it - * from your boundary is all that is needed. + * The key must stay the same for the life of the database file, as there is no rekey. Switching + * encryption on or off raises `OFFLINE_DB_UNREADABLE` once, until the old database is deleted. */ getOfflineDbEncryptionKey?: () => Promise; /** - * Optional positive cap on the number of events a single `/sync` response may - * contain before the offline sync manager skips replaying those events into - * local storage. - * - * On reconnect the SDK downloads the events missed while offline and writes - * them to the offline DB. For a very large payload this replay is both costly - * on-device and unnecessary for what the user is looking at — the active - * channel list and any open channel are refreshed independently on reconnect - * (by `client.connectionRecovery`). When the payload exceeds this - * limit the replay is skipped and that reconnect refresh covers the visible - * channels; inactive channels are hydrated on their next explicit query. The - * last-sync timestamp is still advanced so the same payload is not retried. - * - * Defaults to {@link DEFAULT_MAX_SYNC_EVENTS_LIMIT} (250). Pass `false` to - * disable the limit entirely (replay every event — the historical behavior). - * - * Only relevant when `enableOfflineSupport` is enabled. + * Positive cap on how many events of a single `/sync` response the offline sync manager replays + * into local storage on reconnect. Above the cap the replay is skipped, the reconnect refresh + * covers the visible channels, and the last-sync timestamp still advances. `false` disables the + * cap. Only applies with `enableOfflineSupport`. * * @default 250 */ maxSyncEventsLimit?: number | false; /** - * When true, multipart uploads use the SDK's native upload adapter when available. - * When false, uploads stay on the default axios adapter. - * - * This only controls whether the native adapter gets installed by this Chat instance. - * It does not uninstall an adapter that was already installed on the client. + * Installs the SDK's native multipart upload adapter on the client when true and available; + * otherwise uploads use the default axios adapter. An adapter already installed stays in place. * * @default false */ useNativeMultipartUpload?: boolean; /** - * Instance of Streami18n class should be provided to Chat component to enable internationalization. + * The `Streami18n` instance that enables internationalization. * * The SDK ships English only. Add a language by registering a dictionary keyed by the SDK's * dotted translation keys, then switching to it: @@ -140,9 +98,8 @@ export type ChatProps = Pick & */ i18nInstance?: Streami18n; /** - * You can pass the theme object to customize the styles of Chat components. You can check the default theme in [theme.ts](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/contexts/themeContext/utils/theme.ts) - * - * Please check section about [themes in cookbook](https://github.com/GetStream/stream-chat-react-native/wiki/Cookbook-v3.0#theme) for details. + * Theme overrides, merged over the default theme in + * [theme.ts](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/contexts/themeContext/utils/theme.ts). * * ``` * import type { DeepPartial, Theme } from 'stream-chat-react-native'; @@ -193,14 +150,10 @@ const ChatWithContext = (props: PropsWithChildren) => { } = props; const { ChatLoadingIndicator } = useComponentsContext(); - // Setup translators const translators = useStreami18n(i18nInstance); - /** - * Tracked rather than read inline: `client.user` is a plain field, so a `useMemo` over it only - * re-evaluates when something else re-renders `Chat`. A language changed server-side would - * otherwise never reach the components that read it. - */ + // State rather than a memo: `client.user` is a plain field, so a memo would miss a language + // changed server-side until something else re-rendered `Chat`. const [userLanguage, setUserLanguage] = useState( () => client.user?.language || DEFAULT_USER_LANGUAGE, ); @@ -284,7 +237,7 @@ const ChatWithContext = (props: PropsWithChildren) => { const chatContext = useCreateChatContext({ client, getAppSettings, isMessageAIGenerated }); if (userId && enableOfflineSupport && !initialisedDatabase) { - // if user id has been set and offline support is enabled, we need to wait for database to be initialised + // Hold rendering until the offline database is initialized for this user. return ChatLoadingIndicator ? : null; } @@ -298,17 +251,10 @@ const ChatWithContext = (props: PropsWithChildren) => { }; /** - * Chat - Wrapper component for Chat. The needs to be placed around any other chat components. - * This Chat component provides the ChatContext to all other components. - * - * The ChatContext provides the following props: - * - * - client - client connection - * - getAppSettings - resolves the app's settings - * - isMessageAIGenerated - decides whether a message renders as AI generated + * Root component that provides the chat, translation and theme contexts to the components below it. * - * Connection status is NOT on this context. Read it with `useWSConnectionState()` (our socket) - * or `useNetworkConnectionState()` (the device's network) — they are separate facts. + * Connection status is not on the chat context: read it with `useWSConnectionState()` (the socket) + * or `useNetworkConnectionState()` (the device network). */ export const Chat = (props: PropsWithChildren) => { const { theme } = useTheme(); diff --git a/package/src/components/Chat/hooks/useAppSettingsGetter.ts b/package/src/components/Chat/hooks/useAppSettingsGetter.ts index c74b1882b9..ecd16be043 100644 --- a/package/src/components/Chat/hooks/useAppSettingsGetter.ts +++ b/package/src/components/Chat/hooks/useAppSettingsGetter.ts @@ -13,12 +13,17 @@ type AppSettingsRequest = { /** * A stable `getAppSettings()` for the chat context. The first call for a user fetches the app - * settings and later calls reuse them. With offline support, a successful fetch is copied to the offline - * database and a failed one falls back to that copy. A failed fetch is not kept, so the next call - * fetches again. + * settings and later calls reuse them. With offline support, a successful fetch is copied to the + * offline database and a failed one falls back to that copy. A failed fetch is not kept, so the + * next call fetches again. * - * The settings are requested once `ready` and a user is connected, so they are usually loaded by - * the time anything asks for them. + * The settings are requested once `ready` and a user is connected, so they are usually loaded when + * something asks for them. + * + * @param params.client The client to fetch the settings from. + * @param params.ready Whether the client is ready to be queried. + * @param params.userId The connected user's ID; no request is made without one. + * @returns A function that resolves to the app settings. */ export const useAppSettingsGetter = ({ client, diff --git a/package/src/components/Chat/hooks/useClientUserId.ts b/package/src/components/Chat/hooks/useClientUserId.ts index afb21bc86c..aca664e013 100644 --- a/package/src/components/Chat/hooks/useClientUserId.ts +++ b/package/src/components/Chat/hooks/useClientUserId.ts @@ -4,15 +4,14 @@ import type { StreamChat } from 'stream-chat'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; /** - * The id of the user connected to `client`, kept current as users connect and disconnect. + * The ID of the user connected to `client`, kept current as users connect and disconnect. * - * This hook is TEMPORARY. It goes away once the client keeps its state (the connected user and the - * app settings) in a store of its own, which components can subscribe to directly. + * The client has no store for its own user, so this re-reads it whenever the WebSocket or network + * store publishes, which `connectUser()` and `disconnectUser()` cause. Re-renders only when the ID + * changes. * - * The client has no store for its own user. `connectUser()` sets it right before opening the - * socket and `disconnectUser()` clears it while closing it, so the socket and network stores - * publishing around those calls are the signal to read it again. Re-renders only when the id - * itself changes. + * @param client The client to read the user from. + * @returns The connected user's ID, or `undefined` when no user is connected. */ export const useClientUserId = (client: StreamChat) => { const subscribe = useCallback( diff --git a/package/src/components/Chat/hooks/useConnectionLifecycle.ts b/package/src/components/Chat/hooks/useConnectionLifecycle.ts index f7e14b912e..bb8c61cced 100644 --- a/package/src/components/Chat/hooks/useConnectionLifecycle.ts +++ b/package/src/components/Chat/hooks/useConnectionLifecycle.ts @@ -9,16 +9,15 @@ import type { NetworkStatusReporter, StreamChat } from 'stream-chat'; import { useAppStateListener } from '../../../hooks/useAppStateListener'; /** - * Reports the device's network status to the client, and owns the socket's app-state lifecycle. + * Installs the NetInfo network status reporter on the client and closes the WebSocket while the app + * is in the background, reopening it in the foreground (push notifications are only delivered while + * no socket is active). * - * Two jobs, both side effects — this hook returns nothing. Read status with - * `useNetworkConnectionState()` (the device) or `useWSConnectionState()` (our socket); both read the - * client's own stores, so they are correct on mount rather than only after a transition. + * Read the results with `useNetworkConnectionState()` and `useWSConnectionState()`. * - * 1. **The network reporter.** The client cannot detect device network status itself — every - * platform reports it differently — so it has to be told. On React Native that means NetInfo. - * 2. **Background/foreground.** Close the socket when the app backgrounds and reopen it on - * foreground, because push notifications are only delivered while no socket is active. + * @param client The client to manage. + * @param closeConnectionOnBackground Whether to close the socket while in the background. + * Defaults to true. */ export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBackground = true) => { const clientExists = !!client; @@ -32,7 +31,7 @@ export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBack }, [closeConnectionOnBackground, client, clientExists]); const onForeground = useCallback(() => { - // If the user id is not set, we should not open the connection, as it will raise an unneeded error + // Without a user ID, opening the connection would raise an unneeded error. if (!clientExists || !client.userID) { return; } @@ -42,8 +41,8 @@ export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBack useAppStateListener(onForeground, onBackground); - // A mounted while the app is already active (Android recreating the activity in the same JS - // runtime) never sees the background→active change that reopens a socket its predecessor closed. + // A `` mounted while the app is already active (Android recreating the activity) never sees + // the background-to-foreground change that reopens a socket its predecessor closed. useEffect(() => { if (!closeConnectionOnBackground || !clientExists || !client.userID) return; if (AppState.currentState === 'active' && client.wsConnection.connection?.isDisconnected) { @@ -56,14 +55,9 @@ export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBack return; } - // Declarative config rather than `client.networkConnection.setStatusReporter(...)`. Both survive - // a configuration derivation now, but this one states the reporter as part of the client's - // configuration rather than as an edit applied to it, so a `client.config.get('client')` shows - // what is actually installed. - // - // Installing one at all is not optional on React Native. Left alone the client falls back to a - // reporter that mirrors its own WebSocket, which cannot report that the network came back before - // the socket noticed — the entire reason the network signal is worth having. + // Set through the client config so `client.config.get('client')` shows the installed reporter. + // Required on React Native: the client's fallback mirrors its own WebSocket and cannot report + // the network returning before the socket notices. client.config.set({ client: { networkConnection: { @@ -72,25 +66,17 @@ export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBack }, }); - // Deliberately no teardown. The reporter's lifetime is the CLIENT's, not this component's: the - // client outlives `` (push handling, background work), and `isOnline` is supposed to stay - // true about the device for as long as the client exists. Tearing it down here would also leave a - // stale value rather than a cleared one — `setStatusReporter(null)` keeps the last known status by - // design — so consumers would read an authoritative-looking `isOnline` that nothing is updating - // any more. - // - // Re-running this is safe and cannot stack listeners: `netInfoStatusReporter` is a stable - // module-scope reference, so `ConfigController`'s no-op write check and the observer's own - // installed-reporter identity guard both short-circuit. A *different* client re-runs the effect - // through the dependency array and installs a fresh reporter for it. + // No teardown: the reporter lives as long as the client, which outlives ``, and removing + // it would leave a stale `isOnline` because `setStatusReporter(null)` keeps the last known + // status. Re-running does not stack listeners: `netInfoStatusReporter` is a stable reference, + // so a repeated write is a no-op. }, [client, clientExists]); }; /** - * Subscribes to NetInfo and reports every change to the client. What `` installs. + * Subscribes to NetInfo and reports every change to the client. `` installs it in an effect. * - * Exported so it can be installed **at client construction** instead, which is strictly better if - * you build the client yourself: + * When you build the client yourself, install it at construction instead: * * ```ts * new StreamChat(apiKey, { @@ -98,24 +84,16 @@ export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBack * }); * ``` * - * `` can only install it from an effect, so between the client being constructed and that - * effect running, the client falls back to a reporter that mirrors its own WebSocket. In that window - * a socket-only failure — an expired token, a server close — is recorded as the *device* having no - * network, and the UI blames the network for it. Installing here closes the window; the fallback is - * never reached. + * Until the effect runs, the client falls back to a reporter that mirrors its own WebSocket, which + * records a socket-only failure (expired token, server close) as the device being offline. * - * Module scope, so the same reference is handed to the client on every derivation — re-installing an - * identical reporter is a no-op there, and rebuilding it per render would tear the native listener - * down and recreate it for nothing. - * - * `NetInfo.addEventListener` fires once with the current state on subscribe, which satisfies the - * reporter contract's "report the current status as soon as it is known" requirement — so no - * separate `NetInfo.fetch()` is needed. + * Defined at module scope so every install passes the same reference, which the client treats as a + * no-op. NetInfo reports the current state on subscribe, so no `NetInfo.fetch()` is needed. */ export const netInfoStatusReporter: NetworkStatusReporter = (onStatusChange) => NetInfo.addEventListener(({ isConnected, isInternetReachable }) => { - // `isInternetReachable` is the stronger signal but is `null` until NetInfo has probed, so fall - // back to `isConnected` until it resolves. Coerced because both are `boolean | null`. + // `isInternetReachable` is `null` until NetInfo has probed, so `isConnected` is used until + // then. onStatusChange( isInternetReachable !== null ? isInternetReachable && isConnected : !!isConnected, ); diff --git a/package/src/components/Chat/hooks/useCreateChatClient.ts b/package/src/components/Chat/hooks/useCreateChatClient.ts index 077b4f998c..ddec06ae7d 100644 --- a/package/src/components/Chat/hooks/useCreateChatClient.ts +++ b/package/src/components/Chat/hooks/useCreateChatClient.ts @@ -7,17 +7,10 @@ import type { ClientUser, DeepPartial, InstanceConfigTree, TokenOrProvider } fro import { netInfoStatusReporter } from './useConnectionLifecycle'; /** - * Names the NetInfo reporter in the client's own options, so the device's network status is reported - * from the moment the client exists. - * - * `` also installs it, but only from an effect — and until something reports, the client falls - * back to a reporter that mirrors its own WebSocket. That fallback cannot distinguish "this device - * has no network" from "this socket died", so a socket-only failure in that window (an expired - * token, a server close, the `closeConnection()` that backgrounding uses) is recorded as the device - * being offline, and the UI blames the network for it. Installing here means the fallback is never - * reached. - * - * A `statusReporter` the caller passed themselves wins — it is spread last. + * Names the NetInfo reporter in the client options, so network status is reported from + * construction. `` installs it only from an effect, and until then the client mirrors its own + * WebSocket, which records a socket-only failure (expired token, server close) as the device being + * offline. A `statusReporter` in `options` wins. */ const withNetInfoReporter = ( options?: DeepPartial, @@ -33,7 +26,14 @@ const withNetInfoReporter = ( }); /** - * React hook to create, connect and return `StreamChat` client. + * Creates a `StreamChat` client and connects the user. Disconnects the user when the inputs change + * or the component unmounts. + * + * @param params.apiKey The Stream API key. + * @param params.options Client configuration. Only the first value is used. + * @param params.tokenOrProvider The user token or a function that provides it. + * @param params.userData The user to connect. A new connection is made only when its `id` changes. + * @returns The client once the user is connected, otherwise `null`. */ export const useCreateChatClient = ({ apiKey, @@ -43,10 +43,7 @@ export const useCreateChatClient = ({ }: { apiKey: string; tokenOrProvider: TokenOrProvider; - /** - * The user to connect, as `client.connectUser` accepts it: only `id` is required, so - * `{ id, name, image }` is enough. Server-owned fields (`role`, `created_at`, …) need not be set. - */ + /** The user to connect, as `client.connectUser` accepts it. Only `id` is required. */ userData: ClientUser; options?: DeepPartial; }) => { diff --git a/package/src/components/Chat/hooks/useInitializeOfflineDb.ts b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts index 51f0f45002..cfb3dcd182 100644 --- a/package/src/components/Chat/hooks/useInitializeOfflineDb.ts +++ b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts @@ -8,23 +8,20 @@ import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient'; export type InitializeOfflineDbOptions = { /** - * Encrypts the offline database at rest with SQLCipher, using the key this resolves - * to. Leaving it unset opens the database unencrypted, which is the default. See - * `ChatProps.getOfflineDbEncryptionKey` for the build flag it requires, the stability - * requirement, and how failures are surfaced. + * Resolves the key that encrypts the offline database at rest with SQLCipher; when unset the + * database is unencrypted. See `ChatProps.getOfflineDbEncryptionKey` for the requirements. */ getEncryptionKey?: () => Promise; /** - * Optional positive cap on the number of events a single `/sync` response may - * contain before the offline sync manager skips replaying those events into local - * storage. `false` opts out entirely. + * Positive cap on the events of a single `/sync` response that the offline sync manager replays + * into local storage; above it the replay is skipped. `false` removes the cap. */ maxSyncEventsLimit?: number | false; }; export type UseInitializeOfflineDbParams = { client: StreamChat; - /** Whether offline support is enabled at all. */ + /** Whether offline support is enabled. */ enabled: boolean; options?: InitializeOfflineDbOptions; userID?: string; @@ -32,12 +29,17 @@ export type UseInitializeOfflineDbParams = { /** * Attaches an offline database to the client and initializes it for a user. Returns the attached - * database, so the caller can subscribe to its state: it is attached after the first render. + * database, which is set after the first render. * - * **Raises** whatever prevented the database from opening, from render, so an error - * boundary above the caller can decide what to do. The offline database is never - * silently downgraded, because an integration that asked for encryption must not end - * up with an unencrypted cache. + * Throws from render whatever prevented the database from opening, so an error boundary above the + * caller can handle it. The database is never silently downgraded to an unencrypted one. + * + * @param params.client The client to attach the database to. + * @param params.enabled Whether offline support is enabled. + * @param params.options Encryption key getter and sync event cap. + * @param params.userID The user to initialize the database for. + * @returns The attached database, or `undefined` when none is attached. + * @throws The error that prevented the database from opening. */ export const useInitializeOfflineDb = ({ client, @@ -45,22 +47,16 @@ export const useInitializeOfflineDb = ({ options, userID, }: UseInitializeOfflineDbParams) => { - /** - * Why this attempt could not open the offline database. - * - * Held per attempt rather than read from a longer-lived source: a value that outlived - * the attempt would be seen during the first render after a re-mount and raised - * before that mount's own attempt could run, so an error boundary that re-mounts to - * retry would loop forever. - */ + // Why this attempt could not open the offline database. Held per attempt: a longer-lived error + // would be raised on the next mount before its own attempt runs, so an error boundary that + // re-mounts to retry would loop forever. const [initializationError, setInitializationError] = useState(); const [offlineDb, setOfflineDb] = useState(() => client.offlineDb); const { getEncryptionKey, maxSyncEventsLimit } = options ?? {}; - // `getEncryptionKey` is overwhelmingly likely to be an inline arrow. Stabilising it - // keeps a new identity per render out of the dependencies below, while still calling - // whatever the latest prop is. + // Keeps an inline `getEncryptionKey` from changing the dependencies below every render, while + // still calling the latest prop. const resolveEncryptionKey = useStableCallback( () => getEncryptionKey?.() ?? Promise.resolve(undefined), ); @@ -75,9 +71,9 @@ export const useInitializeOfflineDb = ({ if (!client.offlineDb) { const keyGetter = isEncryptionEnabled ? resolveEncryptionKey : undefined; - // Confirm the database can be opened before attaching it: the client writes - // through `client.offlineDb` without checking that it initialized, so one we - // cannot open turns those writes into rejections (and UI is affected directly). + // Confirm the database opens before attaching it: the client writes through + // `client.offlineDb` without checking it initialized, so an unopenable one rejects those + // writes. if (keyGetter) { SqliteClient.getEncryptionKey = keyGetter; try { @@ -100,9 +96,7 @@ export const useInitializeOfflineDb = ({ setOfflineDb(attachedOfflineDb); if (attachedOfflineDb) { await attachedOfflineDb.init(userID); - // Note: Since `init()` currently swallows errors by design, we have to rely - // on consuming the error later in order to be able to still rethrow without - // introducing a breaking change. + // `init()` swallows errors by design, so the failure is read back from `initializationError`. // TODO: The DB API should be changed in the next major to always throw upwards // and let integrators handle it if necessary. setInitializationError( diff --git a/package/src/components/Chat/hooks/useNetworkConnectionState.ts b/package/src/components/Chat/hooks/useNetworkConnectionState.ts index 4b8594e85e..b59c0f8f73 100644 --- a/package/src/components/Chat/hooks/useNetworkConnectionState.ts +++ b/package/src/components/Chat/hooks/useNetworkConnectionState.ts @@ -6,18 +6,16 @@ import { useStateStore } from '../../../hooks/useStateStore'; const identity = (state: NetworkConnectionState) => state; /** - * The **device's** network status, as reported by the NetInfo listener `` registers on - * `client.networkConnection`. + * The device's network status, as reported by the NetInfo listener `` registers on + * `client.networkConnection`. Unlike {@link useWSConnectionState}, it is not affected by the socket + * dying on a working network. * - * Not the same fact as {@link useWSConnectionState}: a socket dies on a working network, and a device - * drops while the socket has not noticed yet. Use this for "you're offline"; use the WebSocket hook - * for "Reconnecting…". - * - * `isOnline` has **three** states. `undefined` means *unknown* — nobody has reported yet. A guard must - * therefore test `isOnline === false`; `!isOnline` is also true when the answer is unknown, which - * would claim "offline" on the very first render and on any host where no listener is installed. + * `isOnline` is `undefined` until a status is reported, so test `isOnline === false`: `!isOnline` + * would also read as offline on the first render and where no listener is installed. * * Must be used under ``. + * + * @returns The network connection state, or `undefined` when the client has no network store. */ export const useNetworkConnectionState = () => { const { client } = useChatContext(); @@ -25,9 +23,12 @@ export const useNetworkConnectionState = () => { }; /** - * {@link useNetworkConnectionState} narrowed to what a component actually reads, so it re-renders only - * when that changes. The selector must return a flat object or tuple — it is shallow-compared on its - * own keys, and must be declared at module scope to stay referentially stable. + * {@link useNetworkConnectionState} narrowed by `selector`, so the component re-renders only when + * the selected values change. The selector must return a flat object or tuple (shallow-compared) + * and be declared at module scope to stay referentially stable. + * + * @param selector Picks the values to subscribe to. + * @returns The selected values, or `undefined` when the client has no network store. */ export const useNetworkConnectionStateSelector = < O extends Readonly | Readonly>, diff --git a/package/src/components/Chat/hooks/usePendingUploadsDefault.ts b/package/src/components/Chat/hooks/usePendingUploadsDefault.ts index eb42e6a1f5..cca0f19b09 100644 --- a/package/src/components/Chat/hooks/usePendingUploadsDefault.ts +++ b/package/src/components/Chat/hooks/usePendingUploadsDefault.ts @@ -3,23 +3,23 @@ import { useEffect } from 'react'; import type { StreamChat } from 'stream-chat'; /** - * Supplies the SDK's default for `messageComposer.attachments.pendingUploadsEnabled` — whether a - * message can be sent while its attachments are still uploading — and nothing more. + * Turns on `messageComposer.attachments.pendingUploadsEnabled` (sending while attachments are still + * uploading) when offline support is enabled, since offline support needs the local message to keep + * its pending attachments. Without offline support nothing is written and the `stream-chat` + * default (`false`) applies. * - * Offline support depends on the local message keeping its pending attachments, which is exactly - * what this switch does, so enabling `enableOfflineSupport` turns it on. With offline support off - * nothing is written and `stream-chat`'s own default (`false`) applies. - * - * Written only when the integrator has not registered a value. Set it on the client to take the - * decision yourself — ideally before handing the client to ``, so this never writes at all: + * Nothing is written when a value is already registered. Set it on the client, ideally before + * handing the client to ``, to choose yourself: * * ```ts * client.config.set({ messageComposer: { attachments: { pendingUploadsEnabled: false } } }); * ``` * - * Once written, the value is never taken back: the hook cannot tell its own write from an - * integrator's later write of the same value, so a later `enableOfflineSupport={false}` leaves - * pending uploads on rather than risk overwriting a value the integrator chose. + * The value is never reverted: the hook cannot tell its own write from an integrator's later write + * of the same value, so a later `enableOfflineSupport={false}` leaves pending uploads on. + * + * @param client The client whose message composer configuration is written. + * @param enableOfflineSupport Whether offline support is enabled. */ export const usePendingUploadsDefault = (client: StreamChat, enableOfflineSupport: boolean) => { useEffect(() => { @@ -37,7 +37,6 @@ export const usePendingUploadsDefault = (client: StreamChat, enableOfflineSuppor attachments: { pendingUploadsEnabled: true }, }); - // Deliberately no teardown, for the same reason as the network reporter in - // `useConnectionLifecycle`: the configuration lives as long as the client, which outlives ``. + // No teardown: the configuration lives as long as the client, which outlives ``. }, [client, enableOfflineSupport]); }; diff --git a/package/src/components/Chat/hooks/useWSConnectionState.ts b/package/src/components/Chat/hooks/useWSConnectionState.ts index 3897a4141a..bdf14222e4 100644 --- a/package/src/components/Chat/hooks/useWSConnectionState.ts +++ b/package/src/components/Chat/hooks/useWSConnectionState.ts @@ -16,27 +16,16 @@ const displayDelaySelector = (config: WSConnectionConfig) => ({ const networkSelector = (state: NetworkConnectionState) => ({ isOnline: state.isOnline }); /** - * This client's WebSocket status. + * The client's WebSocket status. Unlike {@link useNetworkConnectionState}, which reports the device + * network, a socket can drop on a good network and the network can drop before the socket notices. * - * Not the same fact as {@link useNetworkConnectionState}, and the difference is the point: a socket - * dies on a perfectly good network (a server close, an expired token, a health-check timeout), and a - * device drops while the socket has not noticed yet. Use this for "Reconnecting…"; use the network - * hook for "you're offline". - * - * `isHealthy` is always a boolean — a socket always has a state — so `!isHealthy` is safe, unlike the - * network store's `isOnline`, which is `undefined` until something reports. - * - * Reads the store rather than reacting to an event, so it is correct on mount rather than only after - * the first transition, and so it reports the paths that were always silent - * (`client.closeConnection()`, the mobile backgrounding path, dispatches nothing). - * - * This is the **raw** status, and it has a trap: `isHealthy` is `false` from construction, so it - * reads the same before the first connect as it does after a drop. Those need opposite UI — one is - * "connecting", the other is "we lost it" — and only `lastHealthyAt === null` tells them apart. - * Anything user-visible wants {@link useSettledWSConnectionHealth}, which handles that and the flap - * debounce; reach for this one only when you genuinely want the unfiltered store. + * This is the raw status: `isHealthy` is `false` from construction, so it reads the same before the + * first connect as after a drop, and only `lastHealthyAt === null` tells them apart. Prefer + * {@link useSettledWSConnectionHealth} for UI. * * Must be used under ``. + * + * @returns The WebSocket connection state, or `undefined` when the client has no connection store. */ export const useWSConnectionState = () => { const { client } = useChatContext(); @@ -44,9 +33,12 @@ export const useWSConnectionState = () => { }; /** - * {@link useWSConnectionState} narrowed to what a component actually reads, so it re-renders only - * when that changes. The selector must return a flat object or tuple — it is shallow-compared on its - * own keys, and must be declared at module scope to stay referentially stable. + * {@link useWSConnectionState} narrowed by `selector`, so the component re-renders only when the + * selected values change. The selector must return a flat object or tuple (shallow-compared) and be + * declared at module scope to stay referentially stable. + * + * @param selector Picks the values to subscribe to. + * @returns The selected values, or `undefined` when the client has no connection store. */ export const useWSConnectionStateSelector = < O extends Readonly | Readonly>, @@ -58,33 +50,24 @@ export const useWSConnectionStateSelector = < }; /** - * `isHealthy`, but a drop has to last before it is believed. What UI should render. - * - * Recovery is reported immediately and only the drop is held back, because the two are not - * symmetric: showing "Reconnecting…" a moment late costs nothing, leaving it up a moment too long - * makes a working app look broken. + * `isHealthy` with drops debounced, so brief reconnects do not flash a banner. Recovery is reported + * immediately; a drop only reads as unhealthy once it has lasted the client's + * `wsConnection.offlineNotificationDisplayDelayMs` (0 disables the delay). * - * Most drops resolve in well under a second — a backgrounded socket, a handover between cells, a - * server closing an idle connection — and a banner that rendered all of them would flash constantly. - * The client used to hold this back itself, debouncing its offline event by a fixed five seconds; - * that timer outlived the socket that armed it, so it was removed in favour of the value living - * here, where whoever draws the banner owns it. The length is - * `client.config.set({ client: { wsConnection: { offlineNotificationDisplayDelayMs } } })`; zero - * holds nothing back. - * - * A socket that is already down when this mounts reads as down straight away — there is no flap to - * wait out. A socket that has never been up is a different case: that is a first connect, not a - * drop, so it reads as healthy until the delay says otherwise. + * A socket that has been up and is down on mount reads as down at once. One that has never been up + * is a first connect, not a drop, so it reads as healthy until the delay elapses or the network is + * known to be down. * * Must be used under ``. + * + * @returns Whether the connection counts as healthy. */ export const useSettledWSConnectionHealth = () => { const { client } = useChatContext(); const status = useStateStore(client?.wsConnection?.state, healthSelector); const isHealthy = !!status?.isHealthy; const hasBeenHealthy = !!status?.lastHealthyAt; - // `=== false` is the device telling us it has no network, which is a different thing from - // `undefined`, meaning nobody has reported yet. + // `undefined` means no network status has been reported yet, which is not offline. const networkIsDown = useStateStore(client?.networkConnection?.state, networkSelector)?.isOnline === false; const delay = @@ -94,9 +77,7 @@ export const useSettledWSConnectionHealth = () => { const [settled, setSettled] = useState(() => isHealthy || (!hasBeenHealthy && !networkIsDown)); useEffect(() => { - // Up is immediate, and clearing any pending down with it: a socket that came back before the - // timer fired must never announce the drop it already recovered from — the exact bug that - // retiring the client-side timer was meant to end. + // Recovery is immediate and cancels a pending drop, so a recovered socket never reports one. if (isHealthy) { setSettled(true); return; diff --git a/package/src/components/ImageGallery/ImageGallery.tsx b/package/src/components/ImageGallery/ImageGallery.tsx index d77633ee1d..6d5ac22802 100644 --- a/package/src/components/ImageGallery/ImageGallery.tsx +++ b/package/src/components/ImageGallery/ImageGallery.tsx @@ -81,10 +81,8 @@ type ImageGalleryWithContextProps = Pick< }; /** - * Number of slides mounted on each side of the current one. Kept one wider - * than AnimatedGalleryImage's +-3 load window so an edge slide is already - * mounted (as the empty placeholder) before it ever needs to load its - * image - paging never reveals an unmounted slot. + * Number of slides mounted on each side of the current one. One more than the image load window in + * `AnimatedGalleryImage`, so an edge slide is already mounted as a placeholder before it loads. */ const PAGER_WINDOW_RADIUS = 4; @@ -104,18 +102,12 @@ type GalleryPagerProps = { }; /** - * Windowed pager body. Subscribes to `currentIndex` itself (rather than the - * parent doing so) so a page change rerenders only this small slide list and - * never the parent - keeping the gesture objects/`GestureDetector` stable. + * The windowed slide list. It subscribes to `currentIndex` itself, so a page change re-renders only + * this list and not the parent, which keeps the gesture objects stable. * - * Only the slides within +-{@link PAGER_WINDOW_RADIUS} of the current index are - * mounted; a single leading spacer occupies the flex width of all preceding - * slides so the rendered ones keep their exact natural positions. The slide - * transforms in `useAnimatedGalleryStyle` are a pure function of each slide's - * `index` and that natural flex position, so windowing the mount leaves every - * slide pixelidentical while dropping the mounted component/view count to O(window) - * instead of it being O(N). This way, less React fibers are reconciled and less - * shadow nodes are rendered as well. + * Only slides within {@link PAGER_WINDOW_RADIUS} of the current index are mounted. A leading spacer + * takes the width of the skipped slides, so the mounted ones keep the natural positions that the + * transforms in `useAnimatedGalleryStyle` depend on. */ const GalleryPager = (props: GalleryPagerProps) => { const { fullWindowHeight, fullWindowWidth, offsetScale, scale, slide, translateX, translateY } = @@ -209,10 +201,7 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => const halfScreenHeight = fullWindowHeight / 2; const quarterScreenHeight = fullWindowHeight / 4; - /** - * Fade animation for screen, it is always rendered with pointerEvents - * set to none for fast opening - */ + // Vertical offset of the whole screen: starts below the viewport and animates to 0. const screenTranslateY = useSharedValue(fullWindowHeight); const showScreen = useCallback(() => { 'worklet'; @@ -222,20 +211,13 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => }); }, [screenTranslateY]); - /** - * Run the fade animation on visible change - */ useEffect(() => { dismissKeyboard(); showScreen(); }, [showScreen]); - /** - * Image height for the currently selected asset. SharedValue so worklet - * consumers (gesture math, header/footer opacity) read it directly on the - * UI thread so updating it doesn't trigger a parent rerender. The hook - * owns the value and updates it via a store subscription. - */ + // Height of the current image. A SharedValue so worklets read it on the UI thread and updating + // it does not re-render this component. const currentImageHeight = useCurrentImageHeight({ assets, fullWindowHeight, @@ -243,14 +225,9 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => imageGalleryStateStore, }); - /** - * Header visible value for animating in out - */ + // Header and footer visibility: 1 shown, 0 hidden, animated between. const headerFooterVisible = useSharedValue(1); - /** - * Shared values for movement - */ const translateX = useSharedValue(0); const translateY = useSharedValue(0); const offsetScale = useSharedValue(1); @@ -284,12 +261,8 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => translationX, }); - /** - * If the header is visible we scale down the opacity of it as the - * image is swiped downward. Reads currentImageHeight from a SharedValue so - * the worklet doesn't need to be re-registered when the image dimensions - * change between slides. - */ + // Fades the header and footer as the image is dragged down. The height is read from a SharedValue + // so the worklet is not re-registered when the image dimensions change between slides. const headerFooterOpacity = useDerivedValue(() => { const imageHeight = currentImageHeight.value; return imageHeight * scale.value < fullWindowHeight && translateY.value > 0 @@ -302,9 +275,7 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => : 1; }, []); - /** - * This transition and scaleX reverse lets use scroll right - */ + // Offsets the pager horizontally to the current slide. const pagerStyle = useAnimatedStyle( () => ({ transform: [ @@ -317,9 +288,7 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => [], ); - /** - * Simple background color animation on Y movement - */ + // Background that fades as the image is dragged down. const containerBackground = useAnimatedStyle( () => ({ backgroundColor: backgroundColor || semantics.backgroundCoreApp, @@ -328,10 +297,7 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => [headerFooterOpacity], ); - /** - * Show screen style as component is always rendered we hide it - * down and up and set opacity to 0 for good measure - */ + // Slides the screen in from below the viewport. const showScreenStyle = useAnimatedStyle( () => ({ transform: [ @@ -343,16 +309,10 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => [], ); - /** - * Functions toclose BottomSheetModal with image grid - */ const closeGridView = () => { setIsGridViewVisible(false); }; - /** - * Function to open BottomSheetModal with image grid - */ const openGridView = () => { setIsGridViewVisible(true); }; @@ -364,11 +324,8 @@ export const ImageGalleryWithContext = (props: ImageGalleryWithContextProps) => const handle = imageGalleryStateStore.requesterNode; if (handle == null) return; imageGalleryStateStore.requesterNode = null; - // Because of the fact that iOS and Android handle supressing - // the content underneath differently, we have to wait a frame - // before iOS is allowed to attempt to refocus (it takes a frame - // for the native a11y tree to become aware that it no longer has - // an accessibilityViewIsModal sibling). + // iOS must wait a frame before refocusing: the native a11y tree takes a frame to notice that + // the `accessibilityViewIsModal` sibling is gone. Android refocuses immediately. if (Platform.OS === 'android') { AccessibilityInfo.setAccessibilityFocus(handle); } else { @@ -458,7 +415,12 @@ export const ImageGallery = (props: ImageGalleryProps) => { }; /** - * Clamping worklet to clamp the scaling + * Clamps a value between two bounds. Runs as a worklet. + * + * @param value The value to clamp. + * @param lowerBound The minimum result. + * @param upperBound The maximum result. + * @returns The clamped value. */ export const clamp = (value: number, lowerBound: number, upperBound: number) => { 'worklet'; diff --git a/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx b/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx index 6b4e474bd9..50c323235f 100644 --- a/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx +++ b/package/src/components/ImageGallery/components/AnimatedGalleryImage.tsx @@ -75,23 +75,17 @@ export const AnimatedGalleryImage = React.memo( translateY, }); - /** - * An empty view is rendered for images not close to the currently - * selected in order to maintain spacing while reducing the image - * load on memory. - */ + // Slides far from the current one render an empty view that keeps the spacing and holds no + // image. if (!shouldRender) { return ; } if (isSvg) { - // The outer Animated.View is sized at 8× screen so raster images stay - // crisp under pinch zoom (see useAnimatedGalleryStyle). rn-svg on - // Android rasterizes the SVG to a bitmap at its layout size and an - // 8x screen bitmap exceeds RecordingCanvas's per draw byte limit. The - // inner SvgAwareImage is sized at 1x screen with a counter scale of 8 so - // the bitmap stays small while the composed visible scale (1/8 × 8 === 1) - // is unchanged. + // The outer view is 8x screen so raster images stay crisp under pinch zoom (see + // `useAnimatedGalleryStyle`). On Android, rn-svg rasterizes at layout size and an 8x screen + // bitmap exceeds RecordingCanvas's per-draw byte limit, so the inner `SvgAwareImage` is 1x + // screen with a counter scale of 8, which leaves the visible scale unchanged. return ( { const onPlayBackStatusUpdate = (playbackStatus: PlaybackStatus) => { if (!playbackStatus.isLoaded) { - // Update your UI for the unloaded state setOpacity(1); if (playbackStatus.error) { console.error(`Encountered a fatal error during playback: ${playbackStatus.error}`); } } else { - // Update your UI for the loaded state setOpacity(0); videoPlayer.duration = playbackStatus.durationMillis; if (playbackStatus.isPlaying) { - // Update your UI for the playing state videoPlayer.progress = playbackStatus.positionMillis / playbackStatus.durationMillis; } if (playbackStatus.isBuffering) { - // Update your UI for the buffering state setOpacity(1); } if (playbackStatus.didJustFinish && !playbackStatus.isLooping) { - // The player has just finished playing and will stop. Maybe you want to play something else? videoPlayer.stop(); } } @@ -161,11 +156,7 @@ const AnimatedGalleryVideoComponent = (props: AnimatedGalleryVideoType) => { translateY, }); - /** - * An empty view is rendered for images not close to the currently - * selected in order to maintain spacing while reducing the image - * load on memory. - */ + // Slides far from the current one render an empty view that keeps the spacing and holds no video. if (!shouldRender) { return ( { const { imageGalleryStateStore } = useImageGalleryContext(); diff --git a/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx b/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx index b64fe44f2d..923f54f845 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryFooter.tsx @@ -17,17 +17,15 @@ import { FileTypes } from '../../../types/types'; import { Button } from '../../ui/Button/Button'; import { SafeAreaView } from '../../UIComponents/SafeAreaViewWrapper'; -// Never called - it exists only so `ReturnType` below can name the animated component's type. -// Spelling it as `ReturnType>` -// directly resolves against the wrong overload (the `FlatList` one). +// Never called: it only lets `ReturnType` below name the animated component's type, because +// spelling out `Animated.createAnimatedComponent` picks the wrong overload. // eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in type position only const createAnimatedSafeAreaViewType = () => Animated.createAnimatedComponent(SafeAreaView); /** * `createAnimatedComponent` is guarded because a stripped-down Reanimated mock may not provide it. - * The result is asserted back to the animated component type on purpose: written as a bare ternary - * this types as the *union* of the animated and the plain component, so its `style` prop becomes - * their intersection - which from React Native 0.87 no longer accepts an animated style. + * The cast keeps the animated component type: a bare ternary types as the union with the plain + * component, whose `style` prop then rejects animated styles (React Native 0.87 and later). */ const ReanimatedSafeAreaView = ( Animated.createAnimatedComponent ? Animated.createAnimatedComponent(SafeAreaView) : SafeAreaView @@ -88,8 +86,7 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterProps) => const extension = asset.mime_type?.split('/')[1] || 'jpg'; const shouldDownload = asset.uri && asset.uri.includes('http'); let localFile; - // If the file is already uploaded to a CDN, create a local reference to - // it first; otherwise just use the local file + // Remote files are saved locally first; local files are shared as they are. if (shouldDownload) { setSavingInProgress(true); localFile = await NativeHandlers.saveFile({ @@ -103,9 +100,9 @@ export const ImageGalleryFooterWithContext = (props: ImageGalleryFooterProps) => localFile = asset.uri; } - // `image/jpeg` is added for the case where the mime_type isn't available for a file/image + // Falls back to `image/jpeg` when the asset has no `mime_type`. await NativeHandlers.shareImage({ type: asset.mime_type || 'image/jpeg', url: localFile }); - // Only delete the file if a local reference has been created beforehand + // Only the downloaded copy is deleted. if (shouldDownload) { await NativeHandlers.deleteFile({ uri: localFile }); } @@ -169,7 +166,7 @@ type ShareButtonProps = { const ShareButton = ({ share, savingInProgress }: ShareButtonProps) => { const styles = useStyles(); const { icons } = useComponentsContext(); - // If the shareImage, saveFile or deleteFile is null, we don't want to render the share button + // Hidden when the native share or file system handlers are not registered. if (!isShareImageAvailable() || !isFileSystemAvailable()) { return null; } diff --git a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx index 037c15704d..ac183cd8e7 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryHeader.tsx @@ -20,15 +20,14 @@ import { primitives } from '../../../theme'; import { Button } from '../../ui/Button/Button'; import { SafeAreaView } from '../../UIComponents/SafeAreaViewWrapper'; -// Never called - it exists only so `ReturnType` below can name the animated component's type. +// Never called: it only lets `ReturnType` below name the animated component's type. // eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in type position only const createAnimatedSafeAreaViewType = () => Animated.createAnimatedComponent(SafeAreaView); /** - * A frame-aware wrapper, matching `ImageGalleryFooter`. `SafeAreaView` compares the window inset - * against its own measured frame, so a nested instance contributes nothing once an ancestor has - * already narrowed the subtree - unlike `useSafeAreaInsets`, which is window-global and would - * double-pad this header inside `ChannelDetailsModal`, which insets its own root. + * Animated `SafeAreaView`, as in `ImageGalleryFooter`. It compares the window inset against its own + * frame, so a nested instance adds no padding when an ancestor has already inset the subtree. + * `useSafeAreaInsets` is window-global and would double-pad inside `ChannelDetailsModal`. * * `createAnimatedComponent` is guarded because a stripped-down Reanimated mock may not provide it. */ diff --git a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx index db7c037ef8..1d6d768500 100644 --- a/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx +++ b/package/src/components/ImageGallery/components/ImageGalleryVideoControl.tsx @@ -71,12 +71,10 @@ export const ImageGalleryVideoControl = React.memo((props: ImageGalleryVideoCont await videoPlayer.changePlaybackRate(); }; - // Remember whether playback was ongoing so it can be resumed once the user - // finishes scrubbing. + // Whether playback was ongoing, so it resumes when scrubbing ends. const wasPlayingBeforeSeek = useRef(false); - // Pause while scrubbing so the incoming progress updates don't fight the - // user's finger on the seek bar. + // Pauses while scrubbing so progress updates do not fight the seek bar. const handleStartDrag = useStableCallback(() => { wasPlayingBeforeSeek.current = videoPlayer.isPlaying; videoPlayer.pause(); diff --git a/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx b/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx index 743eb35636..bf4930c752 100644 --- a/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx +++ b/package/src/components/ImageGallery/hooks/useAnimatedGalleryStyle.tsx @@ -16,9 +16,9 @@ type Props = { const oneEighth = 1 / 8; /** - * Named via `ReturnType` because Reanimated does not export `AnimatedStyleHandle` from its root. - * Without an explicit annotation the inferred return type inlines React Native's internal style - * helper types, which cannot be written into the emitted declarations (TS2883). + * Named via `ReturnType` because Reanimated does not export `AnimatedStyleHandle` from its root. An + * inferred return type would inline React Native's internal style types, which cannot be written + * into the emitted declarations (TS2883). */ type GalleryAnimatedStyle = ReturnType>; @@ -36,14 +36,9 @@ export const useAnimatedGalleryStyle = ({ const screenWidth = vw(100); const halfScreenWidth = vw(50); - /** - * The current image, designated by selected is scaled and translated - * based on the gestures. The rendered images before and after the - * currently selected image also translated in X if the scale is - * greater than one so they keep the same distance from the selected - * image as it is scaled. If the scale is less than one they stay in - * place as to not come into the screen when the image shrinks. - */ + // The selected image is scaled and translated by the gestures. Its neighbors shift in X when the + // scale is above 1 to keep their distance from it, and stay in place below 1 so they do not + // enter the screen as the image shrinks. const animatedGalleryStyle = useAnimatedStyle(() => { const selected = currentIndexShared.value === index; const previous = currentIndexShared.value > index; diff --git a/package/src/components/ImageGallery/hooks/useCurrentImageHeight.ts b/package/src/components/ImageGallery/hooks/useCurrentImageHeight.ts index 3f05e4dcd6..ef6b8110fd 100644 --- a/package/src/components/ImageGallery/hooks/useCurrentImageHeight.ts +++ b/package/src/components/ImageGallery/hooks/useCurrentImageHeight.ts @@ -9,19 +9,14 @@ import type { import { FileTypes } from '../../../types/types'; /** - * Owns a SharedValue tracking the visible height of the currently selected - * gallery asset and keeps it in lockstep with the gallery store's - * currentIndex. Worklet consumers read `.value` on the UI thread. + * A SharedValue with the visible height of the currently selected asset, kept in sync with the + * gallery store's `currentIndex`. Worklets read `.value` on the UI thread. * - * The store is subscribed via `subscribeWithSelector` (not `useStateStore`), - * so currentIndex changes never go through React state so no rerender is - * triggered in the calling component. The callback writes directly into the - * SharedValue. + * The store is subscribed with `subscribeWithSelector`, not `useStateStore`, so an index change + * writes the SharedValue without re-rendering the caller. * - * Sync path uses the attachment's intrinsic `original_height`/`original_width` - * when present. Async fallback via `Image.getSize` handles assets that don't - * carry dimensions; a token guards against the closure-race where the user - * swipes past a slide before its dimensions resolve. + * The height comes from `original_height` and `original_width` when present, otherwise from + * `Image.getSize`, whose result is discarded when the user has already swiped to another slide. */ export const useCurrentImageHeight = ({ assets, @@ -53,7 +48,7 @@ export const useCurrentImageHeight = ({ Image.getSize( photo.uri, (width, height) => { - // Stale result, currentIndex moved on before getSize resolved. + // Stale result: `currentIndex` moved on before `getSize` resolved. if (currentToken !== latestToken) return; if (!width || !height) { currentImageHeight.value = fullWindowHeight; diff --git a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx index 33e6f1fd43..3d332003ed 100644 --- a/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx +++ b/package/src/components/ImageGallery/hooks/useImageGalleryGestures.tsx @@ -59,9 +59,6 @@ export const useImageGalleryGestures = ({ const { imageGalleryStateStore } = useImageGalleryContext(); const currentIndexShared = imageGalleryStateStore.currentIndexShared; - /** - * Gesture handler refs - */ const doubleTapRef = useRef(undefined); const panRef = useRef(undefined); const pinchRef = useRef(undefined); @@ -69,15 +66,9 @@ export const useImageGalleryGestures = ({ const { overlay, setOverlay } = useOverlayContext(); const isAndroid = Platform.OS === 'android'; - /** - * Values to track scale for haptic feedback firing - */ const hasHitBottomScale = useSharedValue(1); const hasHitTopScale = useSharedValue(0); - /** - * Shared values for touch tracking - */ const originX = useSharedValue(0); const originY = useSharedValue(0); const oldFocalX = useSharedValue(0); @@ -85,15 +76,9 @@ export const useImageGalleryGestures = ({ const focalX = useSharedValue(0); const focalY = useSharedValue(0); - /** - * Shared values for movement - */ const offsetX = useSharedValue(0); const offsetY = useSharedValue(0); - /** - * Shared values for touch tracking - */ const focalOffsetX = useSharedValue(0); const focalOffsetY = useSharedValue(0); const adjustedFocalX = useSharedValue(0); @@ -101,17 +86,11 @@ export const useImageGalleryGestures = ({ const tapX = useSharedValue(0); const tapY = useSharedValue(0); - /** - * Shared values for gesture tracking - */ const numberOfPinchFingers = useSharedValue(0); const isSwiping = useSharedValue(0); const isPinch = useSharedValue(false); const hasPinched = useSharedValue(0); - /** - * Reset gesture values for use on touch release - */ const resetTouchValues = () => { 'worklet'; focalX.value = 0; @@ -127,9 +106,6 @@ export const useImageGalleryGestures = ({ isSwiping.value = IsSwiping.UNDETERMINED; }; - /** - * Reset movement values for use on selected photo change - */ const resetMovementValues = () => { 'worklet'; translateX.value = 0; @@ -145,9 +121,8 @@ export const useImageGalleryGestures = ({ }; const moveToNextImage = () => { - // Read fresh — both moveToNext/Previous are invoked via runOnJS from a - // worklet, so we'd otherwise be using a value captured at hook-creation - // time. The setter mirrors into currentIndexShared. + // Read fresh: this runs through `runOnJS` from a worklet, so a value captured when the hook + // ran would be stale. The setter also updates `currentIndexShared`. const currentIndex = imageGalleryStateStore.state.getLatestValue().currentIndex; imageGalleryStateStore.currentIndex = currentIndex + 1; }; @@ -158,10 +133,8 @@ export const useImageGalleryGestures = ({ }; /** - * We use simultaneousHandlers to allow pan and pinch gesture handlers - * depending on the gesture. The touch is fully handled by the pinch - * gesture handler once it has began so all interactions by the pan - * handler are blocked if isPinch.value is true + * Pan and pinch run simultaneously. Once the pinch starts, `isPinch` blocks every pan handler + * until the pinch ends. */ const pan = Gesture.Pan() .enabled(overlay === 'gallery') @@ -169,10 +142,7 @@ export const useImageGalleryGestures = ({ .minDistance(10) .onStart(() => { if (!isPinch.value) { - /** - * Cancel any previous motion animation on translations when a touch - * begins to interrupt the animation and take over the position handling - */ + // Interrupt running animations so the touch takes over the position. cancelAnimation(translateX); cancelAnimation(translateY); cancelAnimation(scale); @@ -180,18 +150,14 @@ export const useImageGalleryGestures = ({ offsetY.value = translateY.value; } - /** - * Reset hasPinched for Android single finger offset - */ + // Android: clears the flag that makes pan rebase its offsets after a pinch drops to one + // finger. hasPinched.value = HasPinched.FALSE; }) .onChange((event) => { if (event.numberOfPointers === 1 && !isPinch.value) { - /** - * If Android where a second finger cannot be added back and - * removing one finger returns to Pan Handler then adjust origin - * on finger remove and set swiping false - */ + // Android: when a pinch drops to one finger, control returns to pan. Rebase the offsets and + // stop treating the touch as a swipe. if (isAndroid && hasPinched.value === HasPinched.TRUE) { hasPinched.value = HasPinched.FALSE; isSwiping.value = IsSwiping.FALSE; @@ -199,12 +165,9 @@ export const useImageGalleryGestures = ({ offsetY.value = translateY.value - event.translationY; } - /** - * isSwiping is used to prevent Y movement if a clear swipe to next - * or previous is begun when at the edge of a photo. The value is - * either 0, 1, or 2, via the IsSwiping enum designating undetermined, - * true, or false and is reset on releasing the touch - */ + // `IsSwiping.TRUE` locks Y movement once a horizontal drag starts at the left or right edge + // of the photo. `IsSwiping.FALSE` is set when the vertical translation exceeds 25. Reset on + // touch release. if (isSwiping.value === IsSwiping.UNDETERMINED) { const maxXYRatio = 0.25; if ( @@ -219,18 +182,11 @@ export const useImageGalleryGestures = ({ } } - /** - * localEvtScale is used for swipe away - */ + // Scale relative to the scale when the gesture started. Also drives the overlay opacity. const localEvtScale = scale.value / offsetScale.value; - /** - * If not swiping translate the image in X and Y to the event - * translation plus offset. If swiping only translate X, if - * swiping down when at top of screen or centered balance scale - * using offset to a degree (this needs improvement the calculation - * is incorrect but likely needs origin use to be 100%) - */ + // X always follows the touch. Y is frozen while swiping. The offsets are rescaled by + // `localEvtScale` while the swipe-away scaling is active (approximate). translateX.value = scale.value !== offsetScale.value ? offsetX.value * localEvtScale + event.translationX @@ -242,10 +198,7 @@ export const useImageGalleryGestures = ({ : offsetY.value + event.translationY : translateY.value; - /** - * If swiping down start scaling down the image for swipe - * away effect - */ + // Swipe away: shrink the image as it is dragged down. const imageHeight = currentImageHeight.value; scale.value = imageHeight * offsetScale.value < screenHeight && translateY.value > 0 @@ -265,23 +218,14 @@ export const useImageGalleryGestures = ({ }) .onEnd((event) => { if (!isPinch.value && event.numberOfPointers < 2) { - /** - * To determine if the fling should page to the next image we - * calculate a final position based on the current position and - * event velocity - */ + // Translation combined with weighted velocity: decides paging and dismissal. const finalXPosition = event.translationX - event.velocityX * 0.3; const finalYPosition = event.translationY + event.velocityY * 0.1; const currentIndex = currentIndexShared.value; - /** - * If there is a next photo, the image is lined up to the right - * edge, the swipe is to the left, and the final position is more - * than half the screen width, move to the next image - * - * As we move towards the left to move to next image, the translationX value will be negative on X axis. - */ + // Page to the next image: a left swipe (`translateX` negative) at the photo's right edge + // with `finalXPosition` beyond half the screen width. if ( currentIndex < assetsLength - 1 && Math.abs(halfScreenWidth * (scale.value - 1) + offsetX.value) < 3 && @@ -301,14 +245,6 @@ export const useImageGalleryGestures = ({ runOnJS(moveToNextImage)(); }, ); - - /** - * If there is a previous photo, the image is lined up to the left - * edge, the swipe is to the right, and the final position is more - * than half the screen width, move to the previous image. - * - * As we move towards the right to move to previous image, the translationX value will be positive on X axis. - */ } else if ( currentIndex > 0 && Math.abs(-halfScreenWidth * (scale.value - 1) + offsetX.value) < 3 && @@ -316,6 +252,8 @@ export const useImageGalleryGestures = ({ finalXPosition < -halfScreenWidth && isSwiping.value === IsSwiping.TRUE ) { + // Page to the previous image: a right swipe (`translateX` positive) at the photo's left + // edge with `finalXPosition` beyond half the screen width. cancelAnimation(translationX); translationX.value = withTiming( -(screenWidth + MARGIN) * (currentIndex - 1), @@ -330,13 +268,8 @@ export const useImageGalleryGestures = ({ ); } - /** - * When the pan is finished if the scale is less than 1 return the - * photo to center, if the photo is inside the edges of the screen - * return the photo to line up with the edges, otherwise use decay - * with a clamping at the edges to give the effect the image is - * sliding along using velocity and friction - */ + // Settle X: center when zoomed out, snap back inside the edges, otherwise decay clamped to + // the edges. translateX.value = scale.value < 1 ? withTiming(0) @@ -357,13 +290,7 @@ export const useImageGalleryGestures = ({ velocity: -event.velocityX, }); - /** - * When the pan is finished if the height is less than the screen - * height return the photo to center, if the photo is inside the - * edges of the screen return the photo to line up with the edges, - * otherwise use decay with a clamping at the edges to give the effect - * the image is sliding along using velocity and friction - */ + // Settle Y: same as X, comparing the scaled image height with the screen height. const imageHeight = currentImageHeight.value; translateY.value = imageHeight * scale.value < screenHeight @@ -385,19 +312,12 @@ export const useImageGalleryGestures = ({ resetTouchValues(); - /** - * If the scale has been reduced below one, i.e. zoomed out, translate - * the zoom back to one - */ + // Restore the scale to `offsetScale`, undoing the swipe-away shrink. scale.value = scale.value !== offsetScale.value ? withTiming(offsetScale.value) : offsetScale.value; - /** - * If the photo is centered or at the top of the screen if scaled larger - * than the screen, and not paging left or right, and the final Y position - * is greater than half the screen using swipe velocity and position, close - * the overlay - */ + // Dismiss on a downward swipe: `finalYPosition` beyond half the screen, photo centered or + // at its top edge, and not a horizontal page swipe. if ( finalYPosition > halfScreenHeight && offsetY.value + 8 >= (imageHeight / 2) * scale.value - halfScreenHeight && @@ -453,39 +373,22 @@ export const useImageGalleryGestures = ({ const triggerHaptic = NativeHandlers.triggerHaptic; /** - * On pinch is run when two or more fingers touch the screen, it then takes over - * all touch handling even if the number of fingers is reduced to one until the - * touch is complete + * Takes over all touch handling once two fingers touch, even if the count drops to one, until + * the touch ends. */ const pinch = Gesture.Pinch() .onStart((event) => { - /** - * Android starts with a zero event with 1 touch instead of two - * we therefore must wait to capture starting info until the double - * touch begins - */ + // Android's first event has a single pointer, so the starting values are captured in + // `onChange`. if (!isAndroid) { - /** - * Cancel any previous motion animation on translations when a touch - * begins to interrupt the animation and take over the position handling - */ cancelAnimation(translateX); cancelAnimation(translateY); cancelAnimation(scale); - /** - * Set pinch to true to stop all pan gesture interactions - */ isPinch.value = true; - /** - * Reset isSwiping as now the pan gesture handler is no longer running - */ isSwiping.value = IsSwiping.UNDETERMINED; - /** - * Set initial values for pinch gesture interaction handler - */ numberOfPinchFingers.value = event.numberOfPointers; offsetX.value = translateX.value; offsetY.value = translateY.value; @@ -496,40 +399,20 @@ export const useImageGalleryGestures = ({ offsetScale.value = scale.value; } - /** - * Reset hasPinched for Android single finger offset - */ hasPinched.value = HasPinched.FALSE; }) .onChange((event) => { - /** - * Android starts with a zero event with 1 touch instead of two - * we therefore must wait to capture starting info until the double - * touch begins - */ + // Android's first event has a single pointer, so the starting values are captured here. if (!isPinch.value && isAndroid) { - /** - * Set hasPinched to true so when removing one finger the pan active - * state adjusts the offset - */ + // Lets pan rebase its offsets when a finger is removed. hasPinched.value = HasPinched.TRUE; - /** - * Cancel any previous motion animation on translations when a touch - * begins to interrupt the animation and take over the position handling - */ cancelAnimation(translateX); cancelAnimation(translateY); cancelAnimation(scale); - /** - * Reset isSwiping as now the pan gesture handler is no longer running - */ isSwiping.value = IsSwiping.UNDETERMINED; - /** - * Set initial values for pinch gesture interaction handler - */ numberOfPinchFingers.value = event.numberOfPointers; offsetX.value = translateX.value; offsetY.value = translateY.value; @@ -540,26 +423,15 @@ export const useImageGalleryGestures = ({ offsetScale.value = scale.value; } - /** - * Set pinch to true to stop all pan gesture interactions, we do this - * again here for Android outside the check that creates type - */ + // On Android this is the first place `isPinch` becomes true. isPinch.value = true; - /** - * The scale is clamped to a minimum of 1 and maximum of 8 for aesthetics. - * We use the clamped value to determine a local event scale so the focal - * point does not become out of sync with the actual photo scaling, e.g. - * evt.scale is 20 but scale is 8, using evt.scale for offset will put the - * photo and calculations out of sync - */ + // Scale is clamped to 1..8. The clamped value, not `event.scale`, drives `localEvtScale` so + // the focal point stays in sync with the actual photo scale. scale.value = clamp(offsetScale.value * event.scale, 1, 8); const localEvtScale = scale.value / offsetScale.value; - /** - * When we hit the top or bottom of the scale clamping we run a haptic - * trigger, we track if it has been run to not spam the trigger - */ + // Light haptic once each time the scale reaches a limit. if (scale.value !== 8 && scale.value !== 1) { hasHitTopScale.value = 0; hasHitBottomScale.value = 0; @@ -571,24 +443,13 @@ export const useImageGalleryGestures = ({ runOnJS(triggerHaptic)('impactLight'); } - /** - * We calculate the adjusted focal point on the photo using the events - * focal position on the screen, screen size, and current photo offset - */ + // Focal point adjusted for the screen size and photo offset. adjustedFocalX.value = event.focalX - (halfScreenWidth - offsetX.value); adjustedFocalY.value = event.focalY - (halfScreenHeight + offsetY.value); - /** - * If the number of fingers on the screen changes, the position of the - * focal point will change and this needs to be accounted for, e.g. if - * two fingers are on the screen the focal is between them, but if one is - * then removed the focal is now at the remaining fingers touch position. - * If this happens without accounting for the change the image will jump - * around, we keep track of the previous two finger focal to adjust for this - * change in a reduction from two fingers to one, then if another finger - * is added again we adjust the origin to account for the difference between - * the original two finger touch and the new two finger touch position. - */ + // The focal point jumps when the finger count changes. Dropping to one finger stores the + // difference from the previous focal as `focalOffset`. Adding a finger shifts the origin by + // the difference. if (numberOfPinchFingers.value !== event.numberOfPointers) { numberOfPinchFingers.value = event.numberOfPointers; if (event.numberOfPointers === 1) { @@ -604,24 +465,14 @@ export const useImageGalleryGestures = ({ } } - /** - * If pinch handler has been activated via two fingers then the fingers - * reduced to one we keep track of the old focal using the focal offset - * from when the number of fingers was two. We then translate the photo - * taking into account the offset, focal, focal offset, origin, and scale. - */ + // One finger left: apply the saved focal offset so the photo does not jump. if (numberOfPinchFingers.value === 1) { oldFocalX.value = adjustedFocalX.value + focalOffsetX.value; oldFocalY.value = adjustedFocalY.value + focalOffsetY.value; translateX.value = offsetX.value - oldFocalX.value + localEvtScale * originX.value; translateY.value = offsetY.value + oldFocalY.value - localEvtScale * originY.value; - - /** - * If the number of fingers in the gesture is greater than one the - * adjusted focal point is saved as the old focal and the photo is - * translated taking into account the offset, focal, origin, and scale. - */ } else if (numberOfPinchFingers.value > 1) { + // Two or more fingers: remember this focal for the next finger-count change. oldFocalX.value = adjustedFocalX.value; oldFocalY.value = adjustedFocalY.value; translateX.value = offsetX.value - adjustedFocalX.value + localEvtScale * originX.value; @@ -630,12 +481,7 @@ export const useImageGalleryGestures = ({ }) .onEnd(() => { if (isPinch.value) { - /** - * When the pinch is finished if the scale is less than 1 return the - * photo to center, if the photo is inside the edges of the screen - * return the photo to line up with the edges, otherwise leave the - * photo in its current position - */ + // Snap back inside the edges; otherwise keep the position. translateX.value = scale.value < 1 ? withTiming(0) @@ -645,12 +491,6 @@ export const useImageGalleryGestures = ({ ? withTiming(-halfScreenWidth * (scale.value - 1)) : translateX.value; - /** - * When the pinch is finished if the height is less than the screen - * height return the photo to center, if the photo is inside the - * edges of the screen return the photo to line up with the edges, - * otherwise leave the photo in its current position - */ const imageHeight = currentImageHeight.value; translateY.value = imageHeight * scale.value < screenHeight @@ -661,10 +501,7 @@ export const useImageGalleryGestures = ({ ? withTiming((-imageHeight / 2) * scale.value + screenHeight / 2) : translateY.value; - /** - * If the scale has been reduced below one, i.e. zoomed out, translate - * the zoom back to one - */ + // Commit the final scale as `offsetScale` for the next gesture, never below 1. offsetScale.value = scale.value < 1 ? 1 : scale.value; scale.value = scale.value < 1 ? withTiming(1) : scale.value; resetTouchValues(); @@ -673,9 +510,6 @@ export const useImageGalleryGestures = ({ .withRef(pinchRef) .simultaneousWithExternalGesture(panRef); - /** - * Single tap handler for header hiding and showing - */ const singleTap = Gesture.Tap() .minPointers(1) .numberOfTaps(1) @@ -687,7 +521,7 @@ export const useImageGalleryGestures = ({ .withRef(singleTapRef); /** - * Double tap handler to zoom back out and hide header and footer + * Toggles between 2x zoom and 1x. Zooming back to 1x also hides the header and footer. */ const doubleTap = Gesture.Tap() .maxDeltaX(8) @@ -759,7 +593,12 @@ export const useImageGalleryGestures = ({ }; /** - * Clamping worklet to clamp the scaling + * Clamps `value` between `lowerBound` and `upperBound`. + * + * @param value The value to clamp. + * @param lowerBound The minimum result. + * @param upperBound The maximum result. + * @returns The clamped value. */ export const clamp = (value: number, lowerBound: number, upperBound: number) => { 'worklet'; diff --git a/package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts b/package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts index e4e073212c..6617c64514 100644 --- a/package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts +++ b/package/src/components/ImageGallery/hooks/useImageGalleryVideoPlayer.ts @@ -6,8 +6,10 @@ import { VideoPlayerOptions } from '../../../state-store/video-player'; export type UseImageGalleryVideoPlayerProps = VideoPlayerOptions; /** - * Hook to get the video player instance. - * @param options - The options for the video player. + * Gets the video player from the gallery's pool, creating it when missing. `autoPlay` comes from + * the gallery context. + * + * @param options Video player options. `options.id` identifies the player in the pool. * @returns The video player instance. */ export const useImageGalleryVideoPlayer = (options: UseImageGalleryVideoPlayerProps) => { diff --git a/package/src/components/Indicators/LoadingIndicator.tsx b/package/src/components/Indicators/LoadingIndicator.tsx index ce27c6dc8d..b53aaf4e64 100644 --- a/package/src/components/Indicators/LoadingIndicator.tsx +++ b/package/src/components/Indicators/LoadingIndicator.tsx @@ -28,9 +28,7 @@ export type LoadingProps = { loadingText?: string; }; -/** - * UI Component for LoadingIndicator - */ +/** Spinner with a loading text chosen by `listType`; `loadingText` overrides it. */ export const LoadingIndicator = (props: LoadingProps) => { const { listType, loadingText } = props; diff --git a/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx b/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx index 56706a63a6..95098a1cf6 100644 --- a/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx +++ b/package/src/components/KeyboardCompatibleView/KeyboardCompatibleView.tsx @@ -29,14 +29,12 @@ export const dismissKeyboard = () => { }; /** - * Whether the app runs edge-to-edge on Android (drawing behind the system bars). React Native - * exposes this as a `DeviceInfo` native constant. It's a static, app-level value, so we read it - * once and cache it. Defaults to `false` if the constant is unavailable (older RN / iOS). + * Whether the app runs edge-to-edge on Android, read once from the `DeviceInfo` native constant and + * cached. `false` on iOS or when the constant is unavailable. * - * We need it to decide whether the OS resizes the window when the soft keyboard opens: - * edge-to-edge apps do NOT resize (RN dispatches insets instead), non edge-to-edge apps use - * `adjustResize` and the window shrinks. `Dimensions.screen - Dimensions.window` alone cannot - * tell them apart, because on API < 35 the bars are reported as an inset in BOTH cases. + * Edge-to-edge apps do not resize the window when the keyboard opens, others do (`adjustResize`). + * `Dimensions.screen - Dimensions.window` cannot tell them apart: below API 35 the bars are an + * inset in both cases. */ let cachedIsEdgeToEdge: boolean | undefined; const getIsEdgeToEdge = (): boolean => { @@ -61,10 +59,8 @@ type State = { }; /** - * View that moves out of the way when the keyboard appears by automatically - * adjusting its height, position, or bottom padding. - * - * Following piece of code has been mostly copied from KeyboardAvoidingView component, with few additional tweaks. + * View that moves out of the way when the keyboard appears by adjusting its height, position, or + * bottom padding. Adapted from React Native's `KeyboardAvoidingView`. */ export class KeyboardCompatibleView extends React.Component< KeyboardAvoidingViewProps, @@ -76,7 +72,7 @@ export class KeyboardCompatibleView extends React.Component< > = { behavior: 'padding', enabled: true, - keyboardVerticalOffset: Platform.OS === 'ios' ? 86.5 : -300, // default MessageComposer height + keyboardVerticalOffset: Platform.OS === 'ios' ? 86.5 : -300, // Default MessageComposer height. }; _frame: LayoutRectangle | null = null; @@ -114,25 +110,15 @@ export class KeyboardCompatibleView extends React.Component< const keyboardY = keyboardFrame.screenY - (this.props.keyboardVerticalOffset ?? 0); if (Platform.OS === 'android') { - // On Android, whether we need a JS offset depends on whether the OS resizes the - // window when the soft keyboard opens: - // - // - Non edge-to-edge apps use `adjustResize`: the OS shrinks the window and already - // keeps the composer above the keyboard, so no JS offset is needed. Applying one - // anyway double offsets the view and because our layout `frame` only reflects the - // resize a render AFTER `keyboardDidShow` fires, that offset is briefly computed - // from the stale, full height frame and stacked on top of the native resize. That - // manifests as a transient jump to a very high point that then settles, more commonly - // seen on devices such as Samsung on Android 13. So here we skip the JS offset entirely. - // - // - Edge-to-edge apps (the platform default on Android 15/API 35+ and optin below - // that) do NOT resize the window for the keyboard, so the JS offset below is the only - // thing keeping the composer visible and must be applied. - // - // `screen - window` reflects the system bar inset, which is present on non edge-to-edge - // AND on edge-to-edge below API 35, so it can't distinguish the two on its own. We pair - // it with the `isEdgeToEdge` native flag: only skip the offset when the bars are inset - // (there is room for `adjustResize` to shrink into) AND the app is not edge-to-edge. + // Without edge-to-edge the OS resizes the window (`adjustResize`) and already keeps the + // composer above the keyboard, so no JS offset is applied. The layout `frame` only reflects + // the resize a render after `keyboardDidShow`, so an offset would be computed from the stale + // full-height frame and stack on the native resize, causing a transient jump (seen on + // Samsung, Android 13). Edge-to-edge apps (the default from API 35) do not resize, so the JS + // offset is required. + // `screen - window` is the system bar inset, present in both modes below API 35, so it is + // paired with `isEdgeToEdge`: the offset is skipped only when the bars are inset and the app + // is not edge-to-edge. const systemBarsInset = Dimensions.get('screen').height - Dimensions.get('window').height; if (systemBarsInset > 0 && !getIsEdgeToEdge()) { return 0; @@ -143,13 +129,11 @@ export class KeyboardCompatibleView extends React.Component< return Math.max(this.state.bottom + frame.y + frame.height - keyboardY, 0); } - // Calculate the displacement needed for the view such that it - // no longer overlaps with the keyboard + // Displacement needed to keep the view clear of the keyboard. const relativeHeight = Math.max(frame.y + frame.height - keyboardY, 0); if (Platform.OS === 'android') { - // Edge-to-edge only reaches here (systemBarsInset === 0 above): guard against - // sub status bar noise so we don't apply a tiny bogus offset. + // Offsets at or below the status bar height are noise, not a keyboard overlap. const systemInsetFloor = StatusBar.currentHeight ?? 0; if (relativeHeight <= systemInsetFloor) { return 0; @@ -175,11 +159,11 @@ export class KeyboardCompatibleView extends React.Component< const oldFrame = this._frame; this._frame = event.nativeEvent.layout; if (!this._initialFrameHeight) { - // save the initial frame height, before the keyboard is visible + // Initial frame height, captured before the keyboard is visible. this._initialFrameHeight = this._frame.height; } - // update bottom height for the first time or when the height is changed + // Recompute on the first layout and whenever the height changes. if (!oldFrame || oldFrame.height !== this._frame.height) { await this._updateBottomIfNecessary(); } @@ -189,7 +173,7 @@ export class KeyboardCompatibleView extends React.Component< } }; - // Avoid unnecessary renders if the KeyboardAvoidingView is disabled. + // No state update when disabled, to avoid unnecessary renders. _setBottom = (value: number) => { const enabled = this.props.enabled ?? true; this._bottom = value; @@ -291,7 +275,7 @@ export class KeyboardCompatibleView extends React.Component< } componentWillUnmount() { - // Following if-else condition to avoid deprecated warning coming RN 0.65 + // `removeEventListener` fallback for RN versions whose subscription has no `remove()`. if (this._appStateSubscription?.remove) { this._appStateSubscription?.remove(); } @@ -313,10 +297,9 @@ export class KeyboardCompatibleView extends React.Component< // eslint-disable-next-line no-case-declarations let heightStyle; if (this._frame !== null && this.state.bottom > 0) { - // Note that we only apply a height change when there is keyboard present, - // i.e. this.state.bottom is greater than 0. If we remove that condition, - // this.frame.height will never go back to its original value. - // When height changes, we need to disable flex. + // Applied only while the keyboard is present (`state.bottom > 0`), otherwise + // `frame.height` never returns to its original value. Flex is disabled so the height + // wins. heightStyle = { flex: 0, height: this._initialFrameHeight - bottomHeight, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index da45020dd3..209e4b9744 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -101,16 +101,9 @@ export type TouchableEmitter = | 'reactionList'; export type TextMentionTouchableHandlerAdditionalInfo = { - /** - * The typed mention entity for the pressed mention (user / channel / here / - * role / user_group). Always populated by the default renderText pipeline; - * undefined only when a custom renderer doesn't resolve a match. - */ + /** The pressed mention entity. Undefined when the pressed text matches no known mention. */ mentionedEntity?: MentionEntity; - /** - * Back-compat: still populated when the mention is a user, so existing - * integrators reading `additionalInfo.user` keep working. - */ + /** The pressed user, set only when the mention is a user. */ user?: UserResponse; }; @@ -143,11 +136,7 @@ export type GalleryThumbnailTouchableHandlerPayload = { }; export type ReactionListTouchableHandlerAdditionalInfo = { - /** - * The reaction type of the pressed reaction. Populated for presses that - * originate from a single reaction (segmented list item/clustered pill), - * undefined for the "+N more" overflow item. - */ + /** Type of the pressed reaction. Undefined for the clustered list and the "+N more" item. */ reactionType?: string; }; @@ -175,17 +164,11 @@ export type PressableHandlerPayload = { ); export type MessagePressableHandlerPayload = PressableHandlerPayload & { - /** - * Set of action handler functions for various message actions. You can use these functions to perform any action when give interaction occurs. - */ + /** Handlers of the message actions, to run an action from a custom handler. */ actionHandlers?: MessageActionHandlers; - /** - * Additional message touchable handler info. - */ + /** Extra info about the interaction. */ additionalInfo?: Record; - /** - * Message object, on which interaction occurred. - */ + /** Message the interaction occurred on. */ message?: LocalMessage; }; @@ -238,14 +221,12 @@ export type MessagePropsWithContext = Pick< > & Pick & { messagesContext: MessagesContextValue; - /** - * Whether or not users are able to long press messages. - */ + /** Whether long pressing a message is enabled. Defaults to true. */ enableLongPress?: boolean; goToMessage?: (messageId: string) => void; isTargetedMessage?: boolean; /** - * Handler to open the thread on message. This is callback for touch event for replies button. + * Called with the message's thread when its replies button is pressed. * * @param thread The thread of the message, to render in a ``. */ @@ -255,9 +236,7 @@ export type MessagePropsWithContext = Pick< } & Pick; /** - * Since this component doesn't consume `messages` from `MessagesContext`, - * we memoized and broke it up to prevent new messages from re-rendering - * each individual Message component. + * Does not read `messages` from `MessagesContext`, so new messages do not re-render every Message. */ const MessageWithContext = (props: MessagePropsWithContext) => { const [showMessageReactions, setShowMessageReactions] = useState(false); @@ -387,15 +366,11 @@ const MessageWithContext = (props: MessagePropsWithContext) => { dismissKeyboard(); } if (error) { - /** - * If its a Blocked message, we don't do anything as per specs. - */ + // Blocked messages do nothing on press. if (isBlockedMessage(message)) { return; } - /** - * If its a Bounced message, we open the message bounced options modal. - */ + // Bounced messages open the bounce options dialog. if (isBouncedMessage(message)) { setIsBounceDialogOpen(true); return; @@ -416,20 +391,15 @@ const MessageWithContext = (props: MessagePropsWithContext) => { : 'right' : alignment; - /** - * attachments contain files/images or other attachments - * - * if a URL attachment is present with a file/image then we - * show just the text and markdown should make the link - * clickable - */ + // Groups attachments by kind. A file, playable video or image clears `other` (such as URL + // previews), so only the text remains and its markdown makes the link clickable. const attachments = !isMessageTypeDeleted && Array.isArray(message.attachments) ? message.attachments.reduce( (acc, cur) => { if (cur.type === FileTypes.File) { acc.files.push(cur); - acc.other = []; // remove other attachments if a file exists + acc.other = []; } else if ( cur.type === FileTypes.Video && !cur.og_scrape_url && @@ -444,22 +414,19 @@ const MessageWithContext = (props: MessagePropsWithContext) => { acc.other = []; } else if (cur.type === FileTypes.Video && !cur.og_scrape_url) { acc.files.push(cur); - acc.other = []; // remove other attachments if a file exists + acc.other = []; } else if (cur.type === FileTypes.Audio || cur.type === FileTypes.VoiceRecording) { acc.files.push(cur); } else if (cur.type === FileTypes.Image && !cur.title_link && !cur.og_scrape_url) { - /** - * this next if is not combined with the above one for cases where we have - * an image with no url links at all falling back to being an attachment - */ + // An image without a preview URL is neither collected nor added to `other`. if (getAttachmentPreviewUrl(cur, cur.image_url, cur.thumb_url)) { acc.images.push(cur); - acc.other = []; // remove other attachments if an image exists + acc.other = []; } - // only add other attachments if there are no files/images } else if (cur.type === FileTypes.Image && (cur.og_scrape_url || cur.title_link)) { acc.files.push(cur); } else if (!acc.files.length && !acc.images.length && !acc.videos.length) { + // Other attachments are kept only while there are no files, images or videos. acc.other.push(cur); } @@ -478,9 +445,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { other: [] as Attachment[], videos: [] as Attachment[], }; - /** - * Check if any actions to prevent long press - */ + // Attachment actions disable long press and the quoted reply content. const hasAttachmentActions = !isMessageTypeDeleted && Array.isArray(message.attachments) && @@ -600,7 +565,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { if (hasAttachmentActions || isBlockedMessage(message) || !enableLongPress) { return; } - // If a message is bounced, on long press the message bounce options modal should open. + // Bounced messages open the bounce options dialog on long press. if (isBouncedMessage(message)) { setIsBounceDialogOpen(true); return; @@ -1005,8 +970,7 @@ export type MessageProps = Partial< Pick; /** - * Message - A high level component which implements all the logic required for a message. - * The actual rendering of the message is delegated via the "Message" property + * Implements the logic of a message and renders it through the `MessageItemView` component slot. * * @example ./Message.md */ diff --git a/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx b/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx index 47d26b39b5..34d97faf47 100644 --- a/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx +++ b/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx @@ -18,13 +18,10 @@ import { primitives } from '../../../../theme'; import { useShouldUseOverlayStyles } from '../../hooks/useShouldUseOverlayStyles'; type SentToChannelHeaderPropsWithContext = Pick & { - /** - * Function to handle press on the sent to channel header - * @returns void - */ + /** Called when the header is pressed. */ onPress: () => void; /** - * Boolean to show the view text + * Whether to show the "view" text. * @default false */ showViewText?: boolean; diff --git a/package/src/components/Message/MessageItemView/MessageBlocked.tsx b/package/src/components/Message/MessageItemView/MessageBlocked.tsx index d4869e838d..0c37f7461a 100644 --- a/package/src/components/Message/MessageItemView/MessageBlocked.tsx +++ b/package/src/components/Message/MessageItemView/MessageBlocked.tsx @@ -9,15 +9,11 @@ import { primitives } from '../../../theme'; export type MessageBlockedProps = { /** Current [message object](https://getstream.io/chat/docs/#message_format) */ message: LocalMessage; - /** - * Additional styles for the system message container. - */ + /** Additional styles for the container. */ style?: StyleProp; }; -/** - * A component to display blocked message. e.g, when a message is blocked by moderation policies. - */ +/** Displays a message that was blocked, such as by moderation policies. */ export const MessageBlocked = (props: MessageBlockedProps) => { const { message, style } = props; diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx index 8df106eaa7..2e6754455d 100644 --- a/package/src/components/Message/MessageItemView/MessageContent.tsx +++ b/package/src/components/Message/MessageItemView/MessageContent.tsx @@ -73,35 +73,22 @@ export type MessageContentPropsWithContext = Pick< | 'myMessageTheme' > & Pick & { - /** - * Background color for the message content - */ + /** Background color of the message content. */ backgroundColor?: ColorValue; - /** - * If the message is the very last message in the message list - */ + /** Whether the message is the very last one in the message list. */ isVeryLastMessage?: boolean; - /** - * If the message has no border radius - */ + /** Removes the border width of the bubble. */ noBorder?: boolean; - /** - * If the message is grouped in a single or bottom container - */ + /** Whether the message is the single or bottom one of its group. Defaults to false. */ messageGroupedSingleOrBottom?: boolean; - /** - * If the message has a single file - */ + /** Whether the message has a single file. */ isSingleFile?: boolean; hidePaddingTop?: boolean; hidePaddingHorizontal?: boolean; hidePaddingBottom?: boolean; }; -/** - * Child of MessageItemView that displays a message's content - */ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { const { additionalPressableProps, @@ -176,10 +163,8 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { [message, isMessageAIGenerated], ); - // Merged background-color + border-radius object passed directly into the - // bubble's style array (no spread at the call site). Theme-defined radii - // override the group-position-computed defaults; theme-undefined radii are - // omitted so they don't override the computed defaults. + // Background color and border radii of the bubble. Theme-defined radii override the radii + // computed from the group position; undefined theme radii are omitted so they do not. const bubbleColorAndRadius = useMemo(() => { // enum('top', 'middle', 'bottom', 'single') const groupPosition = groupStyles?.[0]; @@ -196,8 +181,8 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { } } - // Built in one literal rather than by mutation: React Native 0.87 types style objects as - // `Readonly`, so assigning onto an already-typed `ViewStyle` no longer compiles. + // Built as one literal: React Native types style objects as `Readonly`, so assigning onto a + // typed `ViewStyle` does not compile. const style: ViewStyle = { backgroundColor, borderBottomLeftRadius: borderBottomLeftRadius ?? computedBottomLeftRadius, @@ -226,9 +211,8 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { const messageTextContainerStyles = useMemo(() => { return { textContainer: { - // Cancel the container's 8px inter-item gap so only the caption's own - // paragraph marginTop shows. Skip for the first item: there is no gap to - // cancel + // Cancels the container's inter-item `gap` so only the caption's own paragraph `marginTop` + // shows. Not used for the first item, which has no gap above it. marginTop: -gap, }, }; @@ -601,9 +585,7 @@ const MemoizedMessageContent = React.memo( export type MessageContentProps = Partial; -/** - * Child of MessageItemView that displays a message's content - */ +/** Displays the content of a message inside `MessageItemView`. */ export const MessageContent = (props: MessageContentProps) => { const { alignment, diff --git a/package/src/components/Message/MessageItemView/MessageFooter.tsx b/package/src/components/Message/MessageItemView/MessageFooter.tsx index 772d1b6adc..07197f0644 100644 --- a/package/src/components/Message/MessageItemView/MessageFooter.tsx +++ b/package/src/components/Message/MessageItemView/MessageFooter.tsx @@ -25,10 +25,8 @@ type MessageFooterComponentProps = { formattedDate?: string | Date; }; -// The sender name is shown only in group channels (not 1:1 DMs). We derive that from `memberCount` -// reactively off `channel.state` rather than threading `members` through the message context — the -// footer is the only consumer of channel membership, and it only needs this one boolean. `memberCount` -// changes far less often than the `members` map (which churns on presence, watchers, adds/removes). +// The sender name is shown only in group channels, so a channel of 2 members counts as a DM. +// `memberCount` changes far less often than the `members` map (presence, watchers, adds/removes). const isDirectChannelSelector = (state: { memberCount: number }) => ({ isDirectChannel: state.memberCount === 2, }); diff --git a/package/src/components/Message/MessageItemView/MessageItemView.tsx b/package/src/components/Message/MessageItemView/MessageItemView.tsx index dbab78bd98..7a1fabf676 100644 --- a/package/src/components/Message/MessageItemView/MessageItemView.tsx +++ b/package/src/components/Message/MessageItemView/MessageItemView.tsx @@ -78,7 +78,7 @@ const useStyles = ({ alignment }: { alignment: Alignment }) => { ...bubbleWrapper, }, repliesContainer: { - marginTop: -primitives.spacingXxs, // Reducing the margin to account the gap added in the content container + marginTop: -primitives.spacingXxs, // Offsets the gap added by the content container zIndex: 0, ...repliesContainer, }, @@ -403,10 +403,7 @@ const MemoizedMessageItemView = React.memo( export type MessageItemViewProps = Partial; -/** - * - * Message UI component - */ +/** Renders the UI of a message. */ export const MessageItemView = (props: MessageItemViewProps) => { const { alignment, diff --git a/package/src/components/Message/MessageItemView/MessageReplies.tsx b/package/src/components/Message/MessageItemView/MessageReplies.tsx index cbf32c2c9b..a341ab3440 100644 --- a/package/src/components/Message/MessageItemView/MessageReplies.tsx +++ b/package/src/components/Message/MessageItemView/MessageReplies.tsx @@ -206,7 +206,7 @@ const useStyles = () => { flexDirection: 'row', alignItems: 'center', gap: primitives.spacingXs, - marginTop: -primitives.spacingMd, // Pulling the replies container up to hide the stick in the message content + marginTop: -primitives.spacingMd, // Pulls up to hide the stick in the message content }, content: { alignSelf: 'flex-end', diff --git a/package/src/components/Message/MessageItemView/MessageTextContainer.tsx b/package/src/components/Message/MessageItemView/MessageTextContainer.tsx index 32b00d37a3..fcca75def2 100644 --- a/package/src/components/Message/MessageItemView/MessageTextContainer.tsx +++ b/package/src/components/Message/MessageItemView/MessageTextContainer.tsx @@ -166,10 +166,8 @@ const areEqual = ( return false; } - // Enhanced mention sources (channel/here/roles/groups) — without these the - // renderText cache would refresh on a new entity but this comparator would - // short-circuit it away and the highlight would not appear until something - // else re-rendered the row. + // Channel, here, role and group mentions: without these checks the comparator would block the + // `renderText` refresh for a new entity, so the highlight would wait for another re-render. const mentionedBroadcastEqual = prevMessage.mentioned_channel === nextMessage.mentioned_channel && prevMessage.mentioned_here === nextMessage.mentioned_here; @@ -191,11 +189,8 @@ const areEqual = ( return false; } - // stringify could be an expensive operation, so lets rule out the obvious - // possibilities first such as different object reference or empty objects etc. - // Also keeping markdown equality check at the last to make sure other less - // expensive equality checks get executed first and markdown check will be skipped if returned - // false from previous checks. + // `JSON.stringify` is expensive, so the cheap checks (same reference, both empty) run first and + // this comparison stays last among the equality checks. const markdownStylesEqual = prevMarkdownStyles === nextMarkdownStyles || (Object.keys(prevMarkdownStyles || {}).length === 0 && diff --git a/package/src/components/Message/MessageItemView/MessageTimestamp.tsx b/package/src/components/Message/MessageItemView/MessageTimestamp.tsx index 3828960b2c..6dbcd36692 100644 --- a/package/src/components/Message/MessageItemView/MessageTimestamp.tsx +++ b/package/src/components/Message/MessageItemView/MessageTimestamp.tsx @@ -11,17 +11,11 @@ import { primitives } from '../../../theme'; import { useShouldUseOverlayStyles } from '../hooks/useShouldUseOverlayStyles'; export type MessageTimestampProps = Partial> & { - /** - * Already Formatted date - */ + /** The already formatted date. */ formattedDate?: string | Date; - /** - * The timestamp of the message. - */ + /** The timestamp of the message. */ timestamp?: string | Date; - /* - * Lookup key in the language corresponding translations sheet to perform date formatting - */ + /** Translation key of the date format. */ timestampTranslationKey?: string; }; diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index fb344c9413..14ea99e5b7 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -53,33 +53,27 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW noGroupByUser, }); - // Wire timestamps throughout, directly comparable. `new Date(ns)` yielded NaN, so the unread - // separator never rendered. + // `created_at` is a numeric wire timestamp, directly comparable (`new Date(ns)` yields NaN). const createdAtTimestamp = message.created_at; const messageId = message.id; const nextMessageId = nextMessage?.id; const nextMessageIsOwn = nextMessage?.user?.id === client.userID; const nextMessageCreatedAt = nextMessage?.created_at ?? undefined; - // The unread separator belongs above the first UNREAD message from another user — i.e. on the row - // whose newer neighbour is that first unread. We locate it per-message inside the selector so - // `useStateStore`'s per-key comparison keeps the flag referentially stable (`false === false`) for - // every non-boundary row: a mark-read changes the channel-wide unread fields but only re-renders - // the one or two boundary rows whose flag actually flips, not the whole list. + // The unread separator goes on the row above the first unread message from another user. It is + // located per message inside the selector, so the flag stays `false` for every other row and a + // mark-read only re-renders the boundary rows whose flag flips, not the whole list. const showUnreadSeparatorSelector = useCallback( (snapshot: UnreadSnapshotState) => { let showUnreadSeparator: boolean; if (snapshot.firstUnreadMessageId) { - // Frozen boundary (channel open / mark-unread): anchor directly to the known first-unread id. + // Frozen boundary (channel open or mark-unread): anchor to the known first unread ID. showUnreadSeparator = nextMessageId === snapshot.firstUnreadMessageId; } else if (snapshot.unreadCount) { - // Live boundary: the separator sits above the FIRST unread-from-another message — i.e. on - // the last genuinely-read row (created at/before the read boundary) whose newer neighbour is - // that first unread. Gating on "this row is read" (rather than "this row isn't itself an - // unread-from-another") keeps it to a SINGLE separator even when our own messages are - // interleaved among the unreads: an own message sent after the boundary is not "read" by - // this test, so `own → unread-from-another` transitions further down never start a second - // separator. Chronological ordering guarantees exactly one read→unread transition. + // Live boundary: the separator goes on the last read row (created at or before + // `lastReadAt`) whose newer neighbor is the first unread from another user. Requiring this + // row to be read keeps it to one separator when own messages are interleaved among the + // unreads, as an own message sent after the boundary is not read. const lastReadAt = snapshot.lastReadAt ?? 0; const nextIsUnreadFromOther = !!nextMessageId && @@ -89,7 +83,7 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW const thisIsRead = typeof createdAtTimestamp === 'number' && createdAtTimestamp <= lastReadAt; // Own messages sent elsewhere after the boundary leave no read row above the first unread, - // so the rule above never fires. Anchor on the last read message in those cases. + // so the rule above never fires. Anchor on the last read message then. const isLastReadBeforeOwnUnread = thisIsRead && messageId === snapshot.lastReadMessageId && @@ -104,8 +98,8 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW return { showUnreadSeparator, // Only the boundary row needs the unread count (for its inline indicator). Gating it on - // `showUnreadSeparator` keeps this `undefined` for every other row, so the channel-wide - // count changing on each mark-read doesn't re-render the whole list. + // `showUnreadSeparator` keeps it `undefined` elsewhere, so a changing channel-wide count + // does not re-render the whole list. unreadCount: showUnreadSeparator ? snapshot.unreadCount : undefined, }; }, diff --git a/package/src/components/Message/MessageItemView/ReactionList/ReactionListBottom.tsx b/package/src/components/Message/MessageItemView/ReactionList/ReactionListBottom.tsx index 1435b86df2..fbe46bbc34 100644 --- a/package/src/components/Message/MessageItemView/ReactionList/ReactionListBottom.tsx +++ b/package/src/components/Message/MessageItemView/ReactionList/ReactionListBottom.tsx @@ -129,7 +129,7 @@ export const ReactionListBottom = (props: ReactionListBottomProps) => { columnWrapperStyle={styles.columnWrapper} data={reactionListBottomItemData} keyExtractor={(item) => item.reaction.type} - ItemSeparatorComponent={ItemSeparatorComponent} // This is for the gap between the rows of reactions + ItemSeparatorComponent={ItemSeparatorComponent} // Gap between the rows of reactions renderItem={renderItem} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} diff --git a/package/src/components/Message/MessageItemView/ReactionList/ReactionListTop.tsx b/package/src/components/Message/MessageItemView/ReactionList/ReactionListTop.tsx index 621e90a7cc..8f2ee61bdc 100644 --- a/package/src/components/Message/MessageItemView/ReactionList/ReactionListTop.tsx +++ b/package/src/components/Message/MessageItemView/ReactionList/ReactionListTop.tsx @@ -32,9 +32,7 @@ export type ReactionListTopProps = Partial< showCount?: boolean; }; -/** - * ReactionListTop - A high level component which implements all the logic required for a message reaction list - */ +/** Displays the reaction list of a message. */ export const ReactionListTop = (props: ReactionListTopProps) => { const { alignment: propAlignment, diff --git a/package/src/components/Message/MessageItemView/utils/generateMarkdownText.ts b/package/src/components/Message/MessageItemView/utils/generateMarkdownText.ts index 26fc023a75..3158b5646b 100644 --- a/package/src/components/Message/MessageItemView/utils/generateMarkdownText.ts +++ b/package/src/components/Message/MessageItemView/utils/generateMarkdownText.ts @@ -11,10 +11,8 @@ export const generateMarkdownText = (text?: string) => { const normalizedText = text.replace(/\uFFFC/g, ' '); - // Trim the extra spaces from the text. let resultText = normalizedText.trim(); - // List of all the links present in the text. const linkInfos = parseLinksFromText(resultText); for (const linkInfo of linkInfos) { @@ -22,14 +20,14 @@ export const generateMarkdownText = (text?: string) => { length: 200, omission: '...', }); - // Convert raw links/emails in the text to respective markdown syntax. - // Eg: Hi @getstream.io -> Hi @[getstream.io](getstream.io). + // Convert raw links and emails to markdown links. + // Eg: `getstream.io` -> `[getstream.io](http://getstream.io)`. const normalRegEx = new RegExp(escapeRegExp(linkInfo.raw), 'g'); const markdown = `[${displayLink}](${linkInfo.url})`; resultText = normalizedText.replace(normalRegEx, markdown); - // After previous step, in some cases, the mentioned user after `@` might have a link/email so we convert it back to normal raw text. - // Eg: Hi, @[test.user@gmail.com](mailto:test.user@gmail.com) to @test.user@gmail.com. + // A mention whose name looks like a link or email is converted back to raw text. + // Eg: `@[test.user@gmail.com](mailto:test.user@gmail.com)` -> `@test.user@gmail.com`. const mentionsRegex = new RegExp( `@\\[${escapeRegExp(displayLink)}\\]\\(${escapeRegExp(linkInfo.url)}\\)`, 'g', @@ -37,7 +35,7 @@ export const generateMarkdownText = (text?: string) => { resultText = resultText.replace(mentionsRegex, `@${displayLink}`); } - // Escape the " and ' characters, except in code blocks where we deem this allowed. + // Escape `<`, `"` and `'` outside of code spans and code blocks. resultText = resultText.replace(/(```[\s\S]*?```|`.*?`)|[<"']/g, (match, code) => { if (code) { return code; @@ -45,7 +43,7 @@ export const generateMarkdownText = (text?: string) => { return `\\${match}`; }); - // Remove whitespaces that come directly after newlines except in code blocks where we deem this allowed. + // Remove 2 or more spaces right after a newline, except in code spans and code blocks. resultText = resultText.replace(/(```[\s\S]*?```|`.*?`)|\n[ ]{2,}/g, (_, code) => { if (code) { return code; @@ -53,8 +51,8 @@ export const generateMarkdownText = (text?: string) => { return '\n'; }); - // Always replace \n``` with \n\n``` to force the markdown state machine to treat it as a separate block. Otherwise, code blocks inside of list - // items for example were broken. We clean up the code block closing state within the rendering itself. + // A blank line before a code fence makes the markdown parser treat it as a separate block, which + // keeps code blocks inside list items intact. The closing state is cleaned up in the rendering. resultText = resultText.replace(/\n```/g, '\n\n```'); return resultText; diff --git a/package/src/components/Message/MessageItemView/utils/parseLinks.ts b/package/src/components/Message/MessageItemView/utils/parseLinks.ts index 0d09077660..8549d90552 100644 --- a/package/src/components/Message/MessageItemView/utils/parseLinks.ts +++ b/package/src/components/Message/MessageItemView/utils/parseLinks.ts @@ -5,15 +5,10 @@ interface LinkInfo { url: string; } -/** - * This is done to remove all markdown formatted links. - * eg: [google.com](https://www.google.com), [Google](https://www.google.com), [https://www.google.com](https://www.google.com) - * */ +/** Strips markdown links such as `[Google](https://www.google.com)`, so they are not linkified again. */ const removeMarkdownLinksFromText = (input: string) => input.replace(/\[.*\]\(.*\)/g, ''); -/** - * This is done to avoid parsing usernames with dot as well as an email address in it. - */ +/** Strips `@user.name@domain.tld` mentions, so a username with a dot is not parsed as a link. */ const removeUserNamesWithEmailFromText = (input: string) => input.replace(/@(\w+(\.\w+)?)(@\w+\.\w+)/g, ''); @@ -31,7 +26,7 @@ export const parseLinksFromText = (input?: string): LinkInfo[] => { const result: LinkInfo[] = [...links, ...emails].map(({ href, value }) => { let hrefWithProtocol = href; - // Matching these: https://reactnative.dev/docs/0.73/linking?syntax=ios#built-in-url-schemes + // Values that already carry a scheme (`mailto:`, `tel:`, `sms:` or `scheme://`) are kept as is. const pattern = new RegExp(/^(mailto:|tel:|sms:|\S+:\/\/)/); if (!pattern.test(hrefWithProtocol)) { hrefWithProtocol = 'http://' + hrefWithProtocol; diff --git a/package/src/components/Message/MessageItemView/utils/renderText.tsx b/package/src/components/Message/MessageItemView/utils/renderText.tsx index 624ddb5e94..a8f74e7d26 100644 --- a/package/src/components/Message/MessageItemView/utils/renderText.tsx +++ b/package/src/components/Message/MessageItemView/utils/renderText.tsx @@ -101,13 +101,10 @@ const defaultMarkdownStyles: MarkdownStyle = { fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, }, - // Heading sizes are derived from the body font size (`typographyFontSizeMd`) so they - // scale with the integrator's typography settings. lineHeight = fontSize × 1.25 to - // give headings room to breathe. Both fields are required here: without lineHeight, - // the inherited `lineHeight: typographyLineHeightNormal` (20) from `styles.text` (set - // in renderText below) leaks into the heading's inner Text via the markdown library's - // text rule (`{...styles.text, ...state.style}`) and squishes larger heading fontSizes - // into a 20px line box. + // Heading sizes derive from the body font size (`typographyFontSizeMd`) and scale with it, with + // lineHeight = fontSize × 1.25. lineHeight is required: otherwise `styles.text`'s + // `typographyLineHeightNormal` leaks into the heading's inner Text through the markdown + // library's text rule (`{...styles.text, ...state.style}`) and squishes larger headings. heading1: { fontSize: primitives.typographyFontSizeMd * 2, lineHeight: primitives.typographyFontSizeMd * 2 * 1.25, @@ -342,8 +339,8 @@ export const renderText = (params: RenderTextParams) => { let previousLink: string | undefined; const linkReact: ReactNodeOutput = (node, output, { ...state }) => { let url: string; - // Some long URLs with `&` separated parameters are trimmed and the url only until first param is taken. - // This is done because of internal link been taken from the original URL in react-native-markdown-package. So, we check for `withinLink` and take the previous full URL. + // Inside a link (`withinLink`), react-native-markdown-package passes a URL cut at its first `&` + // parameter, so the previous full URL is reused. if (state?.withinLink && previousLink) { url = previousLink; } else { @@ -388,8 +385,7 @@ export const renderText = (params: RenderTextParams) => { const paragraphTextReact: ReactNodeOutput = (node, output, { ...state }) => { if (messageTextNumberOfLines !== undefined) { - // If we want to truncate the message text, lets only truncate the first paragraph - // and simply not render rest of the paragraphs. + // Truncation applies to the first paragraph only; the other paragraphs are not rendered. if (state.key === '0' || state.key === 0) { return ( @@ -408,8 +404,8 @@ export const renderText = (params: RenderTextParams) => { ); }; - // Collect every mention type the server sent us into a single typed list so - // the markdown rule, the lookup, and the press payload all see the same shape. + // Every mention type of the message as one typed list, shared by the markdown rule, the lookup + // and the press payload. const { mentioned_channel, mentioned_group_ids, @@ -447,10 +443,8 @@ export const renderText = (params: RenderTextParams) => { ), ]; - // Lookup keyed by the rendered mention text (sans `@`), lowercased so we - // resolve case-insensitively. First-write-wins: if a user shares a name with - // a role/group, the user entity is preferred — same precedence the React SDK - // applies via insertion order in its plugin. + // Keyed by the lowercased mention text without the `@`, for case-insensitive lookup. The first + // entity wins, so a user is preferred over a role or group with the same name. const mentionLookup = new Map(); for (const entity of mentionEntities) { const key = (entity.name ?? entity.id).toLowerCase(); @@ -566,13 +560,13 @@ export const renderText = (params: RenderTextParams) => { react: blockQuoteReact, }, codeBlock: { react: codeBlockReact }, - // do not render images, we will scrape them out of the message and show on attachment card component + // Images are not rendered inline; the attachment card shows them. image: { match: () => null }, link: { react: linkReact }, list: { react: listReact }, - // Truncate long text content in the message overlay + // Truncates the text when `messageTextNumberOfLines` is set, such as in the message overlay. paragraph: messageTextNumberOfLines ? { react: paragraphTextReact } : {}, - // we have no react rendering support for reflinks + // Reference links are not rendered. reflink: { match: () => null }, sublist: { react: listReact }, ...(mentionTokens @@ -613,11 +607,8 @@ export interface ListOutputProps { } /** - * For lists and sublists, the default behavior of the markdown library we use is - * to always renumber any list, so all ordered lists start from 1. - * - * This custom rule overrides this behavior both for top level lists and sublists, - * in order to start the numbering from the number of the first list item provided. + * Renders lists and sublists. Ordered lists start at the number of their first item, where the + * markdown library renumbers every list from 1. */ export const ListOutput = ({ node, output, state, styles }: ListOutputProps) => { let isSublist = state.withinList; diff --git a/package/src/components/Message/MessageOverlayWrapper.tsx b/package/src/components/Message/MessageOverlayWrapper.tsx index 870b7d54cb..f8a90a0d72 100644 --- a/package/src/components/Message/MessageOverlayWrapper.tsx +++ b/package/src/components/Message/MessageOverlayWrapper.tsx @@ -12,13 +12,11 @@ import type { ViewRef } from '../../types/react-native-compat'; export type MessageOverlayWrapperProps = PropsWithChildren<{ /** - * Stable identifier for this overlay target. Must match `messageOverlayTargetId` - * when this subtree should be teleported into the overlay. + * Identifier of this overlay target. The subtree is teleported into the overlay when it equals + * `messageOverlayTargetId`. */ targetId: string; - /** - * Optional test id attached to the wrapped target container. - */ + /** Test ID of the wrapped target container. */ testID?: string; }>; diff --git a/package/src/components/Message/hooks/useCreateMessageActionHandlers.ts b/package/src/components/Message/hooks/useCreateMessageActionHandlers.ts index 868c2e650b..665fc1b77a 100644 --- a/package/src/components/Message/hooks/useCreateMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useCreateMessageActionHandlers.ts @@ -75,6 +75,17 @@ export type CreateMessageActionHandlersParams = Pick< /** * Builds the handlers behind the message actions. Holds no subscriptions: anything the handlers * branch on (mute, ban and block state, the translated text) is read when the handler runs. + * + * @param params.channel The channel of the message. + * @param params.client The chat client. + * @param params.deleteMessage Deletes a message. + * @param params.deleteReaction Removes a reaction from a message. + * @param params.message The message the handlers act on. + * @param params.retrySendMessage Retries sending a failed message. + * @param params.sendReaction Adds a reaction to a message. + * @param params.setEditingState Puts the composer into editing state. + * @param params.setQuotedMessage Sets the message the composer quotes. + * @returns The handlers of the message actions. */ export const useCreateMessageActionHandlers = ({ channel, @@ -358,8 +369,8 @@ export const useCreateMessageActionHandlers = ({ const messageId = message.id; const own_reactions = message.own_reactions ?? []; const userExistingReaction = own_reactions.find((reaction) => { - // own user should only ever contain the current user id - // just in case we check to prevent bugs with message updates from breaking reactions + // `own_reactions` should only hold the current user's reactions; checked anyway so a bad + // message update cannot break toggling. if (reaction.user && client.userID === reaction.user.id && reaction.type === reactionType) { return true; } else if (reaction.user && client.userID !== reaction.user.id) { @@ -369,7 +380,7 @@ export const useCreateMessageActionHandlers = ({ } return false; }); - // Change reaction in local state, make API call in background, revert to old message if fails + // The operation applies the change locally at once, sends the request and rolls back on error. try { if (channel && messageId) { if (userExistingReaction) { diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index 0b371b9a83..213e914166 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -67,10 +67,8 @@ export const useCreateMessageContext = ({ ? stringifyMessage({ includeReactions: false, message: message.quoted_message }) : ''; - // Resolved here (not at each consumer) so the boolean lives on MessageContext - // and downstream components (MessageContent, MessageTextContainer) read it - // directly. The predicate's identity is stable in the default case; an - // integrator override is expected to be stable too (documented on the config). + // Resolved here so downstream components read the boolean from MessageContext. The predicate + // is expected to be stable, including an integrator override. const { hasInteractiveAccessibilityContent: hasInteractiveAccessibilityContentPredicate } = useAccessibilityContext(); const hasInteractiveAccessibilityContent = hasInteractiveAccessibilityContentPredicate(message); diff --git a/package/src/components/Message/hooks/useIsMessageDeliveredToOthers.ts b/package/src/components/Message/hooks/useIsMessageDeliveredToOthers.ts index 6420a743d0..a6f8a6f40d 100644 --- a/package/src/components/Message/hooks/useIsMessageDeliveredToOthers.ts +++ b/package/src/components/Message/hooks/useIsMessageDeliveredToOthers.ts @@ -6,14 +6,15 @@ import { useChannelContext } from '../../../contexts/channelContext/ChannelConte import { useStateStore } from '../../../hooks/useStateStore'; /** - * Whether the given message has been delivered to at least one *other* member, sourced reactively - * from the channel's `messageReceiptsTracker.snapshotStore`. + * Whether the message has been delivered to at least one other member, read from the channel's + * `messageReceiptsTracker.snapshotStore`. * - * As {@link useIsMessageReadByOthers}, for delivery: compares against the single furthest point - * anyone else has reached (`lastDeliveredRefByOthers`) rather than asking who is parked on this - * message, so the answer is monotonic across the list. + * Like {@link useIsMessageReadByOthers}, it compares the message against the furthest point anyone + * else has reached (`lastDeliveredRefByOthers`), so the answer is monotonic across the list. + * Use `useMessageDeliveredData` to get who received the message. * - * Use this for a delivery indicator; `useMessageDeliveredData` answers who has actually received it. + * @param params.message The message to check. + * @returns Whether another member has received the message. */ export const useIsMessageDeliveredToOthers = ({ message }: { message?: LocalMessage }) => { const { channel } = useChannelContext(); diff --git a/package/src/components/Message/hooks/useIsMessageReadByOthers.ts b/package/src/components/Message/hooks/useIsMessageReadByOthers.ts index 30e074780f..b63e8b56c9 100644 --- a/package/src/components/Message/hooks/useIsMessageReadByOthers.ts +++ b/package/src/components/Message/hooks/useIsMessageReadByOthers.ts @@ -6,17 +6,16 @@ import { useChannelContext } from '../../../contexts/channelContext/ChannelConte import { useStateStore } from '../../../hooks/useStateStore'; /** - * Whether at least one *other* member has read the given message, sourced reactively from the - * channel's `messageReceiptsTracker.snapshotStore`. + * Whether at least one other member has read the message, read from the channel's + * `messageReceiptsTracker.snapshotStore`. * - * Compares against `lastReadRefByOthers` — the single furthest point anyone else has reached — - * rather than asking who is parked on this message. Read cursors only move forward, so one - * comparison answers every message, and an older message can never report less than a newer one. - * The per-message maps cannot express that: they mark only the message each cursor happens to land - * on, so every message before it looks unread. + * It compares the message against `lastReadRefByOthers`, the furthest point anyone else has read. + * Read cursors only move forward, so an older message never reports less than a newer one, which + * the per-message maps cannot guarantee as they mark only the message each cursor lands on. + * Use `useMessageReadData` to get the users who read the message. * - * Use this for a read indicator. `useMessageReadData` answers a different question — the users who - * have read the message — and is what a message-info screen wants. + * @param params.message The message to check. + * @returns Whether another member has read the message. */ export const useIsMessageReadByOthers = ({ message }: { message?: LocalMessage }) => { const { channel } = useChannelContext(); diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index d97aa878fc..9282bf12d2 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -7,7 +7,9 @@ export { /** * The handlers behind the message actions (copy, delete, edit, pin, mute, ...) for the message this - * component renders. Call it inside a message, e.g. in a custom message component or a custom + * component renders. Call it inside a message, such as a custom message component or a custom * `MessageActionList`. Every caller shares the message's one set of handlers. + * + * @returns The action handlers of the message. */ export const useMessageActionHandlers = () => useMessageContext().messageActionHandlers; diff --git a/package/src/components/Message/hooks/useMessageActions.tsx b/package/src/components/Message/hooks/useMessageActions.tsx index b56721fd6f..0b18240811 100644 --- a/package/src/components/Message/hooks/useMessageActions.tsx +++ b/package/src/components/Message/hooks/useMessageActions.tsx @@ -19,9 +19,11 @@ import type { MessageActionType } from '../../MessageMenu/MessageActionListItem' /** * The default message actions (copy, edit, reply, pin, mute, delete, ...) for the message this - * component renders, ready to pass to a `MessageActionList`. Call it inside a message, e.g. in a - * custom message component or a custom `MessageActionList`. The SDK itself calls it only when a - * message's action menu opens. + * component renders, keyed by action name. Call it inside a message, such as a custom message + * component or a custom `MessageActionList`. The SDK itself calls it only when a message's action + * menu opens. + * + * @returns The message actions, keyed by action name. */ export const useMessageActions = () => { const { client } = useChatContext(); diff --git a/package/src/components/Message/hooks/useMessageOperations.ts b/package/src/components/Message/hooks/useMessageOperations.ts index 447a49a0a9..97e9ca89e6 100644 --- a/package/src/components/Message/hooks/useMessageOperations.ts +++ b/package/src/components/Message/hooks/useMessageOperations.ts @@ -20,12 +20,12 @@ export type MessageOperations = { }; /** - * Message operations (delete/remove/retry/react/update) resolved from the channel and — when a - * thread is open — the thread instance. These are thin wrappers over the stream-chat message - * operations engine (`messageOperations.*`, `messagePaginator.ingestItem/removeItem`), which owns the - * optimistic lifecycle, offline-DB persistence and paginator ingest. Previously these lived on the - * MessagesContext; they now resolve per-message (mirroring stream-chat-react, which has no shared - * action context) so the operations stay co-located with the LLC state they drive. + * Message operations (delete, remove, retry, react, update) routed to the channel and, when a + * thread is open, to the thread. They are thin wrappers over the `stream-chat` `messageOperations` + * and message paginators, which own the optimistic lifecycle, offline DB persistence and paginator + * ingest. + * + * @returns The message operations. */ export const useMessageOperations = (): MessageOperations => { const { client } = useChatContext(); @@ -46,15 +46,11 @@ export const useMessageOperations = (): MessageOperations => { } }); - /** - * Removes the message from local state - */ const removeMessage: MessageOperations['removeMessage'] = useStableCallback(async (message) => { if (channel) { - // removeItem is a no-op when the item isn't in that paginator, so removing from the channel - // unconditionally is safe (a hidden reply simply isn't there) and covers replies shown in - // the channel; replies additionally live in the open thread's paginator. No channel.state - // write (that store is being removed). + // `removeItem` is a no-op for an item the paginator does not hold, so removing from the + // channel unconditionally is safe and covers replies shown in the channel. Replies also + // live in the open thread's paginator. channel.messagePaginator.removeItem({ id: message.id }); if (message.parent_id) { thread?.messagePaginator?.removeItem({ id: message.id }); @@ -78,11 +74,10 @@ export const useMessageOperations = (): MessageOperations => { throw new Error('Channel has not been initialized'); } - // Route the optimistic update to the paginator that actually holds the message: the open - // thread when the message is a reply loaded there (giving pure replies optimism the channel - // paginator can't), otherwise the channel. Both instances forward the request through - // `channel.sendReaction`, so this only decides the optimistic ingest target — never sending a - // channel message's reaction into the thread paginator while a (channel-wide) thread is open. + // The optimistic update goes to the paginator that holds the message: the open thread for a + // reply loaded there (the channel paginator cannot hold pure replies), otherwise the channel. + // Both forward the request through `channel.sendReaction`, so this only picks the ingest + // target and keeps a channel message's reaction out of the thread paginator. const target = thread?.messagePaginator.getItem(messageId) ? thread : channel; await target.messageOperations.addReaction({ @@ -111,8 +106,8 @@ export const useMessageOperations = (): MessageOperations => { throw new Error('Channel has not been initialized yet'); } - // A failed send exists only locally, so it is removed without a server delete. A failed edit has - // the same status but the server has the message; it is the one carrying `message_text_updated_at`. + // A failed send exists only locally, so it is removed without a server delete. A failed edit + // has the same status but the server has the message, and carries `message_text_updated_at`. if (message.status === MessageStatusTypes.FAILED && !message.message_text_updated_at) { await removeMessage(message); return; @@ -127,13 +122,11 @@ export const useMessageOperations = (): MessageOperations => { options = { hard: true }; } - // The LLC owns the whole delete lifecycle: the optimistic `deleted` marking (or removal, for a - // hard delete), the request (honoring any configState delete handler), the offline-DB write, and - // the revert if the delete is definitively rejected. - // - // Routed by MEMBERSHIP, like `sendReaction` above: a reply loaded in the open thread goes through - // the thread instance, everything else — including the thread's own parent message, which the - // reply paginator cannot hold — through the channel. + // The LLC owns the delete lifecycle: the optimistic `deleted` marking (or removal for a hard + // delete), the request, the offline DB write, and the revert if the delete is rejected. + // Routed like `sendReaction`: a reply loaded in the open thread goes through the thread, all + // else (including the thread's parent message, which its paginator cannot hold) through the + // channel. const target = thread?.messagePaginator.getItem(message.id) ? thread : channel; await target.messageOperations.delete({ localMessage: message, diff --git a/package/src/components/Message/hooks/useProcessReactions.ts b/package/src/components/Message/hooks/useProcessReactions.ts index 01c2df0cf0..29722cf7f7 100644 --- a/package/src/components/Message/hooks/useProcessReactions.ts +++ b/package/src/components/Message/hooks/useProcessReactions.ts @@ -23,11 +23,11 @@ export type ReactionSummary = { export type ReactionsComparator = (a: ReactionSummary, b: ReactionSummary) => number; export type MessageReactionsData = { - /** An array of the reaction objects to display in the list */ + /** Reactions to display in the list. */ latest_reactions?: ReactionResponse[]; - /** An array of the own reaction objects to distinguish own reactions visually */ + /** Own reactions, to tell them apart visually. */ own_reactions?: ReactionResponse[] | null; - /** An object containing summary for each reaction type on a message */ + /** Summary of each reaction type on the message. */ reaction_groups?: Record | null; }; @@ -77,7 +77,11 @@ const getLatestReactedUserNames = (reactionType: string, latestReactions?: React : []; /** - * Custom hook to process reactions data from message and return a list of reactions with additional info. + * Turns the reaction data of a message into a sorted list of reactions with extra info. + * + * @param props The reaction data of the message, plus optional `sortReactions` and + * `supportedReactions` overrides. + * @returns `existingReactions` (sorted), `hasReactions` and `totalReactionCount`. */ export const useProcessReactions = (props: UseProcessReactionsParams) => { const { supportedReactions: contextSupportedReactions } = useMessagesContext(); diff --git a/package/src/components/Message/hooks/useStreamingMessage.ts b/package/src/components/Message/hooks/useStreamingMessage.ts index 2c8605857b..f6e308aaf3 100644 --- a/package/src/components/Message/hooks/useStreamingMessage.ts +++ b/package/src/components/Message/hooks/useStreamingMessage.ts @@ -11,12 +11,13 @@ const DEFAULT_LETTER_INTERVAL = 0; const DEFAULT_RENDERING_LETTER_COUNT = 2; /** - * A hook that returns text in a streamed, typewriter fashion. The speed of streaming is - * configurable. - * @param {number} [letterInterval=0] - The timeout between each typing animation in milliseconds. - * @param {number} [renderingLetterCount=2] - The number of letters to be rendered each time we update. - * @param {string} text - The text that we want to render in a typewriter fashion. - * @returns {{ streamedMessageText: string }} - A substring of the text property, up until we've finished rendering the typewriter animation. + * Reveals `text` in a typewriter fashion. It starts from the full initial text, so only text added + * later is animated. An unclosed code fence is closed while streaming. + * + * @param params.letterInterval Delay between typing steps in milliseconds. Defaults to 0. + * @param params.renderingLetterCount Number of letters revealed per step. Defaults to 2. + * @param params.text The text to reveal. + * @returns `streamedMessageText`, the part of `text` revealed so far. */ export const useStreamingMessage = ({ letterInterval = DEFAULT_LETTER_INTERVAL, diff --git a/package/src/components/Message/hooks/useUserMuteActive.ts b/package/src/components/Message/hooks/useUserMuteActive.ts index b51f1877e6..38e5af455b 100644 --- a/package/src/components/Message/hooks/useUserMuteActive.ts +++ b/package/src/components/Message/hooks/useUserMuteActive.ts @@ -7,6 +7,11 @@ import { useStateStore } from '../../../hooks/useStateStore'; /** * Whether `currentUserId` has muted `userId`, according to the given mute list. + * + * @param mutedUsers The mute list to search. + * @param currentUserId ID of the user who may have muted. + * @param userId ID of the user who may be muted. + * @returns Whether `currentUserId` has muted `userId`. */ export const isUserMuted = ( mutedUsers: UserMuteResponse[], @@ -20,7 +25,10 @@ export const isUserMuted = ( * Whether the current user has muted `user`. * * Selects that one answer from `client.mutedUsersStore`, so muting or unmuting somebody else does - * not rerender the caller. + * not re-render the caller. + * + * @param user The user to check. + * @returns Whether the current user has muted `user`. */ export const useUserMuteActive = (user: UserResponse | null | undefined) => { const { client } = useChatContext(); diff --git a/package/src/components/Message/utils/measureInWindow.ts b/package/src/components/Message/utils/measureInWindow.ts index e05d5a62b3..5abc80e675 100644 --- a/package/src/components/Message/utils/measureInWindow.ts +++ b/package/src/components/Message/utils/measureInWindow.ts @@ -10,29 +10,30 @@ import { isRN86OrGreater } from '../../../utils/react-native-constants'; type MeasuredRect = { x: number; y: number; w: number; h: number }; /** - * On Android, `measureInWindow` historically did not account for the top inset, so we added it - * ourselves. React Native 0.86 fixed this upstream as part of its edge-to-edge rework and now - * returns topInset corrected window coordinates. On >= 0.86 adding it again double counts the - * inset and offsets every measured view down by `insets.top`, so we only compensate on Android - * running React Native below 0.86. + * Top inset to add to a measured `y`. Android `measureInWindow` excludes the top inset below React + * Native 0.86; from 0.86 the coordinates include it, and adding it again would shift every measured + * view down by `insets.top`. */ const androidTopInsetCompensation = (insets: EdgeInsets): number => Platform.OS === 'android' && !isRN86OrGreater ? insets.top : 0; /** - * How many screen lengths away from the origin a measured coordinate may fall before we treat it as - * bogus. Real targets (a view currently on screen) always measure well within one screen and the failure - * mode we guard against is off by tens of screens, so a generous multiplier cleanly separates the two - * with no risk of false positives on legitimate values. + * Screen lengths from the origin beyond which a measured coordinate is treated as bogus. On-screen + * targets measure within one screen and the failure is off by tens of screens, so 2 separates them. */ const SCREEN_BOUND_MULTIPLIER = 2; /** - * `measureInWindow` can return wildly out of bounds coordinates on Android when the native window has - * been forced edge-to-edge behind React Native's back (i.e a library setting `FLAG_LAYOUT_NO_LIMITS`, - * as `react-native-system-navigation-bar` does for a transparent nav bar) while RN's own edge-to-edge - * (`edgeToEdgeEnabled`) is off. In that state the window relative measurement is corrupted and would - * return a position of the View far offscreen. + * Whether a measured rect is unusable: non-finite values, a non-positive size, or coordinates more + * than `SCREEN_BOUND_MULTIPLIER` screens from the origin. On Android, `measureInWindow` returns far + * out of bounds coordinates when a library forces the window edge-to-edge (such as setting + * `FLAG_LAYOUT_NO_LIMITS` for a transparent nav bar) while `edgeToEdgeEnabled` is off. + * + * @param x Measured x coordinate. + * @param y Measured y coordinate. + * @param w Measured width. + * @param h Measured height. + * @returns Whether the measurement should be discarded. */ export const isMeasuredRectBogus = (x: number, y: number, w: number, h: number): boolean => { if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) { @@ -43,7 +44,7 @@ export const isMeasuredRectBogus = (x: number, y: number, w: number, h: number): } const { width, height } = Dimensions.get('screen'); if (width <= 0 || height <= 0) { - // can't reason about bounds, trust the measurement + // Without a screen size there is nothing to compare against. return false; } return ( @@ -69,10 +70,9 @@ export const measureInWindow = ( return; } - // If `measureInWindow` returned an out of bounds rect, fallback to `measure()`, whose - // `pageX`/`pageY` are relative to the app root and are the same coordinate space as the window. - // They will stays correct when the window frame has been mutated out from under - // React Native. + // Falls back to `measure()` for a bogus rect: its `pageX`/`pageY` are relative to the app + // root, the same space as the window, and stay correct when the window frame was mutated + // behind React Native's back. if (typeof handle.measure !== 'function') { resolve({ h, w, x, y: y + androidTopInsetCompensation(insets) }); return; diff --git a/package/src/components/Message/utils/messageActions.ts b/package/src/components/Message/utils/messageActions.ts index 6566a4914c..106c11d293 100644 --- a/package/src/components/Message/utils/messageActions.ts +++ b/package/src/components/Message/utils/messageActions.ts @@ -24,7 +24,6 @@ export type MessageActionsParams = { threadReply: MessageActionType; unpinMessage: MessageActionType; blockUser: MessageActionType; - // Optional Actions deleteForMeMessage?: MessageActionType; } & Pick & Pick; diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index 2a5e6f6800..463c1508d9 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -243,7 +243,7 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { }, } = useTheme(); - // Close the attachment picker state when the component unmounts + // Closes the attachment picker on unmount. useEffect( () => () => { closeAttachmentPicker(); @@ -252,7 +252,7 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { ); /** - * Effect to get the draft data for legacy thread composer and set it to message composer. + * Loads the draft of the legacy thread composer into the message composer. * TODO: This can be removed once we remove legacy thread composer. */ useEffect(() => { @@ -279,7 +279,7 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { const getUsers = () => { const users = [...getMembers(), ...getWatchers()]; - // make sure we don't list users twice + // Deduplicate by user ID. const uniqueUsers: { [key: string]: UserResponse } = {}; for (const user of users) { if (user && !uniqueUsers[user.id]) { @@ -301,7 +301,7 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { return result; }; - // immediately focus the screen reader to the input on mount if a11y is enabled. + // Moves screen reader focus to the input on mount when a screen reader is enabled. useScreenReaderMountFocus(inputBoxRef); const isFocused = inputBoxRef.current?.isFocused(); @@ -386,7 +386,6 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { borderTopWidth: 1, backgroundColor: semantics.backgroundCoreElevation1, borderTopColor: semantics.borderCoreSubtle, - // paddingBottom: BOTTOM_OFFSET, paddingBottom: selectedPicker && !isKeyboardVisible ? attachmentPickerBottomSheetHeight - bottomInset + BOTTOM_OFFSET @@ -611,15 +610,7 @@ const MemoizedMessageComposer = React.memo( export type MessageComposerProps = Partial; -/** - * UI Component for message composer - * It's a consumer of - * [Channel Context](https://getstream.io/chat/docs/sdk/reactnative/contexts/channel-context/), - * [Chat Context](https://getstream.io/chat/docs/sdk/reactnative/contexts/chat-context/), - * [MessageInput Context](https://getstream.io/chat/docs/sdk/reactnative/contexts/message-input-context/), - * [Suggestions Context](https://getstream.io/chat/docs/sdk/reactnative/contexts/suggestions-context/), and - * [Translation Context](https://getstream.io/chat/docs/sdk/reactnative/contexts/translation-context/) - */ +/** Message composer UI. */ export const MessageComposer = (props: MessageComposerProps) => { const isOnline = useSettledWSConnectionHealth(); const ownCapabilities = useOwnCapabilitiesContext(); @@ -659,11 +650,8 @@ export const MessageComposer = (props: MessageComposerProps) => { const { t } = useTranslationContext(); - /** - * Disable the message input if the channel is frozen, or the user doesn't have the capability to send a message. - * Enable it in frozen mode, if it the input has editing state. - * - */ + // Without the `sendMessage` capability (such as in a frozen channel) only the disallowed + // indicator is rendered, unless a message is being edited. if ( capabilitiesKnown && !ownCapabilities.sendMessage && diff --git a/package/src/components/MessageInput/StopMessageStreamingButton.tsx b/package/src/components/MessageInput/StopMessageStreamingButton.tsx index 74d349116f..36d229a69a 100644 --- a/package/src/components/MessageInput/StopMessageStreamingButton.tsx +++ b/package/src/components/MessageInput/StopMessageStreamingButton.tsx @@ -6,7 +6,7 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; export type StopMessageStreamingButtonProps = { - /** Function that opens attachment options bottom sheet */ + /** Called when the button is pressed. */ onPress?: () => void; }; diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx index 68c42b9102..7f94edd88f 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx @@ -83,10 +83,6 @@ const getIsAudioAttachmentPreview = isLocalVoiceRecordingAttachment(attachment) || (soundPackageAvailable && isLocalAudioAttachment(attachment)); -/** - * AttachmentUploadPreviewList - * UI Component to preview the files set for upload - */ const UnMemoizedAttachmentUploadPreviewList = () => { const { AudioAttachmentUploadPreview, @@ -562,10 +558,7 @@ const MemoizedAttachmentUploadPreviewListWithContext = React.memo( UnMemoizedAttachmentUploadPreviewList, ); -/** - * AttachmentUploadPreviewList - * UI Component to preview the files set for upload - */ +/** Previews the attachments set for upload. */ export const AttachmentUploadPreviewList = () => ; const styles = StyleSheet.create({ diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx index c10d253126..98c359847c 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx @@ -30,9 +30,7 @@ type AudioRecorderPropsWithContext = Pick< | 'uploadVoiceRecording' > & Pick & { - /** - * Style used in slide to cancel container. - */ + /** Style of the slide to cancel container. */ slideToCancelStyle?: StyleProp>; }; @@ -211,9 +209,7 @@ const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ status: state.status, }); -/** - * Component to display the Recording UI in the Message Input. - */ +/** Recording UI of the message input. */ export const AudioRecorder = (props: AudioRecorderProps) => { const { audioRecorderManager, diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 461c97b469..e4fda1fe0f 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -40,17 +40,11 @@ export type AudioRecordingButtonPropsWithContext = Pick< | 'uploadVoiceRecording' > & Pick & { - /** - * Size of the mic button. - */ + /** Size of the mic button. */ buttonSize?: number; - /** - * Handler to determine what should happen on long press of the mic button. - */ + /** Called on long press of the mic button. */ handleLongPress?: () => void; - /** - * Handler to determine what should happen on press of the mic button. - */ + /** Called on press of the mic button. */ handlePress?: () => void; cancellableDuration: boolean; }; @@ -60,9 +54,7 @@ const buttonStylesConfig: ButtonStylesConfig = { type: 'ghost', }; -/** - * Component to display the mic button on the Message Input. - */ +/** Mic button of the message input. */ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonPropsWithContext) => { const { audioRecorderManager, diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx index 1f7781c678..961a312aa9 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingInProgress.tsx @@ -18,9 +18,7 @@ type AudioRecordingInProgressPropsWithContext = Pick< 'audioRecorderManager' > & Pick & { - /** - * Maximum number of waveform lines that should be rendered in the UI. - */ + /** Maximum number of waveform lines rendered. Defaults to 60. */ maxDataPointsDrawn?: number; }; @@ -41,7 +39,7 @@ const AudioRecordingInProgressWithContext = (props: AudioRecordingInProgressProp return ( - {/* `durationMillis` is for Expo apps, `currentPosition` is for Native CLI apps. */} + {/* Mic icon and elapsed recording time. */} @@ -66,9 +64,7 @@ const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ waveformData: state.waveformData, }); -/** - * Component displayed when the audio is in the recording state. - */ +/** Displayed while audio is being recorded. */ export const AudioRecordingInProgress = (props: AudioRecordingInProgressProps) => { const { audioRecorderManager } = useMessageInputContext(); diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx index ad633918a9..4fe55943be 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingLockIndicator.tsx @@ -9,19 +9,13 @@ import { AudioRecorderManagerState } from '../../../../state-store/audio-recorde import { primitives } from '../../../../theme'; export type AudioRecordingLockIndicatorProps = Pick & { - /** - * Height of the message input, to apply necessary position adjustments to this component. - */ + /** Height of the message input, for positioning this component. */ messageInputHeight?: number; - /** - * Styles of the lock indicator. - */ + /** Styles of the lock indicator. */ style?: StyleProp>; }; -/** - * Component displayed to show the lock state of the recording when the button is slided up. - */ +/** Shows the lock state of the recording while the mic button is slid up. */ export const AudioRecordingLockIndicator = ({ messageInputHeight, micLocked, diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx index 4ed6f2d978..137ec3c41a 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingPreview.tsx @@ -42,9 +42,7 @@ const audioRecorderSelector = (state: AudioRecorderManagerState) => ({ recording: state.recording, }); -/** - * Component displayed when the audio is recorded and can be previewed. - */ +/** Displayed after a recording stops, to preview it before sending. */ export const AudioRecordingPreview = () => { const { audioRecorderManager } = useMessageInputContext(); const { icons } = useComponentsContext(); @@ -62,7 +60,7 @@ export const AudioRecordingPreview = () => { const audioPlayer = useAudioPlayer({ duration: recordingDuration / ONE_SECOND_IN_MILLISECONDS, mimeType: 'audio/aac', - // The preview still uses the recorder-backed player until both playback paths are unified. + // The preview uses the regular sound player, not the recorder-backed one. previewVoiceRecording: false, type: 'voiceRecording', uri, @@ -73,7 +71,7 @@ export const AudioRecordingPreview = () => { audioPlayerSelector, ); - // When a audio attachment in preview is removed, we need to remove the player from the pool + // Removes the player from the pool on unmount. useEffect( () => () => { audioPlayer.onRemove(); @@ -127,7 +125,7 @@ export const AudioRecordingPreview = () => { )} - {/* `durationMillis` is for Expo apps, `currentPosition` is for Native CLI apps. */} + {/* Elapsed playback time. */} { /> - {/* Since the progress is in range 0-1 we convert it in terms of 100% */} + {/* `progress` is a fraction from 0 to 1. */} & { - /** - * Maximum number of waveform lines that should be rendered in the UI. - */ + /** Maximum number of waveform lines rendered. */ maxDataPointsDrawn: number; }; const WAVEFORM_MAX_HEIGHT = 20; -/** - * Waveform Component displayed when the audio is in the recording state. - */ +/** Waveform displayed while audio is being recorded. */ export const AudioRecordingWaveform = (props: AudioRecordingWaveformProps) => { const { maxDataPointsDrawn, waveformData } = props; const styles = useStyles(); diff --git a/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx b/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx index 524a3ddae6..7fe3cd500f 100644 --- a/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx +++ b/package/src/components/MessageInput/components/InputButtons/AttachButton.tsx @@ -17,7 +17,7 @@ import { Button } from '../../../ui/'; type AttachButtonPropsWithContext = Pick & Pick & { disabled?: boolean; - /** Function that opens attachment options bottom sheet */ + /** Press handler that takes precedence over `handleAttachButtonPress` and the picker toggle. */ handleOnPress?: ((event: GestureResponderEvent) => void) & (() => void); isAttachmentPickerOpen?: boolean; } & { toggleAttachmentPicker: () => void }; @@ -90,9 +90,7 @@ const MemoizedAttachButton = React.memo( export type AttachButtonProps = Partial; -/** - * UI Component for attach button in MessageComposer component. - */ +/** Attach button of the message composer. */ export const AttachButton = (props: AttachButtonProps) => { const { disableAttachmentPicker } = useAttachmentPickerContext(); const { diff --git a/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx b/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx index 19346a3334..22a36f7178 100644 --- a/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx +++ b/package/src/components/MessageInput/components/OutputButtons/CooldownTimer.tsx @@ -3,9 +3,7 @@ import React from 'react'; import { Button } from '../../../../components/ui'; import { useCooldownRemaining } from '../../hooks/useCooldownRemaining'; -/** - * Renders an amount of seconds left for a cooldown to finish. - **/ +/** Shows the seconds left of the channel cooldown. */ export const CooldownTimer = () => { const seconds = useCooldownRemaining(); diff --git a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx index 1bcce4085e..6ce9f05a3f 100644 --- a/package/src/components/MessageInput/hooks/useAudioRecorder.tsx +++ b/package/src/components/MessageInput/hooks/useAudioRecorder.tsx @@ -11,7 +11,12 @@ import { generateRandomId } from '../../../utils/utils'; import { resampleWaveformData } from '../utils/audioSampling'; /** - * The hook that controls all the async audio core features including start/stop or recording, player, upload/delete of the recorded audio. + * Controls voice recording: start, stop, delete, and upload or send of the recorded audio. + * + * @param params.audioRecorderManager Manager that owns the recording state. + * @param params.sendMessage Sends the message when a recording is sent on completion. + * @returns `deleteVoiceRecording`, `startVoiceRecording`, `stopVoiceRecording` and + * `uploadVoiceRecording`. */ export const useAudioRecorder = ({ audioRecorderManager, @@ -19,11 +24,7 @@ export const useAudioRecorder = ({ }: Pick) => { const { attachmentManager } = useMessageComposer(); - /** - * A function that takes care of stopping the voice recording from the library's - * side only. Meant to be used as a pure function (during unmounting for instance) - * hence this approach. - */ + // Stops the recording on the library side only, so it is safe to call on unmount. const stopVoiceRecording = useCallback( async (withDelete?: boolean) => { await audioRecorderManager.stopRecording(withDelete); @@ -31,8 +32,7 @@ export const useAudioRecorder = ({ [audioRecorderManager], ); - // This effect stop the player from playing and stops audio recording on - // the audio SDK side on unmount. + // Stops the recording on the audio SDK side on unmount. useEffect( () => () => { stopVoiceRecording(); @@ -40,26 +40,16 @@ export const useAudioRecorder = ({ [stopVoiceRecording], ); - /** - * Function to start voice recording. Will return whether access is granted - * with regards to the microphone permission as that's how the underlying - * library works on iOS. - */ + // Resolves to whether microphone access is granted, as the underlying library reports on iOS. const startVoiceRecording = useCallback(async () => { return await audioRecorderManager.startRecording(); }, [audioRecorderManager]); - /** - * Function to delete voice recording. - */ const deleteVoiceRecording = useCallback(async () => { await stopVoiceRecording(true); }, [stopVoiceRecording]); - /** - * Function to upload or send voice recording. - * @param sendOnComplete boolean - */ + // Sends the message with the recording when `sendOnComplete` is true, otherwise only uploads it. const uploadVoiceRecording = useCallback( async (sendOnComplete: boolean) => { try { diff --git a/package/src/components/MessageInput/hooks/useCooldownRemaining.tsx b/package/src/components/MessageInput/hooks/useCooldownRemaining.tsx index e3ec0ea0eb..a4f6e04928 100644 --- a/package/src/components/MessageInput/hooks/useCooldownRemaining.tsx +++ b/package/src/components/MessageInput/hooks/useCooldownRemaining.tsx @@ -8,10 +8,10 @@ const cooldownTimerStateSelector = (state: CooldownTimerState) => ({ }); /** - * Provides and initial value of cooldown, from which the countdown should start, e.g.: + * The seconds left of the channel cooldown, or 0 when there is none. It reads + * `channel.cooldownTimer`, so the remaining time is correct after a reload. * - * The value of channel.data.cooldown is 100s but 30s has already elapsed, user reloads the page, - * the initial value is now 70s from which the countdown will continue using useTimer() hook. + * @returns Seconds left of the cooldown. */ export const useCooldownRemaining = (): number => { const { channel } = useChannelContext(); diff --git a/package/src/components/MessageInput/utils/audioSampling.ts b/package/src/components/MessageInput/utils/audioSampling.ts index ef4b4fbfe7..4b51c3570c 100644 --- a/package/src/components/MessageInput/utils/audioSampling.ts +++ b/package/src/components/MessageInput/utils/audioSampling.ts @@ -6,10 +6,12 @@ export const resampleWaveformData = (waveformData: number[], amplitudesCount: nu : upSample(waveformData, amplitudesCount); /** - * The downSample function uses the Largest-Triangle-Three-Buckets (LTTB) algorithm. - * See the thesis Downsampling Time Series for Visual Representation by Sveinn Steinarsson for more (https://skemman.is/bitstream/1946/15343/3/SS_MSthesis.pdf) - * @param data - * @param targetOutputSize + * Downsamples `data` with the Largest-Triangle-Three-Buckets (LTTB) algorithm, keeping the first + * and last points. See https://skemman.is/bitstream/1946/15343/3/SS_MSthesis.pdf + * + * @param data The values to downsample. + * @param targetOutputSize The number of values to return. + * @returns The downsampled values, or `data` itself when it already fits the target. */ export function downSample(data: number[], targetOutputSize: number): number[] { if (data.length <= targetOutputSize || targetOutputSize === 0) { @@ -21,7 +23,7 @@ export function downSample(data: number[], targetOutputSize: number): number[] { } const result: number[] = []; - // bucket size adjusted due to the fact that the first and the last item in the original data array is kept in target output + // Excludes the first and last items, which are always kept. const bucketSize = (data.length - 2) / (targetOutputSize - 2); let lastSelectedPointIndex = 0; result.push(data[lastSelectedPointIndex]); // Always add the first point diff --git a/package/src/components/MessageInput/utils/normalizeAudioLevel.ts b/package/src/components/MessageInput/utils/normalizeAudioLevel.ts index 4e0a7dbf0b..7b5f99f485 100644 --- a/package/src/components/MessageInput/utils/normalizeAudioLevel.ts +++ b/package/src/components/MessageInput/utils/normalizeAudioLevel.ts @@ -1,5 +1,9 @@ /** - * Utility function to normalize the audio level. + * Maps an audio level between `lowerBound` and 0 to the range 0 to 1. + * + * @param value The audio level. An undefined value counts as the loudest level. + * @param lowerBound The level that maps to 0. Quieter levels also map to 0. + * @returns The normalized level. */ export const normalizeAudioLevel = (value: number, lowerBound: number) => { // For Native CLI, the lower bound is around -50 diff --git a/package/src/components/MessageList/InlineDateSeparator.tsx b/package/src/components/MessageList/InlineDateSeparator.tsx index 5e04284cea..8d3cec6083 100644 --- a/package/src/components/MessageList/InlineDateSeparator.tsx +++ b/package/src/components/MessageList/InlineDateSeparator.tsx @@ -6,13 +6,8 @@ import { useTranslationContext } from '../../contexts/translationContext/Transla import { getCalendarDateStringForA11y, getDateString } from '../../i18n/utils'; import { primitives } from '../../theme'; -/** - * Props for the `InlineDateSeparator` component. - */ export type InlineDateSeparatorProps = { - /** - * Date to be displayed. - */ + /** Date to display. */ date?: Date; }; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 64ff5b1220..6b43130991 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -103,74 +103,44 @@ type MessageFlashListPropsWithContext = Pick< Pick & Pick & { /** - * Whether the composer lets a message be sent while its attachments are still uploading - * (`messageComposer.attachments.pendingUploadsEnabled`). Read from the composer by default. + * Whether a message can be sent while its attachments are still uploading. Read from + * `messageComposer.attachments.pendingUploadsEnabled` by default. */ pendingUploadsEnabled: boolean; markRead: (options?: MarkReadFunctionOptions) => void; } & Pick & Pick & { /** - * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want - * to attach some additional props to underlying FlatList, you can add it to following prop. - * - * You can find list of all the available FlatList props here - https://facebook.github.io/react-native/docs/flatlist#props - * - * **NOTE** Don't use `additionalFlatListProps` to get access to ref of flatlist. Use `setFlatListRef` instead. - * - * e.g. - * ```js - * - * ``` + * Extra props for the underlying FlashList, applied over the defaults. To get the list ref, use + * `setFlatListRef`. */ additionalFlashListProps?: Partial>; /** - * UI component for footer of message list. By default message list will use `InlineLoadingMoreIndicator` - * as FooterComponent. If you want to implement your own inline loading indicator, you can access `loadingMore` - * from context. - * - * This is a [ListHeaderComponent](https://facebook.github.io/react-native/docs/flatlist#listheadercomponent) of FlatList - * used in MessageList. Should be used for header by default if inverted is true or defaulted + * Rendered as the list footer, which is the newest-message end. Defaults to the + * loading-more-recent indicator followed by the typing indicator. */ FooterComponent?: React.ComponentType; /** - * UI component for header of message list. By default message list will use `InlineLoadingMoreRecentIndicator` - * as HeaderComponent. If you want to implement your own inline loading indicator, you can access `loadingMoreRecent` - * from context. - * - * This is a [ListFooterComponent](https://facebook.github.io/react-native/docs/flatlist#listheadercomponent) of FlatList - * used in MessageList. Should be used for header if inverted is false + * Rendered as the list header, which is the older-message end. Receives `loadingMore`. Defaults + * to `ThreadFooterComponent` in a thread, `InlineLoadingMoreIndicator` otherwise. */ HeaderComponent?: React.ComponentType<{ loadingMore?: boolean }>; - /** Whether or not the FlatList is inverted. Defaults to true */ + /** Ignored: the FlashList is never inverted. */ inverted?: boolean; - /** Turn off grouping of messages by user */ + /** Turns off grouping of messages by user. */ noGroupByUser?: boolean; onListScroll?: ScrollViewProps['onScroll']; /** - * Handler to open the thread on message. This is callback for touch event for replies button. + * Called when the replies button of a message is pressed. * * @param thread The thread of the message, to render in a ``. */ onThreadSelect?: (thread: Thread) => void; - /** - * Use `setFlatListRef` to get access to ref to inner FlatList. - * - * e.g. - * ```js - * { - * // Use ref for your own good - * }} - * ``` - */ + /** Receives the ref of the inner FlashList. */ setFlatListRef?: (ref: FlashListRef | null) => void; /** - * If true, the message list will be used in a live-streaming scenario. - * This flag is used to make sure that the auto scroll behaves well, if multiple messages are received. - * - * This flag is experimental and is subject to change. Please test thoroughly before using it. + * Tunes autoscroll for live-streaming scenarios, where many messages arrive in a short time. + * Defaults to false. * * @experimental */ @@ -179,12 +149,10 @@ type MessageFlashListPropsWithContext = Pick< const WAIT_FOR_SCROLL_TIMEOUT = 0; -// Classify an attachment bearing message by its primary shape so FlashList only -// recycles same shaped cells (means less work to rerender). Gallery/media is the -// heaviest subtree to mount, so we short circuit to it as soon as we see one gallery -// image/video nad this keeps gallery cells recycling only with other gallery cells, -// so the Gallery subtree reconciles on rebind instead of unmount & remount. Mirrors -// the attachment categorization in Message. +// Classifies a message with attachments by its primary shape, so FlashList recycles only +// same-shaped cells. The gallery is the heaviest subtree to mount, so one gallery image or video +// returns at once: gallery cells then recycle only with each other and reconcile on rebind instead +// of remounting. Mirrors the attachment categorization in Message. const getAttachmentItemType = (message: LocalMessage) => { const attachments = message.attachments ?? []; let hasGiphy = false; @@ -293,7 +261,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => thread, } = props; - // Inside a thread the parent message sits above the replies, along with the older-replies indicator. + // Inside a thread the parent message and the older-replies indicator sit above the replies. const HeaderComponent = HeaderComponentProp ?? (thread ? ThreadFooterComponent : InlineLoadingMoreIndicator); const { @@ -319,9 +287,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); const [scrollEnabled, setScrollEnabled] = useState(true); - /** - * The timeout id used to debounce our scrollToIndex calls on messageList updates - */ const scrollToDebounceTimeoutRef = useRef>(undefined); const { theme } = useTheme(); @@ -397,9 +362,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }, [autoscrollToRecent, hasPendingInitialTargetLoad]); - // While the message overlay is open we suppress autoscroll-to-recent so that - // incoming messages do not shift visible content and invalidate the overlay's - // anchored geometry. Content anchoring (startRenderingFromBottom) stays on. + // Autoscroll-to-recent is suppressed while the message overlay is open, so incoming messages do + // not shift content under the overlay's anchored geometry. `startRenderingFromBottom` stays on. const isOverlayOpen = useHasActiveId(); const maintainVisibleContentPosition = useMemo(() => { @@ -418,11 +382,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const lastFocusScrollTokenRef = useRef(undefined); - /** - * Scrolls to the focused message (messageFocusSignal) once it's rendered. Re-attempts when the - * list updates while a focus is pending, and marks each focus token handled so unrelated list - * changes during the highlight window don't re-scroll. - */ + // Scrolls to the focused message (`messageFocusSignal`) once it is rendered. Each focus token is + // handled once, so list changes during the highlight do not re-scroll. useEffect(() => { if (!focusedMessageId || focusToken === lastFocusScrollTokenRef.current) { return; @@ -432,8 +393,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => (message) => message?.id === focusedMessageId, ); - // Not in the rendered window yet (jumpToMessage already loaded-around it, so a later - // processedMessageList change re-runs this) — bail rather than re-jump (no jump↔effect loop). + // Not in the rendered window yet: a later `processedMessageList` change re-runs this effect. if (indexOfParentInMessageList === -1) { return; } @@ -462,9 +422,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => requestAnimationFrame(async () => { await scrollToIndex(); }); - // Start the highlight's auto-dismiss countdown now that the message is scrolled into view. - // The LLC deliberately does NOT start it on emit (the message may not be visible yet), so - // without this the highlight would persist forever. + // The client does not start the highlight's auto-dismiss countdown on emit, as the message + // may not be visible yet. Start it now that the message is scrolled into view. paginator?.scheduleMessageFocusSignalClear({ token: focusToken }); }, WAIT_FOR_SCROLL_TIMEOUT); }, [paginator, focusToken, focusedMessageId, processedMessageList]); @@ -494,8 +453,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (!didMergeMessageSetsWithNoUpdates) { const shouldScrollToRecentOnNewOwnMessage = shouldScrollToRecentOnNewOwnMessageRef.current(); - // we should scroll to bottom where ever we are now - // as we have sent a new own message + // A new own message scrolls to the latest wherever the list currently is. if (shouldScrollToRecentOnNewOwnMessage) { flashListRef.current?.scrollToEnd({ animated: true, @@ -504,10 +462,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }, [paginator, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef]); - /** - * FlatList doesn't accept changeable function for onViewableItemsChanged prop. - * Thus useRef. - */ + // The list does not accept a changing `onViewableItemsChanged`, hence the ref below. const unstableOnViewableItemsChanged = ({ viewableItems, }: { @@ -554,8 +509,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ); const setNativeScrollability = useStableCallback((value: boolean) => { - // FlashList does not have setNativeProps exposed, hence we cannot use that. - // Instead, we resort to state. + // FlashList does not expose `setNativeProps`, so scrollability is state. setScrollEnabled(value); }); @@ -570,10 +524,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [goToMessage, modifiedTheme, noGroupByUser, onThreadSelect, setNativeScrollability], ); - /** - * Pagination is driven from the scroll position rather than the list's `onEndReached`, from a - * fixed distance to either edge. Not inverted: the top is the oldest end. - */ + // Paginates within 100 points of either edge. Not inverted: the top is the oldest end. const onUserScrollEvent: NonNullable = useStableCallback((event) => { const nativeEvent = event.nativeEvent; const offset = nativeEvent.contentOffset.y; @@ -601,12 +552,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const isScrollAtBottom = contentLength - visibleLength - offset < messageInputHeight; - /** - * 1. If I scroll up -> show scrollToBottom button. - * 2. If I scroll to bottom of screen - * |-> hide scrollToBottom button. - * |-> if channel is unread, call markRead(). - */ + // The scroll-to-bottom button shows unless the list is at the bottom with no newer messages. setScrollToBottomButtonVisible( messageListHasMessages && (paginator?.hasMoreHead || !isScrollAtBottom), ); @@ -631,10 +577,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (thread) { return; } - /** - * When we are not in the bottom of the list, and we receive new messages, we need to mark the channel as read. - We would still need to show the unread label, where the first unread message appeared so we don't update the channelUnreadState. - */ + // `updateChannelUnreadState: false` keeps the unread label on the first unread message. await markRead({ updateChannelUnreadState: false, }); @@ -669,7 +612,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }); - // We need to omit the style related props from the additionalFlatListProps and add them directly instead of spreading + // `style` and `contentContainerStyle` are merged with the SDK styles below, not spread. let additionalFlashListPropsExcludingStyle: | Omit, 'style' | 'contentContainerStyle'> | undefined; @@ -712,9 +655,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => bumpOverlayLayoutRevision(closeCorrectionDeltaY); const changedBy = currentListHeightRef.current - height; - // `getNativeScrollRef()` is typed as returning the `ScrollView` *component* by flash-list, - // which under React Native 0.87's Strict TypeScript API is a function type rather than the - // host instance it actually returns at runtime. Re-assert the instance type. + // flash-list types `getNativeScrollRef()` as the `ScrollView` component, a function type under + // React Native 0.87's Strict TypeScript API, but it returns the host instance at runtime. const nativeScrollRef = flashListRef.current?.getNativeScrollRef() as | ScrollViewRef | null @@ -868,10 +810,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }; /** - * Unfortunately, FlashList does not handle autoscrolling if the footer changes properly. Because - * of that, we calculate this manually and autoscroll to the bottom if we're near the end. We only - * do this if the typing indicator is about to be rendered for now. Later on we can rely on proper - * layout calculations. + * Scrolls to the end when the typing indicator appears and the list is near the end, as FlashList + * does not autoscroll when its footer changes. */ const FlashListFooterTypingAdapter = ({ enabled, @@ -913,10 +853,10 @@ const FlashListFooterTypingAdapter = ({ export type MessageFlashListProps = Partial; /** - * This is a @experimental component. - * It is implemented using @shopify/flash-list package to optimize the performance of the MessageList component. - * The implementation is experimental and is subject to change. - * Please feel free to report any issues or suggestions. + * A `MessageList` built on `@shopify/flash-list` for performance. The implementation is subject to + * change. + * + * @experimental */ export const MessageFlashList = (props: MessageFlashListProps) => { const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); @@ -996,12 +936,7 @@ const useStyles = () => { ...container, }, contentContainer: { - /** - * paddingBottom is set to 4 to account for the default date - * header and inline indicator alignment. The top margin is 8 - * on the header but 4 on the inline date, this adjusts the spacing - * to allow the "first" inline date to align with the date header. - */ + // 4 aligns the first inline date (top margin 4) with the date header (top margin 8). paddingBottom: 4, ...contentContainer, }, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index b2af697c9a..74bb9b5037 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -82,7 +82,7 @@ import { excludeCanceledUploadNotifications } from '../Notifications/notificatio import { ThreadFooterComponent } from '../Thread/components/ThreadFooterComponent'; import { PortalWhileClosingView } from '../UIComponents'; -// This is just to make sure that the scrolling happens in a different task queue. +// Zero-delay timeout that moves scrolling to a later task. // TODO: Think if we really need this and strive to remove it if we can. const WAIT_FOR_SCROLL_TIMEOUT = 0; const MAX_RETRIES_AFTER_SCROLL_FAILURE = 10; @@ -123,12 +123,7 @@ const useStyles = () => { ...container, }, contentContainer: { - /** - * paddingBottom is set to 4 to account for the default date - * header and inline indicator alignment. The top margin is 8 - * on the header but 4 on the inline date, this adjusts the spacing - * to allow the "first" inline date to align with the date header. - */ + // 4 aligns the first inline date (top margin 4) with the date header (top margin 8). paddingBottom: 4, ...contentContainer, }, @@ -192,71 +187,42 @@ type MessageListPropsWithContext = Pick< Pick & Pick & { /** - * Whether the composer lets a message be sent while its attachments are still uploading - * (`messageComposer.attachments.pendingUploadsEnabled`). Read from the composer by default. + * Whether a message can be sent while its attachments are still uploading. Read from + * `messageComposer.attachments.pendingUploadsEnabled` by default. */ pendingUploadsEnabled: boolean; /** - * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want - * to attach some additional props to underlying FlatList, you can add it to following prop. - * - * You can find list of all the available FlatList props here - https://facebook.github.io/react-native/docs/flatlist#props - * - * **NOTE** Don't use `additionalFlatListProps` to get access to ref of flatlist. Use `setFlatListRef` instead. - * - * e.g. - * ```js - * - * ``` + * Extra props for the underlying FlatList, applied over the defaults. To get the list ref, use + * `setFlatListRef`. */ additionalFlatListProps?: Partial>; /** - * UI component for footer of message list. By default message list will use `InlineLoadingMoreIndicator` - * as FooterComponent. If you want to implement your own inline loading indicator, you can access `loadingMore` - * from props. - * - * This is a [ListHeaderComponent](https://facebook.github.io/react-native/docs/flatlist#listheadercomponent) of FlatList - * used in MessageList. Should be used for header by default if inverted is true or defaulted + * Rendered as the list footer, which is the older-message end when inverted. Receives + * `loadingMore`. Defaults to `ThreadFooterComponent` in a thread, `InlineLoadingMoreIndicator` + * otherwise. */ FooterComponent?: React.ComponentType<{ loadingMore?: boolean }>; /** - * UI component for header of message list. By default message list will use `InlineLoadingMoreRecentIndicator` - * as HeaderComponent. If you want to implement your own inline loading indicator, you can access `loadingMoreRecent` - * from context. - * - * This is a [ListFooterComponent](https://facebook.github.io/react-native/docs/flatlist#listheadercomponent) of FlatList - * used in MessageList. Should be used for header if inverted is false + * Rendered as the list header, which is the newest-message end when inverted. Defaults to the + * loading-more-recent indicator followed by the typing indicator. */ HeaderComponent?: React.ComponentType; - /** Whether or not the FlatList is inverted. Defaults to true */ + /** Whether the FlatList is inverted. Defaults to true. */ inverted?: boolean; - /** Turn off grouping of messages by user */ + /** Turns off grouping of messages by user. */ noGroupByUser?: boolean; onListScroll?: ScrollViewProps['onScroll']; /** - * Handler to open the thread on message. This is callback for touch event for replies button. + * Called when the replies button of a message is pressed. * * @param thread The thread of the message, to render in a ``. */ onThreadSelect?: (thread: Thread) => void; - /** - * Use `setFlatListRef` to get access to ref to inner FlatList. - * - * e.g. - * ```js - * { - * // Use ref for your own good - * }} - * ``` - */ + /** Receives the ref of the inner FlatList. */ setFlatListRef?: (ref: FlatListType | null) => void; /** - * If true, the message list will be used in a live-streaming scenario. - * This flag is used to make sure that the auto scroll behaves well, if multiple messages are received. - * - * This flag is experimental and is subject to change. Please test thoroughly before using it. + * Tunes autoscroll for live-streaming scenarios, where many messages arrive in a short time. + * Defaults to false. * * @experimental */ @@ -268,15 +234,7 @@ const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ height: state.height, }); -/** - * The message list component renders a list of messages. It consumes the following contexts: - * - * [ChannelContext](https://getstream.io/chat/docs/sdk/reactnative/contexts/channel-context/) - * [ChatContext](https://getstream.io/chat/docs/sdk/reactnative/contexts/chat-context/) - * [MessagesContext](https://getstream.io/chat/docs/sdk/reactnative/contexts/messages-context/) - * [ThreadContext](https://getstream.io/chat/docs/sdk/reactnative/contexts/thread-context/) - * [TranslationContext](https://getstream.io/chat/docs/sdk/reactnative/contexts/translation-context/) - */ +/** Renders the list of messages in a FlatList. */ const MessageListWithContext = (props: MessageListPropsWithContext) => { const { animateLayout = true, @@ -306,7 +264,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { thread, } = props; - // Inside a thread the parent message sits above the replies, along with the older-replies indicator. + // Inside a thread the parent message and the older-replies indicator sit above the replies. const FooterComponent = FooterComponentProp ?? (thread ? ThreadFooterComponent : InlineLoadingMoreIndicator); const { @@ -404,9 +362,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const minIndexForVisible = Math.min(1, processedMessageList.length); - // While the message overlay is open we suppress autoscroll-to-recent so that - // incoming messages do not shift visible content and invalidate the overlay's - // anchored geometry. Content anchoring (minIndexForVisible) stays on. + // Autoscroll-to-recent is suppressed while the message overlay is open, so incoming messages do + // not shift content under the overlay's anchored geometry. `minIndexForVisible` stays on. const isOverlayOpen = useHasActiveId(); const autoscrollToTopThreshold = @@ -422,17 +379,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const flatListRef = useRef | null>(null); - /** - * The timeout id used to debounce our scrollToIndex calls on messageList updates - */ const scrollToDebounceTimeoutRef = useRef>(undefined); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); - /** - * FlatList doesn't accept changeable function for onViewableItemsChanged prop. - * Thus useRef. - */ + // FlatList does not accept a changing `onViewableItemsChanged`, hence the ref below. const unstableOnViewableItemsChanged = ({ viewableItems, }: { @@ -508,23 +459,22 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const shouldForceScrollToRecent = latestNonCurrentMessageBeforeUpdate?.id !== latestCurrentMessageAfterUpdate.id; - // we don't want this behaviour while pruning, as it may scroll unnecessarily in - // certain scenarios + // While pruning (`maxLoadedItems`), only follow new messages: anything else may scroll + // unnecessarily. if ((maxLoadedItems && shouldForceScrollToRecent) || !maxLoadedItems) { setAutoscrollToRecent(shouldForceScrollToRecent); } if (shouldForceScrollToRecent) { const shouldScrollToRecentOnNewOwnMessage = shouldScrollToRecentOnNewOwnMessageRef.current(); - // we should scroll to bottom where ever we are now - // as we have sent a new own message + // A new own message scrolls to the latest wherever the list currently is. if (shouldScrollToRecentOnNewOwnMessage) { setTimeout(() => { flatListRef.current?.scrollToOffset({ animated: true, offset: 0, }); - }, WAIT_FOR_SCROLL_TIMEOUT); // flatlist might take a bit to update, so a small delay is needed + }, WAIT_FOR_SCROLL_TIMEOUT); // FlatList may need a moment to update } } }, [ @@ -537,12 +487,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const lastFocusScrollTokenRef = useRef(undefined); - /** - * Scrolls to the focused message (messageFocusSignal) once it's rendered. Keyed on the focus - * token so it re-fires on every jump (even to the same id); also re-attempts when the list - * updates while a focus is pending, and marks each token handled so unrelated list changes during - * the highlight window don't re-scroll. - */ + // Scrolls to the focused message (`messageFocusSignal`) once it is rendered. Keyed on the focus + // token, so every jump re-fires, even to the same ID, and list changes during the highlight do + // not re-scroll. useEffect(() => { if (!focusedMessageId || focusToken === lastFocusScrollTokenRef.current) { return; @@ -551,8 +498,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const indexOfParentInMessageList = processedMessageList.findIndex( (message) => message?.id === focusedMessageId, ); - // Not in the rendered window yet (jumpToMessage already loaded-around it, so a later - // processedMessageList change re-runs this) — bail rather than re-jump (no jump↔effect loop). + // Not in the rendered window yet: a later `processedMessageList` change re-runs this effect. if (indexOfParentInMessageList === -1 || !flatListRef.current) { return; } @@ -563,11 +509,10 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { flatListRef.current.scrollToIndex({ animated: true, index: indexOfParentInMessageList, - viewPosition: 0.5, // try to place message in the center of the screen + viewPosition: 0.5, // center the message }); - // Start the highlight's auto-dismiss countdown now that the message is scrolled into view. - // The LLC deliberately does NOT start it on emit (the message may not be visible yet), so - // without this the highlight would persist forever. + // The client does not start the highlight's auto-dismiss countdown on emit, as the message + // may not be visible yet. Start it now that the message is scrolled into view. paginator?.scheduleMessageFocusSignalClear({ token: focusToken }); }, WAIT_FOR_SCROLL_TIMEOUT); }, [paginator, focusToken, focusedMessageId, processedMessageList]); @@ -589,11 +534,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { [goToMessage, modifiedTheme, noGroupByUser, onThreadSelect, setNativeScrollability], ); - /** - * Pagination is driven from the scroll position rather than FlatList's `onEndReached`: FlatList - * has no `onStartReached`, and `onEndReachedThreshold` is a fraction of the content length, which - * fires far too early in a long list. A fixed distance from either edge is used instead. - */ + // Paginates within 100 points of either edge: FlatList has no `onStartReached`, and + // `onEndReachedThreshold` is a fraction of the content length, which fires too early in a long + // list. const onUserScrollEvent: NonNullable = useStableCallback((event) => { const nativeEvent = event.nativeEvent; const offset = nativeEvent.contentOffset.y; @@ -618,12 +561,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const offset = event.nativeEvent.contentOffset.y; const isScrollAtBottom = offset <= messageInputHeight; - /** - * 1. If I scroll up -> show scrollToBottom button. - * 2. If I scroll to bottom of screen - * |-> hide scrollToBottom button. - * |-> if channel is unread, call markRead(). - */ + // The scroll-to-bottom button shows unless the list is at the bottom with no newer messages. setScrollToBottomButtonVisible( messageListHasMessages && (paginator?.hasMoreHead || !isScrollAtBottom), ); @@ -649,10 +587,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (thread) { return; } - /** - * When we are not in the bottom of the list, and we receive new messages, we need to mark the channel as read. - We would still need to show the unread label, where the first unread message appeared so we don't update the channelUnreadState. - */ + // `updateChannelUnreadState: false` keeps the unread label on the first unread message. await markRead({ updateChannelUnreadState: false, }); @@ -677,22 +612,20 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const onScrollToIndexFailedRef = useRef< FlatListProps['onScrollToIndexFailed'] >((info) => { - // We got a failure as we tried to scroll to an item that was outside the render length + // The target item is outside the rendered range. if (!flatListRef.current) { return; } - // we don't know the actual size of all items but we can see the average, so scroll to the closest offset - // since we used only an average offset... we won't go to the center of the item yet - // with a little delay to wait for scroll to offset to complete, we can then scroll to the index + // Only the average item size is known, so the list gets a moment to scroll near the target + // before the index is scrolled to. failScrollTimeoutId.current = setTimeout(() => { try { flatListRef.current?.scrollToIndex({ animated: true, index: info.index, - viewPosition: 0.5, // try to place message in the center of the screen + viewPosition: 0.5, // center the message }); - // The highlight is driven by the live messageFocusSignal (persists for its TTL), so there's - // no targeted-message state to re-set across scroll-fail retries. + // The highlight follows `messageFocusSignal` for its TTL, so retries need no state reset. scrollToIndexFailedRetryCountRef.current = 0; } catch (e) { if ( @@ -702,17 +635,15 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { scrollToIndexFailedRetryCountRef.current = 0; return; } - // At some cases the index we're trying to scroll to, doesn't exist yet in the messageList - // Scrolling to an index not in range of the Flatlist's data will result in a crash that - // won't call onScrollToIndexFailed. - // By catching this error we retry scrolling by calling onScrollToIndexFailedRef + // The index may not exist in the list data yet. Scrolling to an out-of-range index throws + // without calling `onScrollToIndexFailed`, so the error is caught and the scroll retried. scrollToIndexFailedRetryCountRef.current += 1; onScrollToIndexFailedRef.current(info); } }, WAIT_FOR_SCROLL_TIMEOUT); - // Only when index is greater than 0 and in range of items in FlatList - // this onScrollToIndexFailed will be called again + // FlatList calls `onScrollToIndexFailed` again only when the index is above 0 and within the + // data range. }); const dismissImagePicker = useStableCallback(() => { @@ -730,7 +661,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } }); - // We need to omit the style related props from the additionalFlatListProps and add them directly instead of spreading + // `style` and `contentContainerStyle` are merged with the SDK styles below, not spread. let additionalFlatListPropsExcludingStyle: | Omit, 'style' | 'contentContainerStyle'> | undefined; @@ -746,12 +677,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { [additionalFlatListProps?.style, styles.listContainer], ); - /** - * `strictMode` only became a `FlatList` prop in React Native 0.87, and this SDK supports `>=0.79`. - * Passed via spread rather than as a literal attribute because JSX excess-property checking does - * not apply to spreads, so this compiles on every supported version. A `@ts-expect-error` cannot - * work here: it is required below 0.87 and reported as unused from 0.87 onwards. - */ + // `strictMode` is a `FlatList` prop only from React Native 0.87, and the SDK supports `>=0.79`. + // It is passed by spread because JSX excess-property checks skip spreads, so this compiles on + // every supported version. A `@ts-expect-error` would be reported as unused from 0.87. const liveStreamingListProps = useMemo( () => ({ strictMode: isLiveStreaming }), [isLiveStreaming], @@ -775,10 +703,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const viewportHeightRef = useRef(undefined); - /** - * This debounced callback makes sure that if the current number of messages do not - * fill our screen, we load more messages continuously until we cover enough ground. - */ + // Keeps loading more while the content does not fill the viewport. const debouncedPrefillMessages = useMemo( () => debounce( @@ -871,19 +796,15 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // TODO: Consider hiding this behind a feature flag. layout={transitions.layout200} contentContainerStyle={flatListContentContainerStyle} - /** Disables the MessageList UI. Which means, message actions, reactions won't work. */ data={processedMessageListWithNeighbors} inverted={inverted} keyboardShouldPersistTaps='handled' keyExtractor={keyExtractor} ListFooterComponent={ListFooterComponent} ListHeaderComponent={ListHeaderComponent} - /** - If autoscrollToTopThreshold is 10, we scroll to recent only if before the update, the list was already at the - bottom (10 offset or below). - minIndexForVisible = 1 means that beyond the item at index 1 we will not change the position on list updates, - however it is not used when autoscrollToTopThreshold = 10. - */ + // With `autoscrollToTopThreshold` set, the list scrolls to recent only if it was within + // that offset of the bottom before the update. `minIndexForVisible` is the first item + // index whose position is kept on list updates. maintainVisibleContentPosition={maintainVisibleContentPosition} maxToRenderPerBatch={30} onContentSizeChange={onContentSizeChange} @@ -1010,17 +931,12 @@ export const MessageList = (props: MessageListProps) => { ); }; -// SOme notes about why we're relying on `createAnimatedComponent` instead of `Animated.FlatList`: -// 1. `Animated.FlatList` is much less performant for what we need. We essentially need simple -// `layout` animations to account for the list's outer container switching layout. What we're -// getting however, is an animated `CellRenderer` component as well as scroll event throttling -// reduced to 1. Since we don't really want any of this, we stick to doing it ourselves. -// 2. We need to memoize the output because of the fact that `createAnimatedComponent` changes the -// identity of the `style` prop on every render. It also seems to be an intended thing too, -// so not something that's going to change soon. This means that whenever our `MessageList` -// rerenders (but the list's props remain stable), it anyway rerenders internally as well (for -// about half of the milliseconds it takes for a full `data` rerender !). This affects performance -// significantly, especially in high ingress scenarios (i.e a livestream). +// `createAnimatedComponent` rather than `Animated.FlatList`: only simple `layout` animations are +// needed, and `Animated.FlatList` also animates every `CellRenderer` and reduces scroll event +// throttling to 1. +// Memoized because `createAnimatedComponent` changes the identity of `style` on every render, which +// would re-render the list internally even when its props are stable (costly in high-ingress cases +// such as a livestream). const AnimatedList = React.memo( Animated.createAnimatedComponent(FlatList), ); diff --git a/package/src/components/MessageList/MessageSystem.tsx b/package/src/components/MessageList/MessageSystem.tsx index 39281ee696..182030d4fb 100644 --- a/package/src/components/MessageList/MessageSystem.tsx +++ b/package/src/components/MessageList/MessageSystem.tsx @@ -10,22 +10,13 @@ import { getDateString } from '../../i18n/utils'; import { primitives } from '../../theme'; export type MessageSystemProps = { - /** Current [message object](https://getstream.io/chat/docs/#message_format) */ + /** The system message to render. */ message: LocalMessage; - /** - * Additional styles for the system message container. - */ + /** Additional styles for the system message container. */ style?: StyleProp; - /* - * Lookup key in the language corresponding translations sheet to perform date formatting - */ }; -/** - * A component to display system message. e.g, when someone updates the channel, - * they can attach a message with that update. That message will be available - * in message list as (type) system message. - */ +/** Renders a message of type `system`, such as the one attached to a channel update. */ export const MessageSystem = (props: MessageSystemProps) => { const { message, style } = props; diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index 84de5f60bd..51fa3510ee 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -19,9 +19,9 @@ export const SCROLL_TO_BOTTOM_WITH_COUNT_ACCESSIBILITY_LABEL_KEY = 'messageList.scrollToBottom.withCount.accessibilityLabel'; export type ScrollToBottomButtonProps = { - /** onPress handler */ + /** Called when the button is pressed. */ onPress: () => void; - /** If we should show the notification or not */ + /** Whether the button is rendered. Defaults to true. */ showNotification?: boolean; }; diff --git a/package/src/components/MessageList/StickyHeader.tsx b/package/src/components/MessageList/StickyHeader.tsx index 76044139da..2d1eb9921a 100644 --- a/package/src/components/MessageList/StickyHeader.tsx +++ b/package/src/components/MessageList/StickyHeader.tsx @@ -5,17 +5,10 @@ import { useTranslationContext } from '../../contexts/translationContext/Transla import { getDateString } from '../../i18n/utils'; -/** - * Props for the StickyHeader component. - */ export type StickyHeaderProps = { - /** - * Date to be displayed in the sticky header. - */ + /** Date shown in the header. Nothing renders without it. */ date?: Date; - /** - * The formatted date string to be displayed in the sticky header. - */ + /** Text shown in place of the string formatted from `date`. */ dateString?: string | number; }; diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index 70a2ee7ad9..0713a6943d 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -21,13 +21,9 @@ export type UnreadMessagesNotificationProps = { * list's throttled `markRead` instance. */ markRead?: (options?: MarkReadFunctionOptions) => void; - /** - * Callback to handle the close event - */ + /** Called when the dismiss button is pressed. Defaults to calling `markRead`. */ onCloseHandler?: () => void; - /** - * Callback to handle the press event - */ + /** Called when the notification is pressed. Defaults to jumping to the first unread message. */ onPressHandler?: () => Promise; }; diff --git a/package/src/components/MessageList/hooks/useMarkRead.ts b/package/src/components/MessageList/hooks/useMarkRead.ts index a73cb358df..ab4fb1dd5b 100644 --- a/package/src/components/MessageList/hooks/useMarkRead.ts +++ b/package/src/components/MessageList/hooks/useMarkRead.ts @@ -7,27 +7,23 @@ import { useStableCallback } from '../../../hooks'; import { MarkReadFunctionOptions } from '../../Channel/Channel'; /** - * Returns a `markRead` callback for the active channel. + * Returns a stable `markRead` callback for the channel. * - * Marks the channel read through the client's `messageDeliveryReporter.throttledMarkRead` — the - * canonical v10 read-reporting path, which the reporter throttles + coordinates — rather than calling - * `channel.markRead()` behind our own throttle. It is a no-op when the channel is missing or - * pending disposal; when read events are disabled it resets the local unread count via `markReadLocally()` - * (dispatches `message.read_locally`) if the client opted into a local count; otherwise it reports the - * read and resets the paginator's unread snapshot (unless `updateChannelUnreadState` is `false`) so the - * "N new messages" banner + unread separator clear once caught up. The returned function is stabilized - * so it can be used as a dependency without triggering rerenders. + * The callback reports the read through `client.messageDeliveryReporter.throttledMarkRead`, then + * resets the paginator's unread snapshot unless `updateChannelUnreadState` is `false`. It does + * nothing when the channel is missing or pending disposal. When read events are disabled it only + * calls `channel.markReadLocally()`, and only if `localUnreadCountEnabled` is set. + * + * @param channel The channel to mark as read. + * @returns The `markRead` callback. `options.updateChannelUnreadState` defaults to true. */ export const useMarkRead = (channel: Channel) => { const { client } = useChatContext(); - // The channel's *resolved* configuration, not the raw server flag: `readEvents.enabled` is the - // channel type's `read_events` already ANDed with anything registered through - // `client.config.set({ channel: { readEvents } })`, so a client-side opt-out is honoured too. - // - // Read at call time rather than through `useStateStore`: `markRead` is an imperative callback, so it - // needs the current value, not a re-render when the value moves. Still wrapped, because a channel - // pending disposal makes the underlying client throw. + // The resolved config, not the raw server flag: `readEvents.enabled` is the channel type's + // `read_events` ANDed with `client.config.set({ channel: { readEvents } })`. Read at call time + // because `markRead` is imperative; the try/catch covers a channel pending disposal, which makes + // the client throw. const getReadEventsEnabledSafely = () => { try { return channel?.config.readEvents.enabled; @@ -41,9 +37,8 @@ export const useMarkRead = (channel: Channel) => { return; } - // Read events disabled (e.g. livestreams): if the channel opted into a local unread count, reset - // it locally (dispatches message.read_locally) — no backend round trip. The paginator's unread - // snapshot updates from that. + // Read events disabled (such as livestreams): reset a local unread count, if enabled, without a + // backend call. This dispatches `message.read_locally`, which updates the paginator's snapshot. if (!getReadEventsEnabledSafely()) { if (channel.config.readEvents.localUnreadCountEnabled) { channel.markReadLocally(); @@ -51,20 +46,17 @@ export const useMarkRead = (channel: Channel) => { return; } - // Canonical v10 read reporting: the reporter throttles + coordinates the `/read` calls and honors - // any custom markReadRequest handler registered through - // `client.config.set({ channel: { requestHandlers } })`. + // The reporter throttles and coordinates `/read` calls and honors a custom `markReadRequest` + // handler registered through `client.config.set({ channel: { requestHandlers } })`. client.messageDeliveryReporter.throttledMarkRead(channel); // Reset the paginator's unread snapshot so the "N new messages" banner and the unread separator - // clear once the channel is caught up. The LLC bumps `unreadCount` on every incoming - // `message.new` but never clears it on `message.read`, so without this the banner latches on and - // can't be dismissed. `throttledMarkRead` is fire-and-forget (no response to read), so advance - // the boundary to the latest message. + // clear once caught up. The client bumps `unreadCount` on every `message.new` but never clears + // it on `message.read`, and `throttledMarkRead` returns no response, so the boundary advances + // to the latest message. // - // Gated on `updateChannelUnreadState` (default true): the mark-read-on-mount call passes `false` - // so opening a channel with unreads keeps its original unread UI (separator frozen at the - // boundary) until the user actually catches up. + // The mark-read-on-mount call passes `updateChannelUnreadState: false`, so a channel opened + // with unreads keeps its unread separator until the user catches up. const { updateChannelUnreadState = true } = options ?? {}; if (updateChannelUnreadState) { const { lastMessage, lastMessageAt, unreadStateSnapshot } = channel.messagePaginator; diff --git a/package/src/components/MessageList/hooks/useMessageDateSeparator.ts b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts index 9682efa79e..4c0ecf69d6 100644 --- a/package/src/components/MessageList/hooks/useMessageDateSeparator.ts +++ b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts @@ -16,8 +16,7 @@ export const getDateSeparatorValue = ({ return undefined; } - // Nullish rather than truthy: `0` is a legitimate wire timestamp (the epoch), and treating it as - // "no date" would collapse the grouping key and suppress the separator. + // Nullish check, as `0` is a valid timestamp (the epoch) and must not suppress the separator. const previousMessageDate = previousMessage?.created_at != null ? nsToDate(previousMessage.created_at).toDateString() @@ -26,8 +25,7 @@ export const getDateSeparatorValue = ({ message?.created_at != null ? nsToDate(message.created_at).toDateString() : undefined; if (previousMessageDate !== messageDate) { - // A `Date`, because the separator components that render this are presentational and keep their - // `date?: Date` props. Converted once, here, where core data leaves the message. + // A `Date`, as the presentational separator components take `date?: Date`. return message?.created_at != null ? nsToDate(message.created_at) : undefined; } @@ -35,7 +33,12 @@ export const getDateSeparatorValue = ({ }; /** - * Hook to get whether a message should have a date separator above it + * Returns the date for a separator above the message, or `undefined` when none is needed. + * + * @param params.hideDateSeparators Suppresses every separator. + * @param params.message The message the separator sits above. + * @param params.previousMessage The message rendered before it. + * @returns The message's creation date when it falls on a different day than the previous message. */ export const useMessageDateSeparator = ({ hideDateSeparators, diff --git a/package/src/components/MessageList/hooks/useMessageGroupStyles.ts b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts index 43378ff6420..d464a8829b 100644 --- a/package/src/components/MessageList/hooks/useMessageGroupStyles.ts +++ b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts @@ -8,7 +8,16 @@ import { MessagesContextValue } from '../../../contexts/messagesContext/Messages import { getGroupStyle } from '../utils/getGroupStyles'; /** - * Hook to get the group styles for a message + * Returns the group styles of a message, computed by `getMessageGroupStyle`. + * + * @param params.noGroupByUser Skips grouping, which yields an empty array. + * @param params.dateSeparatorDate Date of the separator above the message, if any. + * @param params.maxTimeBetweenGroupedMessages Longest gap, in milliseconds, within a group. + * @param params.message The message to style. + * @param params.previousMessage The message rendered before it. + * @param params.nextMessage The message rendered after it. + * @param params.getMessageGroupStyle Computes the group styles. Defaults to `getGroupStyle`. + * @returns The group styles. */ export const useMessageGroupStyles = ({ noGroupByUser, @@ -27,7 +36,7 @@ export const useMessageGroupStyles = ({ previousMessage?: LocalMessage; nextMessage?: LocalMessage; }) => { - // This is needed to calculate the group styles for the next message + // The next message's separator feeds into this message's group style. const nextMessageDateSeparatorDate = useMessageDateSeparator({ message: nextMessage, previousMessage: message, diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 2d895ea285..a6c45079c4 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -40,10 +40,7 @@ export const useMessageList = (params: UseMessageListParams) => { return useMemo( () => ({ - /** - * The paginator's configured window cap, or `undefined` when the list is unbounded. Read from - * the paginator rather than a prop — it is state-layer configuration. - */ + /** The paginator's configured window cap, or `undefined` when the list is unbounded. */ maxLoadedItems, /** Render order: newest first for the inverted FlatList, oldest first for FlashList. */ processedMessageList: data, diff --git a/package/src/components/MessageList/hooks/useMessageListFocus.ts b/package/src/components/MessageList/hooks/useMessageListFocus.ts index 54bc0dec89..752a62d7b6 100644 --- a/package/src/components/MessageList/hooks/useMessageListFocus.ts +++ b/package/src/components/MessageList/hooks/useMessageListFocus.ts @@ -15,13 +15,15 @@ const messageFocusSelector = (state: { }); /** - * The message `paginator` wants the list to scroll to and highlight, and `goToMessage` to request - * one. A jump (`jumpToMessage`, `jumpToTheFirstUnreadMessage`, …) emits the paginator's - * `messageFocusSignal`; the list scrolls when it sees a new `focusToken`, which changes on every jump - * even to the same message. + * Tracks the message the list should scroll to and highlight, from the paginator's + * `messageFocusSignal`. A jump (`jumpToMessage`, `jumpToTheFirstUnreadMessage`, ...) emits the + * signal, and `focusToken` changes on every jump, even to the same message. * - * The signal lives on the paginator, which outlives the list, so it is cleared on unmount. Otherwise a - * highlight still running when the user navigates away would scroll and highlight again on return. + * The paginator outlives the list, so the signal is cleared on unmount. Otherwise a highlight + * still running when the user navigates away would scroll and highlight again on return. + * + * @param paginator The channel's or the open thread's paginator. + * @returns `focusedMessageId`, `focusToken` and `goToMessage`, which jumps to a message by ID. */ export const useMessageListFocus = (paginator?: MessagePaginator) => { const { focusedMessageId, focusToken } = diff --git a/package/src/components/MessageList/hooks/useMessageListLiveState.ts b/package/src/components/MessageList/hooks/useMessageListLiveState.ts index cf475e39fa..7ea915f06e 100644 --- a/package/src/components/MessageList/hooks/useMessageListLiveState.ts +++ b/package/src/components/MessageList/hooks/useMessageListLiveState.ts @@ -32,7 +32,12 @@ type UseMessageListLiveStateParams = { * A channel list also marks the channel read when a message arrives in that state. A thread list * leaves the channel's flags alone: the channel list underneath it keeps reporting its own. * - * Returns the callback the list calls with the messages currently on screen. + * @param params.channel The channel the list belongs to. + * @param params.markRead Marks the channel read. + * @param params.paginator The list's paginator. + * @param params.thread The thread this list renders, if any. + * @param params.userId The current user's ID. + * @returns A callback the list calls with the messages currently on screen. */ export const useMessageListLiveState = ({ channel, diff --git a/package/src/components/MessageList/hooks/useMessageListPagination.ts b/package/src/components/MessageList/hooks/useMessageListPagination.ts index 710496f210..4a58d504d1 100644 --- a/package/src/components/MessageList/hooks/useMessageListPagination.ts +++ b/package/src/components/MessageList/hooks/useMessageListPagination.ts @@ -12,10 +12,13 @@ const loadingSelector = (state: { isLoading: boolean; items?: unknown[] }) => ({ /** * Paging for a message list over `paginator`, the channel's or the open thread's. * - * `loadMore` fetches older messages and `loadMoreRecent` newer ones. The paginator ignores a request - * while it is already loading or when that end has nothing more, so the list can call these on every - * scroll event that reaches an edge. A request first waits for one in flight in the other direction, - * so the two never shift the scroll position under each other. + * `loadMore` fetches older messages and `loadMoreRecent` newer ones. A request is ignored while the + * paginator is loading or has nothing more at that end, so the list can call these on every scroll + * event that reaches an edge. A request first waits for one in flight in the other direction, so + * the two never shift the scroll position under each other. + * + * @param paginator The channel's or the open thread's paginator. + * @returns The loading flags and the `loadMore` and `loadMoreRecent` callbacks. */ export const useMessageListPagination = (paginator?: MessagePaginator) => { const { hasMessages, isLoading } = useStateStore(paginator?.state, loadingSelector) ?? {}; diff --git a/package/src/components/MessageList/hooks/useOwnUnreadCount.ts b/package/src/components/MessageList/hooks/useOwnUnreadCount.ts index 81913d8ae0..faa2f39ae5 100644 --- a/package/src/components/MessageList/hooks/useOwnUnreadCount.ts +++ b/package/src/components/MessageList/hooks/useOwnUnreadCount.ts @@ -8,6 +8,9 @@ import { useStateStore } from '../../../hooks/useStateStore'; /** * The current user's unread message count in `channel`, kept current as reads arrive. `undefined` * when no channel is given, which also leaves the channel's state unsubscribed. + * + * @param channel The channel to count unread messages in. + * @returns The unread count, or `undefined` without a channel. */ export const useOwnUnreadCount = (channel: Channel | undefined) => { const { client } = useChatContext(); diff --git a/package/src/components/MessageList/hooks/useScrollToBottomAccessibilityAction.ts b/package/src/components/MessageList/hooks/useScrollToBottomAccessibilityAction.ts index 5a25fea9d8..0a055ed2d1 100644 --- a/package/src/components/MessageList/hooks/useScrollToBottomAccessibilityAction.ts +++ b/package/src/components/MessageList/hooks/useScrollToBottomAccessibilityAction.ts @@ -17,10 +17,9 @@ type AccessibilityActions = ViewProps['accessibilityActions']; type OnAccessibilityAction = ViewProps['onAccessibilityAction']; /** - * Declared explicitly rather than inferred. Left to inference, TypeScript expands - * `AccessibilityActionEvent` into its structural shape when emitting the declaration, and that - * shape names `HostInstance`. Naming the props here keeps the emitted `.d.ts` referring to - * `ViewProps[...]`, which resolves against whichever React Native the consumer actually has. + * Spelled out so the emitted `.d.ts` refers to `ViewProps[...]`, which resolves against the + * consumer's React Native. An inferred type expands `AccessibilityActionEvent` into a shape that + * names `HostInstance`. */ export type UseScrollToBottomAccessibilityActionResult = { accessibilityActions: AccessibilityActions; diff --git a/package/src/components/MessageList/hooks/useStickyHeaderDate.ts b/package/src/components/MessageList/hooks/useStickyHeaderDate.ts index 204c0df967..aef7890789 100644 --- a/package/src/components/MessageList/hooks/useStickyHeaderDate.ts +++ b/package/src/components/MessageList/hooks/useStickyHeaderDate.ts @@ -6,8 +6,10 @@ import { convertTimestampToDate } from 'stream-chat'; import { useStableCallback } from '../../../hooks'; /** - * The date the list's sticky header shows: the day of the topmost message on screen. Hidden once the - * oldest message of the whole list is on screen, where the inline date separator says the same. + * The date the list's sticky header shows: the day of the topmost message on screen. Hidden once + * the oldest message of the whole list is on screen, where the inline date separator says the same. + * + * @returns `stickyHeaderDate` and `updateStickyHeaderDate`, which takes the topmost message. */ export const useStickyHeaderDate = () => { const [stickyHeaderDate, setStickyHeaderDate] = useState(); diff --git a/package/src/components/MessageList/hooks/useTypingString.ts b/package/src/components/MessageList/hooks/useTypingString.ts index 5a2f079af6..c303bd4bb1 100644 --- a/package/src/components/MessageList/hooks/useTypingString.ts +++ b/package/src/components/MessageList/hooks/useTypingString.ts @@ -21,10 +21,7 @@ export const useTypingString = () => { } if (typingUsernames.length > 1) { - /** - * Joins the multiple names with number after first name - * example: "Dan and Neil" - */ + // The first name followed by the number of other typing users. return t( 'messageList.typing.withMoreUsers.label', '{{ firstUser }} and {{ nonSelfUserLength }} more are typing', diff --git a/package/src/components/MessageList/hooks/useTypingUsers.ts b/package/src/components/MessageList/hooks/useTypingUsers.ts index 6a74d547c4..3dc8e2e45f 100644 --- a/package/src/components/MessageList/hooks/useTypingUsers.ts +++ b/package/src/components/MessageList/hooks/useTypingUsers.ts @@ -9,11 +9,15 @@ import { filterTypingUsers } from '../utils/filterTypingUsers'; const selector = (state: TypingUsersState) => ({ typing: state.typing }); /** - * Shared typing-users core. Takes `channel` (and an optional `threadId`) EXPLICITLY so it works both - * inside a `` provider (the message list) and outside one (channel-list preview rows, which - * receive `channel` as a prop) — it deliberately never reads the channel/thread from context itself. - * `client` comes from the always-present root `ChatContext`. Sourced reactively from the - * `channel.state.typing` slice; own typing and other-thread typing are filtered out. + * The users typing in `channel`, without the current user, from `channel.state.typing`. + * With `threadId` only that thread's typing counts; without it, only the channel's own. + * + * The channel is a parameter rather than read from context, so this works outside a `` + * provider, such as in channel preview rows. + * + * @param channel The channel to watch. + * @param threadId The thread to watch instead of the channel's main list. + * @returns The typing users. */ export const useChannelTypingUsers = (channel: Channel, threadId?: string) => { const { client } = useChatContext(); @@ -23,8 +27,9 @@ export const useChannelTypingUsers = (channel: Channel, threadId?: string) => { }; /** - * In-channel typing users — reads the channel/thread from context (message-list usage). Thin wrapper - * over {@link useChannelTypingUsers}. + * {@link useChannelTypingUsers} for the channel, and open thread if any, from context. + * + * @returns The typing users. */ export const useTypingUsers = () => { const { channel } = useChannelContext(); diff --git a/package/src/components/MessageList/hooks/useUnreadNotificationVisibility.ts b/package/src/components/MessageList/hooks/useUnreadNotificationVisibility.ts index 7d3d4b9e1c..8b09e6ba90 100644 --- a/package/src/components/MessageList/hooks/useUnreadNotificationVisibility.ts +++ b/package/src/components/MessageList/hooks/useUnreadNotificationVisibility.ts @@ -15,8 +15,16 @@ type UseUnreadNotificationVisibilityParams = { }; /** - * Whether the "N new messages" notification is shown at the top of a channel list: the user has - * scrolled up past the last message they read, and that message is no longer on screen. + * Tracks whether the unread notification is shown at the top of a channel's message list: the user + * has scrolled up past the last message they read, and that message is no longer on screen. + * + * @param params.attachmentPickerStore Hides the notification while the images picker is open. + * @param params.channel The channel the list belongs to. + * @param params.markRead Marks the channel read when the notification is closed. + * @param params.readEvents Whether the channel has read events enabled. + * @param params.userId The current user's ID. + * @returns `isUnreadNotificationOpen`, `onUnreadNotificationClose` (marks the channel read and + * hides the notification) and `updateUnreadNotification` (call it with the messages on screen). */ export const useUnreadNotificationVisibility = ({ attachmentPickerStore, diff --git a/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts b/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts index c624cf103b..715742bbe4 100644 --- a/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts +++ b/package/src/components/MessageList/utils/buildMessageListWithNeighbours.ts @@ -7,16 +7,20 @@ export type MessageListItemWithNeighbours = { }; /** - * The stable identity of a message row, used both as the FlatList/FlashList render key and as the - * neighbour-cache key. Those two must agree: if they diverge for the same message the cache stores - * a row under one key while the list renders it under another, and memoisation silently never hits. + * The stable identity of a message row, used as both the list render key and the neighbour-cache + * key. The two must agree: otherwise the cache stores a row under one key while the list renders it + * under another, and memoization never hits. + * + * @param item The message of the row. + * @param index The position of the row, used when the message has neither an ID nor a timestamp. + * @returns The row key. */ export const getMessageListItemCacheKey = (item: LocalMessage, index: number) => { if (item.id) { return item.id; } - // Nullish, not truthy: `created_at` is unix nanoseconds and `0` is a legitimate value (the - // epoch). Treating it as absent falls through to the index, which shifts as pages load. + // Nullish check: `created_at` is in unix nanoseconds and `0` is valid. The index fallback shifts + // as pages load. if (item.created_at != null) { return String(item.created_at); } diff --git a/package/src/components/MessageList/utils/filterTypingUsers.ts b/package/src/components/MessageList/utils/filterTypingUsers.ts index d319a22376..d893530df5 100644 --- a/package/src/components/MessageList/utils/filterTypingUsers.ts +++ b/package/src/components/MessageList/utils/filterTypingUsers.ts @@ -22,7 +22,7 @@ export const filterTypingUsers = ({ client, threadId, typing }: FilterTypingUser return; } - /** removes own typing events */ + // Skip own typing events. if (client.user?.id === typingEvent.user?.id) { return; } @@ -30,7 +30,7 @@ export const filterTypingUsers = ({ client, threadId, typing }: FilterTypingUser const isRegularEvent = !typingEvent.parent_id && !threadId; const isCurrentThreadEvent = typingEvent.parent_id === threadId; - /** filters different threads events */ + // Skip events of other threads. if (!isRegularEvent && !isCurrentThreadEvent) { return; } diff --git a/package/src/components/MessageList/utils/getGroupStyles.ts b/package/src/components/MessageList/utils/getGroupStyles.ts index c5134a379c..b6e4755202 100644 --- a/package/src/components/MessageList/utils/getGroupStyles.ts +++ b/package/src/components/MessageList/utils/getGroupStyles.ts @@ -16,7 +16,15 @@ export type MessageGroupStylesParams = { export type GroupStyle = '' | 'middle' | 'top' | 'bottom' | 'single'; /** - * Get the group styles for a message + * Returns the group style of a message: `top`, `bottom`, `middle` or `single`. + * + * @param params.message The message to style. + * @param params.previousMessage The message rendered before it. + * @param params.nextMessage The message rendered after it. + * @param params.maxTimeBetweenGroupedMessages Longest gap, in milliseconds, within a group. + * @param params.nextMessageDateSeparatorDate Date of the separator above the next message, if any. + * @param params.dateSeparatorDate Date of the separator above the message, if any. + * @returns An array holding the message's group style. */ export const getGroupStyle = ({ message, @@ -35,7 +43,7 @@ export const getGroupStyle = ({ previousMessage.type === 'system' || previousMessage.type === 'error' || userId !== previousMessage?.user?.id || - // NOTE: This is needed for the group styles to work after the message separated by date. + // A date separator above the message starts a new group. dateSeparatorDate || isEditedMessage(previousMessage); @@ -49,22 +57,12 @@ export const getGroupStyle = ({ nsToMs(nextMessage.created_at - message.created_at) > maxTimeBetweenGroupedMessages) || isEditedMessage(message); - /** - * Add group styles key for top message - */ if (isTopMessage) { groupStyles.push('top'); } - /** - * Add group styles key for bottom message - */ - if (isBottomMessage) { - /** - * If the bottom message is also the top, or deleted, or an error, - * add the key for single message instead of bottom - */ + // A message that is also the top, or an error, is `single` instead of `bottom`. if (isTopMessage || message.type === 'error') { groupStyles.splice(0, groupStyles.length); groupStyles.push('single'); @@ -73,10 +71,7 @@ export const getGroupStyle = ({ } } - /** - * Add the key for all non top or bottom messages, if the message is - * deleted or an error add the key for single otherwise middle - */ + // Neither top nor bottom: `middle`, or `single` for an error. if (!isTopMessage && !isBottomMessage) { if (message.type === 'error') { groupStyles.splice(0, groupStyles.length); diff --git a/package/src/components/MessageMenu/EmojiPickerList.tsx b/package/src/components/MessageMenu/EmojiPickerList.tsx index 6468a4b730..7857388737 100644 --- a/package/src/components/MessageMenu/EmojiPickerList.tsx +++ b/package/src/components/MessageMenu/EmojiPickerList.tsx @@ -51,7 +51,7 @@ export const EmojiPickerList = ({ renderFullInitially = true, }: { onSelectReaction: (unicode: string) => void; - // whether all of the items should be rendered initially or not + // Whether every emoji renders on the first pass. Defaults to true. renderFullInitially?: boolean; }) => { const styles = useStyles(); @@ -76,11 +76,8 @@ export const EmojiPickerList = ({ keyExtractor={emojiKeyExtractor} numColumns={7} removeClippedSubviews={false} - // This is sort of needed in some instances, because when virtualization - // kicks in it messes with the animations, as more native views get their - // bindings to JS. For the reactions specifically it does not really - // matter as they aren't too heavy - but we should anyway revisit - // this in the future. + // Needed in some cases: virtualization interferes with the animations, as more native views + // get bound to JS. Reactions are light, so rendering them all up front is cheap. initialNumToRender={renderFullInitially ? emojis.length : undefined} renderItem={renderEmoji} /> diff --git a/package/src/components/MessageMenu/MessageActionList.tsx b/package/src/components/MessageMenu/MessageActionList.tsx index 577445a343..5c0387f933 100644 --- a/package/src/components/MessageMenu/MessageActionList.tsx +++ b/package/src/components/MessageMenu/MessageActionList.tsx @@ -11,14 +11,9 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; export type MessageActionListProps = { - /** - * Function to close the message actions bottom sheet - * @returns void - */ + /** Closes the message actions overlay. */ dismissOverlay?: () => void; - /** - * An array of message actions to render - */ + /** The actions to render. */ messageActions?: MessageActionType[]; }; diff --git a/package/src/components/MessageMenu/MessageActionListItem.tsx b/package/src/components/MessageMenu/MessageActionListItem.tsx index 394eaf63d4..9c144f18f2 100644 --- a/package/src/components/MessageMenu/MessageActionListItem.tsx +++ b/package/src/components/MessageMenu/MessageActionListItem.tsx @@ -28,34 +28,21 @@ export type ActionType = | 'unpinMessage'; export type MessageActionType = { - /** - * Callback when user presses the action button. - */ + /** Called when the action button is pressed. */ action: () => void; - /** - * Type of the action performed. - * Eg: 'banUser', 'blockUser', 'copyMessage', 'deleteMessage', 'editMessage', 'flagMessage', 'markUnread , 'muteUser', 'pinMessage', 'selectReaction', 'reply', 'retry', 'quotedReply', 'threadReply', 'unpinMessage' - */ + /** The type of the action: an `ActionType`, or a custom string. */ actionType: ActionType | string; - /** - * Title for action button. - */ + /** Title of the action button. */ title: string; - /** - * Element to render as icon for action button. - */ + /** Icon of the action button. */ icon?: React.ReactElement; - /** - * Styles for underlying Text component of action title. - */ + /** Styles for the `Text` of the action title. */ titleStyle?: StyleProp; type: 'standard' | 'destructive'; }; -/** - * MessageActionListItem - A high-level component that implements all the logic required for a `MessageAction` in a `MessageActionList` - */ +/** Props of the item that renders a `MessageActionType` in a `MessageActionList`. */ export type MessageActionListItemProps = MessageActionType; export const MessageActionListItem = (props: MessageActionListItemProps) => { diff --git a/package/src/components/MessageMenu/MessageActionListWithActions.tsx b/package/src/components/MessageMenu/MessageActionListWithActions.tsx index 3fb19a7b9c..27e3c8d04b 100644 --- a/package/src/components/MessageMenu/MessageActionListWithActions.tsx +++ b/package/src/components/MessageMenu/MessageActionListWithActions.tsx @@ -11,8 +11,8 @@ import { useMessageOperations } from '../Message/hooks/useMessageOperations'; import { messageActions as defaultMessageActions } from '../Message/utils/messageActions'; /** - * The action menu's list. Lives inside the open overlay, so the actions are only built while a - * menu is open rather than on every render of every message. + * Renders the action list of the open message menu. The actions are built only while a menu is + * open, not on every message render. */ export const MessageActionListWithActions = () => { const { MessageActionList } = useComponentsContext(); diff --git a/package/src/components/MessageMenu/MessageReactionPicker.tsx b/package/src/components/MessageMenu/MessageReactionPicker.tsx index 1c5c88da81..d8f3f99898 100644 --- a/package/src/components/MessageMenu/MessageReactionPicker.tsx +++ b/package/src/components/MessageMenu/MessageReactionPicker.tsx @@ -135,9 +135,7 @@ export const EmojiViewerButton = ({ ); }; -/** - * MessageReactionPicker - A high level component which implements all the logic required for a message overlay reaction list - */ +/** Renders the reaction picker of the message overlay. */ export const MessageReactionPicker = (props: MessageReactionPickerProps) => { const { dismissOverlay, handleReaction } = props; const { diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index d1751395b6..67e12cc57c 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -43,13 +43,9 @@ const getItemLayout = (_, index: number) => ({ export type MessageUserReactionsProps = Partial> & Partial> & { - /** - * An array of reactions - */ + /** Reactions to list, used instead of the fetched ones. */ reactions?: Reaction[]; - /** - * The selected reaction - */ + /** The reaction type selected initially. */ selectedReaction?: string; }; diff --git a/package/src/components/MessageMenu/MessageUserReactionsAvatar.tsx b/package/src/components/MessageMenu/MessageUserReactionsAvatar.tsx index 6bf43a4031..3c9ecbffc2 100644 --- a/package/src/components/MessageMenu/MessageUserReactionsAvatar.tsx +++ b/package/src/components/MessageMenu/MessageUserReactionsAvatar.tsx @@ -7,9 +7,7 @@ import { getInitialsFromName } from '../../utils/utils'; import { Avatar, AvatarProps } from '../ui'; export type MessageUserReactionsAvatarProps = { - /** - * The reaction object - */ + /** The reaction whose user's avatar is shown. */ reaction: Reaction; } & Partial>; diff --git a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx index 3c0f8799d7..58a18a501c 100644 --- a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx +++ b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx @@ -14,13 +14,9 @@ import type { Reaction } from '../../types/types'; import { ReactionData } from '../../utils/utils'; export type MessageUserReactionsItemProps = { - /** - * The reaction object - */ + /** The reaction to show. */ reaction: Reaction; - /** - * An array of supported reactions - */ + /** Supported reactions, used to look up the icon of the reaction type. */ supportedReactions: ReactionData[]; }; diff --git a/package/src/components/MessageMenu/ReactionButton.tsx b/package/src/components/MessageMenu/ReactionButton.tsx index 5541ce77f8..628383d02a 100644 --- a/package/src/components/MessageMenu/ReactionButton.tsx +++ b/package/src/components/MessageMenu/ReactionButton.tsx @@ -6,22 +6,16 @@ import { IconProps } from '../../icons'; import { Button, ButtonProps } from '../ui'; export type ReactionButtonProps = { - /** - * Icon to display for the reaction button - */ + /** Icon of the reaction. */ Icon: React.ComponentType; - /** - * Whether the reaction button is selected - */ + /** Whether the button is selected. */ selected: boolean; - /** - * The type of reaction - */ + /** The reaction type, passed to `onPress`. */ type: string; /** - * Function to call when the reaction button is pressed - * @param reactionType - * @returns + * Called when the button is pressed. + * + * @param reactionType The `type` of the pressed button. */ onPress?: (reactionType: string) => void; count?: string; diff --git a/package/src/components/Notifications/Notification.tsx b/package/src/components/Notifications/Notification.tsx index dc45f55f20..205be24990 100644 --- a/package/src/components/Notifications/Notification.tsx +++ b/package/src/components/Notifications/Notification.tsx @@ -24,10 +24,15 @@ export type NotificationIconProps = { notification: NotificationType; }; -/** Visual variant resolved from notification severity. Notifications without severity use `default`. */ +/** Visual variant resolved from severity. Notifications without severity use `default`. */ export type NotificationVariant = 'default' | NotificationSeverity; -/** Resolves the visual variant used by the default notification renderer. */ +/** + * Resolves the visual variant used by the default notification renderer. + * + * @param notification The notification to render. + * @returns The variant of its severity, or `default` without one. + */ export const getNotificationVariant = (notification: NotificationType): NotificationVariant => notification.severity ?? 'default'; @@ -71,7 +76,7 @@ export type NotificationProps = { Icon?: React.ComponentType; /** Called instead of the default notification removal behavior. */ onDismiss?: () => void; - /** Forces a dismiss button for transient notifications. Persistent notifications always show one. */ + /** Forces a dismiss button on transient notifications. Persistent ones always show one. */ showClose?: boolean; /** Reserved for custom animated notification implementations. */ transitionState?: NotificationTransitionState; diff --git a/package/src/components/Notifications/NotificationList.tsx b/package/src/components/Notifications/NotificationList.tsx index f66b1dae62..4d4256b743 100644 --- a/package/src/components/Notifications/NotificationList.tsx +++ b/package/src/components/Notifications/NotificationList.tsx @@ -28,7 +28,7 @@ export type NotificationListProps = { enterFrom?: NotificationListEnterFrom; /** Additional per-list filter applied after target filtering. */ filter?: NotificationListFilter; - /** Exact notification host id. Use together with `panel` when rendering outside a target provider. */ + /** Exact notification host ID. Use together with `panel` outside a target provider. */ hostId?: string; /** Target panel consumed by this list. Defaults to the nearest `NotificationTargetProvider`. */ panel?: NotificationTargetPanel; diff --git a/package/src/components/Notifications/NotificationTargetContext.tsx b/package/src/components/Notifications/NotificationTargetContext.tsx index 02888b9040..71ea105538 100644 --- a/package/src/components/Notifications/NotificationTargetContext.tsx +++ b/package/src/components/Notifications/NotificationTargetContext.tsx @@ -22,10 +22,20 @@ export const NotificationTargetProvider = ({ ); }; -/** Returns the nearest notification target, if the caller is rendered inside a target provider. */ +/** + * Returns the nearest notification target, if the caller is rendered inside a target provider. + * + * @returns The target, or `undefined` outside a provider. + */ export const useNotificationTargetContext = () => useContext(NotificationTargetContext); -/** Resolves an explicit target or falls back to the nearest compatible target provider. */ +/** + * Resolves an explicit target or falls back to the nearest compatible target provider. + * + * @param params.hostId Exact host ID. Resolves only together with `panel`. + * @param params.panel Target panel. Without `hostId`, resolves only to the context target of it. + * @returns The resolved target, or `undefined` when none matches. + */ export const useResolvedNotificationTarget = ({ hostId, panel, diff --git a/package/src/components/Notifications/hooks/useNotificationApi.ts b/package/src/components/Notifications/hooks/useNotificationApi.ts index d64c5aa755..9201e33e1e 100644 --- a/package/src/components/Notifications/hooks/useNotificationApi.ts +++ b/package/src/components/Notifications/hooks/useNotificationApi.ts @@ -16,7 +16,12 @@ import { useNotificationTargetContext } from '../NotificationTargetContext'; export const SYSTEM_NOTIFICATION_TAG = 'system' as const; -/** Returns whether a notification is reserved for system-level consumers instead of snackbars. */ +/** + * Returns whether a notification is reserved for system-level consumers instead of snackbars. + * + * @param notification The notification to test. + * @returns True when it carries the system tag. + */ export const hasSystemNotificationTag = (notification: Notification) => notification.tags?.includes(SYSTEM_NOTIFICATION_TAG) ?? false; @@ -35,7 +40,7 @@ export type NotificationIncidentDescriptor = { export type AddNotificationOptions = { /** Incident metadata used to derive `options.type` when the payload does not provide one. */ incident?: NotificationIncidentDescriptor; - /** Exact host target for this notification. Defaults to the nearest notification target provider. */ + /** Exact host target for this notification. Defaults to the nearest target provider. */ target?: NotificationTarget; /** Broad panel targets. Use for notifications that should appear in every host of a panel. */ targetPanels?: NotificationTargetPanel[]; @@ -53,7 +58,7 @@ export type AddSystemNotification = ( payload: AddNotificationPayload, options?: AddSystemNotificationOptions, ) => string; -/** Removes a notification by id. */ +/** Removes a notification by ID. */ export type RemoveNotification = (id: string) => void; /** Removes non-system notifications that belong to the current target provider. */ export type RemoveNotificationsForCurrentPanel = () => void; @@ -129,7 +134,11 @@ const mergeNotificationOptions = ( }; }; -/** Returns imperative helpers for dispatching, targeting and dismissing client notifications. */ +/** + * Returns imperative helpers for dispatching, targeting and dismissing client notifications. + * + * @returns The notification helpers. + */ export const useNotificationApi = (): NotificationApi => { const { client } = useChatContext(); const contextTarget = useNotificationTargetContext(); diff --git a/package/src/components/Notifications/hooks/useNotifications.ts b/package/src/components/Notifications/hooks/useNotifications.ts index fced8bb6c2..cee7f83d16 100644 --- a/package/src/components/Notifications/hooks/useNotifications.ts +++ b/package/src/components/Notifications/hooks/useNotifications.ts @@ -17,7 +17,14 @@ export type UseNotificationsOptions = { target?: NotificationTarget; }; -/** Subscribes to the client notification store and returns matching non-system notifications. */ +/** + * Subscribes to the client notification store and returns matching non-system notifications. + * + * @param options.filter Additional filter applied after optional target filtering. + * @param options.requireTarget When true, returns an empty list unless `target` is provided. + * @param options.target Exact target used to select notifications for one host. + * @returns The matching notifications. + */ export const useNotifications = (options?: UseNotificationsOptions): Notification[] => { const { client } = useChatContext(); const selector = useCallback( diff --git a/package/src/components/Notifications/hooks/useSystemNotifications.ts b/package/src/components/Notifications/hooks/useSystemNotifications.ts index ceda80e59a..9224cf70df 100644 --- a/package/src/components/Notifications/hooks/useSystemNotifications.ts +++ b/package/src/components/Notifications/hooks/useSystemNotifications.ts @@ -14,7 +14,12 @@ export type UseSystemNotificationsOptions = { filter?: UseSystemNotificationsFilter; }; -/** Subscribes to notifications tagged as system notifications. */ +/** + * Subscribes to notifications tagged as system notifications. + * + * @param options.filter Additional filter applied to system-tagged notifications. + * @returns The matching notifications. + */ export const useSystemNotifications = (options?: UseSystemNotificationsOptions): Notification[] => { const { client } = useChatContext(); const selector = useCallback( diff --git a/package/src/components/Notifications/notificationFilters.ts b/package/src/components/Notifications/notificationFilters.ts index 2d82e3c23b..7015b4b9e3 100644 --- a/package/src/components/Notifications/notificationFilters.ts +++ b/package/src/components/Notifications/notificationFilters.ts @@ -4,23 +4,21 @@ import type { NotificationListFilter } from './NotificationList'; const ATTACHMENT_UPLOAD_FAILED_TYPE = 'api:attachment:upload:failed'; -/** - * A cancelled upload surfaces as an `api:attachment:upload:failed` notification - * whose original error is an `AbortError` (fetch) or `CanceledError` (axios). - * This is what happens when the user removes an attachment before its upload - * has finished - the inflight request is aborted, not genuinely failed. - */ +// Removing an attachment before its upload finishes aborts the request. That surfaces as an +// `api:attachment:upload:failed` notification whose original error is an `AbortError` (fetch) or +// `CanceledError` (axios). const isCanceledAttachmentUpload = (notification: Notification) => notification.type === ATTACHMENT_UPLOAD_FAILED_TYPE && (notification.originalError?.name === 'CanceledError' || notification.originalError?.name === 'AbortError'); /** - * Notification list filter that keeps every notification except the spurious - * "upload failed" one produced when a user removes an attachment mid-upload - * (a filter returns `true` to keep a notification). Genuine upload failures - * (network, server) are kept because their error is neither aborted nor - * cancelled. + * Notification list filter that hides the "upload failed" notification produced when a user removes + * an attachment mid-upload. Genuine upload failures (network, server) stay, as their error is + * neither an abort nor a cancellation. + * + * @param notification The notification to test. + * @returns `false` for a canceled upload, `true` otherwise. */ export const excludeCanceledUploadNotifications: NotificationListFilter = (notification) => !isCanceledAttachmentUpload(notification); diff --git a/package/src/components/Notifications/notificationTarget.ts b/package/src/components/Notifications/notificationTarget.ts index 874345fded..e63c08dd2a 100644 --- a/package/src/components/Notifications/notificationTarget.ts +++ b/package/src/components/Notifications/notificationTarget.ts @@ -15,7 +15,7 @@ export type BuiltInNotificationTargetPanel = (typeof NOTIFICATION_TARGET_PANELS) export type NotificationTargetPanel = BuiltInNotificationTargetPanel | (string & {}); /** Exact notification host within a panel. */ export type NotificationTarget = { - /** Stable id for a mounted notification host, for example a channel cid-derived id. */ + /** Stable ID of a mounted notification host, such as one derived from a channel `cid`. */ hostId: string; /** Surface that owns the host. */ panel: NotificationTargetPanel; @@ -42,21 +42,44 @@ const actionNotificationTargets = new WeakMap< NotificationTargetStackItem[] >(); -/** Returns whether a value can be encoded as a notification target panel segment. */ +/** + * Returns whether a value can be encoded as a notification target panel segment. + * + * @param value The value to test. + * @returns True for a non-empty string without `:`. + */ export const isNotificationTargetPanel = (value: unknown): value is NotificationTargetPanel => typeof value === 'string' && value.length > 0 && !value.includes(':'); -/** Returns whether a value is one of the SDK's built-in notification panels. */ +/** + * Returns whether a value is one of the SDK's built-in notification panels. + * + * @param value The value to test. + * @returns True for a built-in panel. + */ export const isBuiltInNotificationTargetPanel = ( value: unknown, ): value is BuiltInNotificationTargetPanel => typeof value === 'string' && (NOTIFICATION_TARGET_PANELS as readonly string[]).includes(value); -/** Builds a notification target tag. Without `hostId`, the tag targets every host in the panel. */ +/** + * Builds a notification target tag. Without `hostId`, the tag targets every host in the panel. + * + * @param panel The target panel. + * @param hostId The exact host within the panel. + * @returns The tag. + */ export const getNotificationTargetTag = (panel: NotificationTargetPanel, hostId?: string) => `${NOTIFICATION_TARGET_TAG_PREFIX}${panel}${hostId ? `:${hostId}` : ''}`; -/** Adds a broad or exact target tag to a notification tag list without duplicating tags. */ +/** + * Adds a broad or exact target tag to a notification tag list without duplicating tags. + * + * @param panel The target panel. Without it the tags are returned unchanged. + * @param tags The existing tags. + * @param hostId The exact host within the panel. Omit to target the whole panel. + * @returns The tags, with the target tag first. + */ export const addNotificationTargetTag = ( panel: NotificationTargetPanel | undefined, tags?: string[], @@ -67,17 +90,39 @@ export const addNotificationTargetTag = ( return Array.from(new Set([getNotificationTargetTag(panel, hostId), ...(tags ?? [])])); }; -/** Adds an exact panel and host target tag to a notification tag list. */ +/** + * Adds an exact panel and host target tag to a notification tag list. + * + * @param target The exact target. + * @param tags The existing tags. + * @returns The tags, with the target tag first. + */ export const addExactNotificationTargetTag = (target: NotificationTarget, tags?: string[]) => addNotificationTargetTag(target.panel, tags, target.hostId); -/** Host id helper for channel notification targets. */ +/** + * Builds the host ID of a channel notification target. + * + * @param cid The channel `cid`. + * @returns The host ID. + */ export const getChannelNotificationHostId = (cid: string) => `channel:${cid}` as const; -/** Host id helper for thread notification targets. */ +/** + * Builds the host ID of a thread notification target. + * + * @param threadId The thread ID. + * @returns The host ID. + */ export const getThreadNotificationHostId = (threadId: string) => `thread:${threadId}` as const; -/** Compares notification targets by panel and host id. */ +/** + * Compares notification targets by panel and host ID. + * + * @param left The first target. + * @param right The second target. + * @returns True when both are defined and match. + */ export const isNotificationTargetEqual = ( left: NotificationTarget | undefined, right: NotificationTarget | undefined, @@ -107,7 +152,12 @@ const getNotificationTargetTags = (notification: Notification) => .map(parseNotificationTargetTag) .filter((target): target is ParsedNotificationTargetTag => !!target); -/** Returns whether a tag list already contains a notification target tag. */ +/** + * Returns whether a tag list already contains a notification target tag. + * + * @param tags The tags to search. + * @returns True when any tag is a target tag. + */ export const hasNotificationTargetTag = (tags: string[] | undefined) => tags?.some((tag) => !!parseNotificationTargetTag(tag)) ?? false; @@ -138,7 +188,12 @@ const getNotificationTargetFromComposer = ( return cid ? { hostId: getChannelNotificationHostId(cid), panel: 'channel' } : undefined; }; -/** Resolves an exact notification target from tags or composer origin metadata. */ +/** + * Resolves an exact notification target from tags or composer origin metadata. + * + * @param notification The notification to resolve. + * @returns The target, or `undefined` when none resolves. + */ export const getNotificationTarget = (notification: Notification): NotificationTarget | undefined => getNotificationTargetFromTags(notification) ?? getNotificationTargetFromComposer(notification); @@ -155,7 +210,12 @@ const getExplicitNotificationTargetPanels = ( return isNotificationTargetPanel(panel) ? [panel] : []; }; -/** Resolves the first target panel declared or inferred for a notification. */ +/** + * Resolves the first target panel declared or inferred for a notification. + * + * @param notification The notification to resolve. + * @returns The panel, or `undefined` when none resolves. + */ export const getNotificationTargetPanel = ( notification: Notification, ): NotificationTargetPanel | undefined => { @@ -165,7 +225,12 @@ export const getNotificationTargetPanel = ( return getNotificationTarget(notification)?.panel; }; -/** Resolves all target panels declared or inferred for a notification. */ +/** + * Resolves all target panels declared or inferred for a notification. + * + * @param notification The notification to resolve. + * @returns The panels, empty when none resolve. + */ export const getNotificationTargetPanels = ( notification: Notification, ): NotificationTargetPanel[] => { @@ -218,23 +283,45 @@ const getLastNotificationTarget = ( return stack?.[stack.length - 1]?.target; }; -/** Registers a mounted notification host as the latest active fallback target. */ +/** + * Registers a mounted notification host as the latest active fallback target. + * + * @param owner The notification manager the target belongs to. + * @param target The host to register. + * @returns A function that unregisters the target. + */ export const registerActiveNotificationTarget = ( owner: NotificationTargetOwner, target: NotificationTarget, ) => registerNotificationTarget(activeNotificationTargets, owner, target); -/** Registers the target for notifications emitted while an action is running. */ +/** + * Registers the target for notifications emitted while an action is running. + * + * @param owner The notification manager the target belongs to. + * @param target The target to register. + * @returns A function that unregisters the target. + */ export const registerNotificationActionTarget = ( owner: NotificationTargetOwner, target: NotificationTarget, ) => registerNotificationTarget(actionNotificationTargets, owner, target); -/** Returns the latest mounted fallback target for a notification manager owner. */ +/** + * Returns the latest mounted fallback target for a notification manager owner. + * + * @param owner The notification manager. + * @returns The target, or `undefined` when none is registered. + */ export const getActiveNotificationTarget = (owner: NotificationTargetOwner) => getLastNotificationTarget(activeNotificationTargets, owner); -/** Returns the latest action-scoped target for a notification manager owner. */ +/** + * Returns the latest action-scoped target for a notification manager owner. + * + * @param owner The notification manager. + * @returns The target, or `undefined` when none is registered. + */ export const getNotificationActionTarget = (owner: NotificationTargetOwner) => getLastNotificationTarget(actionNotificationTargets, owner); @@ -248,7 +335,14 @@ const setNotificationTargetTag = (notification: Notification, target: Notificati notification.tags = addExactNotificationTargetTag(target, notification.tags); }; -/** Mutates an unscoped notification by adding the best available exact target tag. */ +/** + * Mutates an unscoped notification by adding the best available exact target tag. The target comes + * from the composer origin, then the running action, then the active host. + * + * @param owner The notification manager. + * @param notification The notification to tag. + * @returns True when a tag was added. + */ export const resolveNotificationTargetTagIfNeeded = ( owner: NotificationTargetOwner, notification: Notification, @@ -277,7 +371,13 @@ export const resolveNotificationTargetTagIfNeeded = ( return false; }; -/** Returns whether a notification should be consumed by an exact target host. */ +/** + * Returns whether a notification should be consumed by an exact target host. + * + * @param notification The notification to test. + * @param target The host. + * @returns True when the host should show it. + */ export const isNotificationForTarget = (notification: Notification, target: NotificationTarget) => { const exactTargets = getExactNotificationTargetTags(notification); if (exactTargets.length > 0) { @@ -297,7 +397,13 @@ export const isNotificationForTarget = (notification: Notification, target: Noti return false; }; -/** Returns whether a notification should be consumed by any host in a panel. */ +/** + * Returns whether a notification should be consumed by any host in a panel. + * + * @param notification The notification to test. + * @param panel The panel. + * @returns True when the panel should show it. + */ export const isNotificationForPanel = ( notification: Notification, panel: NotificationTargetPanel, diff --git a/package/src/components/Notifications/notificationTranslations.ts b/package/src/components/Notifications/notificationTranslations.ts index 7f2c8c288a..e05ce09a16 100644 --- a/package/src/components/Notifications/notificationTranslations.ts +++ b/package/src/components/Notifications/notificationTranslations.ts @@ -128,15 +128,11 @@ const translateCommandDisabled = ({ /** * A translator for every notification `stream-chat` itself emits. * - * `Record` is the point: a new identifier in core fails to compile here until - * it is mapped, and an entry for one that no longer exists is rejected. Before core exported the union, - * this table and the React SDK's equivalent were hand-maintained copies of each other, and both had - * drifted — carrying entries nothing emits while missing identifiers that fell through to untranslated - * English. + * `Record` makes a new core identifier fail to compile until it is + * mapped, and rejects an entry for one that does not exist. * - * The key *names* stay this SDK's own. They have shipped, integrators' dictionaries are keyed on them, - * and `build-translations` reads the literal string at the call site — a key resolved from a map would - * be invisible to the generator and so would never reach the catalog. + * The translation keys are literal strings at the call site: integrators' dictionaries use them, + * and `build-translations` cannot see a key resolved from a map. */ const coreNotificationTranslators: Record = { [CORE_NOTIFICATION_TYPE.attachmentFileMissing]: ({ t }) => @@ -148,7 +144,6 @@ const coreNotificationTranslators: Record t('notifications.attachmentUploadInProgress.error', 'Wait until all attachments have uploaded'), [CORE_NOTIFICATION_TYPE.commandDisabled]: translateCommandDisabled, - // The three below were unmapped, so they rendered untranslated English from `notification.message`. [CORE_NOTIFICATION_TYPE.commandNotReady]: ({ t }) => t('notifications.commandNotReady.error', 'Command not ready to be sent'), [CORE_NOTIFICATION_TYPE.locationCreateFailed]: ({ t }) => @@ -157,7 +152,7 @@ const coreNotificationTranslators: Record t('notifications.messageJumpToLatestFailed.error', 'Failed to jump to the latest message'), - // The composer's send and update failures, which the SDK used to report itself with this copy. + // The composer's send and update failures. [CORE_NOTIFICATION_TYPE.messageSendFailed]: ({ t }) => t('common.sendMessageFailed.error', 'Send message request failed'), [CORE_NOTIFICATION_TYPE.messageUpdateFailed]: ({ t }) => @@ -171,12 +166,10 @@ const coreNotificationTranslators: Record = { 'api:poll:end:failed': translatePollEndFailed, @@ -205,8 +198,6 @@ export const getNotificationDisplayMessage = ({ : undefined; // An unrecognized identifier renders `message` verbatim. It is untranslated English, but a newer - // core — or an integrator emitting its own identifier — must not produce an empty toast. This used - // to run the message through a table of English sentences, which is the mechanism core's `type` - // replaced: matching on prose went stale silently on every core upgrade. + // core or an integrator's own identifier must not produce an empty toast. return translator ? translator({ notification, t }) : notification.message; }; diff --git a/package/src/components/Poll/CreatePollContent.tsx b/package/src/components/Poll/CreatePollContent.tsx index 9fb57623fc..53060e49ce 100644 --- a/package/src/components/Poll/CreatePollContent.tsx +++ b/package/src/components/Poll/CreatePollContent.tsx @@ -53,7 +53,7 @@ export const CreatePollContent = () => { const optionsRef = useRef(options); optionsRef.current = options; - // positions and index lookup map + // Option positions and the index lookup map. // TODO: Please rethink the structure of this, bidirectional data flow is not great const currentOptionPositions = useSharedValue({ inverseIndexCache: {}, @@ -81,8 +81,8 @@ export const CreatePollContent = () => { currentPositions.positionCache[option.id] !== undefined, ); - // Avoid overwriting freshly measured heights/tops from CreatePollOptions onLayout. - // We only need this effect when options ids/order introduced missing cache entries. + // Skip when the cache matches the options, so heights and tops measured by the `onLayout` of + // `CreatePollOptions` are kept. if (isCacheAlignedWithOptions) { return; } @@ -241,7 +241,7 @@ export const CreatePoll = ({ return; } - // Let the modal render once with exit animations disabled before we dismiss it. + // Let the modal render once with exit animations disabled before it is dismissed. modalStateStore.partialNext({ isClosing: true }); closeFrameRef.current = requestAnimationFrame(() => { closeFrameRef.current = null; diff --git a/package/src/components/Poll/components/Button.tsx b/package/src/components/Poll/components/Button.tsx index 19aa77e565..1c8cf39d1b 100644 --- a/package/src/components/Poll/components/Button.tsx +++ b/package/src/components/Poll/components/Button.tsx @@ -11,8 +11,8 @@ export type PollButtonProps = { export type PollVoteButtonProps = { /** - * Render with the incoming chat tokens regardless of poll ownership. Set on - * surfaces that are not a message bubble, such as the full-options list. + * Renders with the incoming chat tokens regardless of poll ownership. Set it outside a message + * bubble, such as in the full-options list. */ forceIncoming?: boolean; option: PollOptionResponseData; diff --git a/package/src/components/Poll/components/CreatePollHeader.tsx b/package/src/components/Poll/components/CreatePollHeader.tsx index 8d237400fa..f2f446d8cd 100644 --- a/package/src/components/Poll/components/CreatePollHeader.tsx +++ b/package/src/components/Poll/components/CreatePollHeader.tsx @@ -10,15 +10,9 @@ import { Button } from '../../ui'; import { useCanCreatePoll } from '../hooks/useCanCreatePoll'; export type CreatePollHeaderProps = { - /** - * Handler for back button press - * @returns void - */ + /** Called when the back button is pressed. */ onBackPressHandler: () => void; - /** - * Handler for create poll button press - * @returns void - */ + /** Called when the create poll button is pressed. */ onCreatePollPressHandler: () => void; }; diff --git a/package/src/components/Poll/components/CreatePollOptions.tsx b/package/src/components/Poll/components/CreatePollOptions.tsx index 387936c97e..62536d1ee9 100644 --- a/package/src/components/Poll/components/CreatePollOptions.tsx +++ b/package/src/components/Poll/components/CreatePollOptions.tsx @@ -60,9 +60,9 @@ export type CreatePollOptionType = { optionId: string; onOptionLayout: (optionId: string, height: number) => void; /** + * Called after a reorder. * - * @param newOrder The inverse index object of the new options position after re-ordering. - * @returns + * @param newOrder The new inverse index cache (position to option ID). */ onNewOrder: (newOrder: CurrentOptionPositionsCache['inverseIndexCache']) => void; onRemoveOption: (optionId: string) => void; @@ -290,9 +290,8 @@ export const CreatePollOption = ({ const { icons } = useComponentsContext(); const { createPollOptionGap = 8 } = useCreatePollContentContext(); - // Keyed on `stream-chat`'s stable validation code. This used to match on the English sentence the - // composer returned, which meant a reworded message in a `stream-chat` patch silently stopped - // translating. `error.message` remains the fallback so an unrecognized code renders readable text. + // Keyed on `stream-chat`'s stable validation code; `error.message` is the fallback for an + // unrecognized code. const knownValidationErrors = useMemo>>( () => ({ [POLL_COMPOSER_VALIDATION_CODE.optionDuplicate]: t( @@ -340,13 +339,10 @@ export const CreatePollOption = ({ () => currentOptionPositions.value, ); - // Target index computed during drag. const newIndex = useSharedValue(null); - // Current dragged item's index in the live reorder cache. const currentIndex = useSharedValue(null); - // Keep drag translation anchored to drag-start top. const dragStartTop = useSharedValue(0); const previousDragTop = useSharedValue(0); @@ -366,13 +362,10 @@ export const CreatePollOption = ({ return; } - // start dragging isDragging.value = withSpring(1); - // keep track of dragged item draggedItemId.value = optionId; - // capture drag start position/index currentIndex.value = currentPosition.updatedIndex; dragStartTop.value = currentPosition.updatedTop; previousDragTop.value = currentPosition.updatedTop; @@ -393,14 +386,12 @@ export const CreatePollOption = ({ const frameDeltaY = newTop - previousDragTop.value; previousDragTop.value = newTop; - // we add a small leeway to account for sharp animations which tend to bug out otherwise + // A small leeway past the bounds avoids glitches in sharp animations. if (newTop < -10 || newTop > maxBound + 10) { - // out of bounds, exit out of the animation early return; } top.value = newTop; - // calculate the new index where drag is headed to const draggedTop = newTop; const draggedBottom = newTop + draggedItemPosition.updatedHeight; newIndex.value = findTargetIndex( @@ -449,7 +440,6 @@ export const CreatePollOption = ({ : undefined; top.value = withSpring(currentDragTop ?? top.value); - // stop dragging isDragging.value = withDelay(200, withSpring(0)); draggedItemId.value = null; currentIndex.value = null; @@ -597,13 +587,12 @@ export const CreatePollOptions = ({ currentOptionPositions }: CreatePollOptionsP pollComposer.handleFieldBlur('options'); }, [pollComposer]); - // used to know if drag is happening or not + // 1 while an option is being dragged. const isDragging = useSharedValue<0 | 1>(0); - // this will hold id for item which user started dragging + // ID of the option being dragged. const draggedItemId = useSharedValue(null); - // holds the animated height of the option container const animatedOptionsContainerHeight = useSharedValue(currentOptionPositions.value.totalHeight); useAnimatedReaction( diff --git a/package/src/components/Poll/components/PollButtons.tsx b/package/src/components/Poll/components/PollButtons.tsx index fbe50636ab..7d1b1874f0 100644 --- a/package/src/components/Poll/components/PollButtons.tsx +++ b/package/src/components/Poll/components/PollButtons.tsx @@ -270,8 +270,8 @@ const useStyles = () => { ? semantics.chatBorderOnChatOutgoing : semantics.chatBorderOnChatIncoming, }, - // NOTE: PollButtons only ever renders on the in-bubble surface (it is what - // opens the full-options modal), so it needs no `forceIncoming` handling. + // PollButtons only renders on the in-bubble surface (it opens the full-options modal), so it + // needs no `forceIncoming` handling. }); }, [semantics, isPollCreatedByClient]); }; diff --git a/package/src/components/Poll/components/PollOption.tsx b/package/src/components/Poll/components/PollOption.tsx index 1abc287639..143ade8e47 100644 --- a/package/src/components/Poll/components/PollOption.tsx +++ b/package/src/components/Poll/components/PollOption.tsx @@ -97,8 +97,8 @@ export const PollOption = ({ option, showProgressBar = true, forceIncoming }: Po const { latestVotesByOption, voteCountsByOption, voteCount } = usePollState(); const { t } = useTranslationContext(); const isPollCreatedByClient = useIsPollCreatedByCurrentUser(); - // `forceIncoming` is set on the full-options surface, which is a neutral card - // rather than a bubble - so it wins over poll ownership. + // `forceIncoming` marks the full-options surface, a neutral card rather than a bubble, so it wins + // over poll ownership. const isIncoming = !isPollCreatedByClient || !!forceIncoming; const styles = useStyles({ isIncoming }); diff --git a/package/src/components/Poll/hook/useIsPollCreatedByCurrentUser.ts b/package/src/components/Poll/hook/useIsPollCreatedByCurrentUser.ts index 9cb9d532e9..802e4ca2de 100644 --- a/package/src/components/Poll/hook/useIsPollCreatedByCurrentUser.ts +++ b/package/src/components/Poll/hook/useIsPollCreatedByCurrentUser.ts @@ -2,8 +2,9 @@ import { useChatContext } from '../../../contexts'; import { usePollState } from '../hooks/usePollState'; /** - * React hook to check if the poll was created by the logged in client. - * @returns Whether the poll was created by the logged in client. + * Whether the poll was created by the current user. + * + * @returns True when the poll's creator is the logged in user. */ export const useIsPollCreatedByCurrentUser = () => { const { createdBy } = usePollState(); diff --git a/package/src/components/Poll/hooks/useEndVote.ts b/package/src/components/Poll/hooks/useEndVote.ts index be4e368b3e..f0344a393e 100644 --- a/package/src/components/Poll/hooks/useEndVote.ts +++ b/package/src/components/Poll/hooks/useEndVote.ts @@ -3,9 +3,10 @@ import { useStableCallback } from '../../../hooks'; import { useNotificationApi } from '../../Notifications'; /** - * Returns a stable callback that closes the current poll and emits a success or - * failure notification. Shared by `EndVoteButton` and the rotor accessibility - * action so both paths produce identical side effects. + * Returns a stable callback that closes the current poll and emits a success or failure + * notification. Shared by the end vote button and its accessibility action. + * + * @returns An async callback that resolves with the close response and rethrows on failure. */ export const useEndVote = () => { const { poll } = usePollContext(); diff --git a/package/src/components/Poll/hooks/usePollAnswersPagination.ts b/package/src/components/Poll/hooks/usePollAnswersPagination.ts index a4663ec7d7..651efc69ae 100644 --- a/package/src/components/Poll/hooks/usePollAnswersPagination.ts +++ b/package/src/components/Poll/hooks/usePollAnswersPagination.ts @@ -21,19 +21,13 @@ export type UsePollAnswersReturnType = { }; /** - * A hook that queries answers for a given Poll and returns them in a paginated fashion. - * Should be used instead of the latest_answers property within the reactive state in the - * event that we need more than the top 10 answers. The returned property pollAnswers will - * automatically be updated and trigger a state change when paginating further. + * Queries the answers of the poll in context page by page, for when `latest_answers` in the poll + * state does not hold enough of them. `pollAnswers` stays in sync with answer events for the poll. * - * @param loadFirstPage {boolean} Signifies whether the first page should be automatically loaded whenever - * the hook is first called. - * @param paginationParams {PollAnswersQueryParams} The pagination params we might want to use for our custom - * needs when invoking the hook. - * - * @returns {UsePollAnswersReturnType} An object containing all of the needed pagination values as well as the - * answers. - **/ + * @param params.loadFirstPage Loads the first page on mount. Defaults to true. + * @param params.paginationParams Filter, sort and options merged into every query. + * @returns The loaded answers with the pagination state and `loadMore`. + */ export const usePollAnswersPagination = ({ loadFirstPage = true, paginationParams, diff --git a/package/src/components/Poll/hooks/usePollOptionVotesPagination.ts b/package/src/components/Poll/hooks/usePollOptionVotesPagination.ts index 0d99ea398b..3004085f23 100644 --- a/package/src/components/Poll/hooks/usePollOptionVotesPagination.ts +++ b/package/src/components/Poll/hooks/usePollOptionVotesPagination.ts @@ -26,22 +26,14 @@ export type UsePollVotesReturnType = { }; /** - * A hook that queries votes for a given Poll and returns them in a paginated fashion. - * Should be used instead of the latestVotesByOption property within the reactive state in the - * event that we need more than the top 10 votes for an option. The returned property votes will - * automatically be updated and trigger a state change when paginating further. Querying for votes - * can only be done on an option by option basis. + * Queries the votes of one poll option page by page, for when `latestVotesByOption` in the poll + * state does not hold enough of them. `votes` stays in sync with vote events for the option. * - * @param option {PollOptionResponseData} The option for which we want to load the votes. - * @param loadFirstPage {boolean} Signifies whether the first page should be automatically loaded whenever - * the hook is first called. - * @param paginationParams {PollOptionVotesQueryParams} The pagination params we might want to use for our custom - * needs when invoking the hook. - * - * @returns {UsePollVotesReturnType} An object containing all of the needed pagination values as well as the - * answers. - **/ - + * @param params.option The option to load the votes of. + * @param params.loadFirstPage Loads the first page on mount. Defaults to true. + * @param params.paginationParams Filter, sort and options merged into every query. + * @returns The loaded votes with the pagination state and `loadMore`. + */ export const usePollOptionVotesPagination = ({ loadFirstPage = true, option, diff --git a/package/src/components/Poll/hooks/usePollVoteToggle.ts b/package/src/components/Poll/hooks/usePollVoteToggle.ts index 15104dc38c..794aa9edee 100644 --- a/package/src/components/Poll/hooks/usePollVoteToggle.ts +++ b/package/src/components/Poll/hooks/usePollVoteToggle.ts @@ -11,10 +11,11 @@ const ownVotesSelector = (state: PollState) => ({ }); /** - * Returns a stable callback that toggles the current user's vote on a poll option - * by id: casts a vote if none exists, removes it if one does. Shared by the - * visible vote button and the rotor accessibility action so both paths use - * identical logic. + * Returns a stable callback that toggles the current user's vote on an option, by option ID: it + * removes the vote if one exists and casts it otherwise. Shared by the vote button and its + * accessibility action. + * + * @returns An async callback taking the option ID. */ export const usePollVoteToggle = () => { const { message, poll } = usePollContext(); diff --git a/package/src/components/Poll/index.ts b/package/src/components/Poll/index.ts index ed6e2af135..fcc007981f 100644 --- a/package/src/components/Poll/index.ts +++ b/package/src/components/Poll/index.ts @@ -1,9 +1,7 @@ -// components export * from './Poll'; export * from './CreatePollContent'; export * from './components'; -// hooks export * from './hooks/usePollState'; export * from './hooks/usePollStateStore'; export * from './hooks/usePollAnswersPagination'; diff --git a/package/src/components/ProgressControl/ProgressBar.tsx b/package/src/components/ProgressControl/ProgressBar.tsx index 347d868dfd..52bb8f4a05 100644 --- a/package/src/components/ProgressControl/ProgressBar.tsx +++ b/package/src/components/ProgressControl/ProgressBar.tsx @@ -15,7 +15,6 @@ export type ProgressBarProps = { export const ProgressBar = ({ progress, filledColor, emptyColor }: ProgressBarProps) => { const styles = useStyles(); - // clamp for safety const value = Math.max(0, Math.min(progress, 1)); const unfilledValue = 1 - value; diff --git a/package/src/components/ProgressControl/ProgressControl.tsx b/package/src/components/ProgressControl/ProgressControl.tsx index 37c8b3deb8..de45bf9971 100644 --- a/package/src/components/ProgressControl/ProgressControl.tsx +++ b/package/src/components/ProgressControl/ProgressControl.tsx @@ -15,36 +15,19 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; export type ProgressControlProps = { - /** - * If true, the underlying attachment is playing. - */ + /** Whether the underlying attachment is playing. */ isPlaying: boolean; - /** - * The progress of the progress bar in percentage - */ + /** Playback progress as a fraction from 0 to 1. */ progress: number; - /** - * The test ID of the progress control - */ + /** Test ID of the pan gesture. */ testID: string; - /** - * The function to be called when the user ends dragging the progress bar - */ + /** Called with the progress when a drag ends. The control is only seekable when this is set. */ onEndDrag?: (progress: number) => void; - /** - * The function to be called when the user starts dragging the progress bar - */ + /** Called with the progress when a drag starts. */ onStartDrag?: (progress: number) => void; - /** - * Expands the draggable/touchable area vertically around the thin visual - * track via `hitSlop`. The 3px track is easy to miss, so set this when the - * control is the primary seek surface (e.g. the image gallery video controls) - * to make it comfortably grabbable. The visual track height is unchanged. - */ + /** Expands the touch area vertically by 12px on each side of the 3px track. */ expandedTouchArea?: boolean; - /** - * The width of the progress control - */ + /** Not read; the control measures its own width. */ width?: number; }; diff --git a/package/src/components/ProgressControl/ProgressThumb.tsx b/package/src/components/ProgressControl/ProgressThumb.tsx index 1ca650bcd5..df6090e7a1 100644 --- a/package/src/components/ProgressControl/ProgressThumb.tsx +++ b/package/src/components/ProgressControl/ProgressThumb.tsx @@ -9,13 +9,9 @@ export const PROGRESS_THUMB_WIDTH = 12; export const PROGRESS_THUMB_HEIGHT = 12; export type ProgressControlThumbProps = { - /** - * If true, the underlying attachment is playing. - */ + /** Whether the underlying attachment is playing. */ isPlaying: boolean; - /** - * The style of the progress control thumb. - */ + /** Style of the thumb. */ style?: StyleProp; }; diff --git a/package/src/components/ProgressControl/StableDurationLabel.tsx b/package/src/components/ProgressControl/StableDurationLabel.tsx index 0429e0b57c..e1dd1b21fd 100644 --- a/package/src/components/ProgressControl/StableDurationLabel.tsx +++ b/package/src/components/ProgressControl/StableDurationLabel.tsx @@ -18,10 +18,8 @@ type StableDurationLabelProps = { }; /** - * Renders a duration label with a layout-stable width. - * - * It reserves space using an invisible max-width label, then absolutely positions - * the live value on top so playback updates do not cause parent relayout. + * Renders a duration label with a stable width: an invisible `reserveLabel` takes the space and + * `label` is positioned over it, so updating `label` does not re-lay out the parent. */ export const StableDurationLabel = React.memo( ({ diff --git a/package/src/components/ProgressControl/WaveProgressBar.tsx b/package/src/components/ProgressControl/WaveProgressBar.tsx index 83ed723203..f1bcd7fb68 100644 --- a/package/src/components/ProgressControl/WaveProgressBar.tsx +++ b/package/src/components/ProgressControl/WaveProgressBar.tsx @@ -11,33 +11,19 @@ import { primitives } from '../../theme'; import { resampleWaveformData } from '../MessageInput/utils/audioSampling'; export type WaveProgressBarProps = { - /** - * If true, the underlying attachment is playing. - */ + /** Whether the underlying attachment is playing. */ isPlaying?: boolean; - /** - * The progress of the waveform in percentage - */ + /** Playback progress as a fraction from 0 to 1. */ progress: number; - /** - * The waveform data to be displayed - */ + /** Waveform amplitudes, resampled to `amplitudesCount` bars. */ waveformData: number[]; - /** - * The number of amplitudes to display - */ + /** Number of bars to draw. Defaults to as many as fit the measured width, at least 20. */ amplitudesCount?: number; - /** - * The function to be called when the user ends dragging the waveform - */ + /** Called with the progress when a drag ends. */ onEndDrag?: (progress: number) => void; - /** - * The function to be called when the user is dragging the waveform - */ + /** Called with the progress on every drag update. */ onProgressDrag?: (progress: number) => void; - /** - * The function to be called when the user starts dragging the waveform - */ + /** Called with the progress when a drag starts. */ onStartDrag?: (progress: number) => void; }; diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index cdbe781fb7..f2c9db155c 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -103,7 +103,7 @@ export type ReplyPropsWithContext = { ImageComponent: React.ComponentType void; mode: 'reply' | 'edit'; - // This is temporary for the MessageContent Component to style the Reply component + // Style overrides passed by `MessageContent`. styles?: { container?: ViewStyle; leftContainer?: ViewStyle; @@ -168,7 +168,7 @@ export const ReplyWithContext = (props: ReplyPropsWithContext) => { {title} - {/* `isMyMessage` here selects which side to paint, so it takes the surface. */} + {/* Paints for the surface it sits on, so it takes the parent message's ownership. */} @@ -254,9 +254,8 @@ export type ReplyProps = Partial & Pick; /** - * Mounted only when the Reply is rendered as the composer header preview - * (edit/reply). Keeps the translation + announcer subscriptions off the - * per-row in-message quoted-reply render path. + * Announces the edit/reply state to screen readers. Mounted only for the composer header preview, + * so in-message quoted replies skip the translation and announcer subscriptions. */ const ReplyComposerAnnouncer = ({ message, @@ -310,20 +309,18 @@ export const Reply = (props: ReplyProps) => { ? (messageFromContext.quoted_message as ReplyPropsWithContext['quotedMessage']) : quotedMessageFromComposer; - // `mode='edit'` supplies the edited message via `props.quotedMessage` and leaves - // the composer's quoted message empty, so ownership must consider both. + // `mode='edit'` passes the edited message as `props.quotedMessage` and leaves the composer's + // quoted message empty, so ownership considers both. const isMyMessage = client.user?.id === (props.quotedMessage ?? quotedMessage)?.user?.id; const isParentMessageMine = messageFromContext ? !!isMyMessageFromContext : undefined; - // Composer header passes `onDismiss`; the in-message quoted-reply renderer - // does not. Only the composer-preview path pays for announcement work. + // Only the composer header passes `onDismiss`, which marks the composer preview. const isComposerPreview = !!props.onDismiss; return ( <> {isComposerPreview ? ( - // Edit passes the message via `quotedMessage` prop; reply uses the - // composer-state quoted message we computed locally. + // Edit passes the message as `quotedMessage`; reply uses the composer's quoted message. ) : null} void; }; @@ -36,8 +34,7 @@ type ThreadReplyPaginatorState = { }; const paginatorSelector = (state: ThreadReplyPaginatorState) => ({ - // `hasItems`, not the array: every use below is an `items === undefined` test ("has the reply - // paginator loaded yet"), so selecting the array re-renders this component on every reply. + // `hasItems` rather than the array: the array would re-render this component on every reply. hasItems: state.items !== undefined, isLoading: state.isLoading, lastQueryError: state.lastQueryError, @@ -49,8 +46,8 @@ const threadStaleSelector = (state: { isLoading: boolean; isStateStale: boolean }); /** - * Thread - binds its children to a thread's replies. Render it next to ``, not inside it, - * and compose the thread UI as children: + * Binds its children to a thread's replies. Render it next to ``, not inside it, and + * compose the thread UI as children: * * ```tsx * @@ -85,10 +82,9 @@ export const Thread = (props: PropsWithChildren) => { } }, [isStateStale, thread]); - // A thread opened from the thread list can belong to a channel nothing has queried yet. `queryThreads` - // watches that channel but returns no `own_capabilities` for it, so the menu, the reactions and the - // composer would act as if the user could do nothing. Querying the channel once fills them in; its - // messages and read state are left alone. + // A thread opened from the thread list can belong to a channel nobody has queried. `queryThreads` + // returns no `own_capabilities` for it, so the menu, reactions and composer would treat the user + // as having none; watching the channel once fills them in. useEffect(() => { const { channel } = thread; if (channel.initialized || channel.isProvisional) { @@ -97,32 +93,28 @@ export const Thread = (props: PropsWithChildren) => { channel.watch().catch((err: unknown) => console.warn('Thread channel watch failed', err)); }, [thread]); - // Activating registers the thread with `client.threads` for the session, which keeps it subscribed - // (incoming replies, read state, thread.updated) whether or not the thread list holds it. While - // active, the thread marks itself read. `activate()` returns the release of this activation. + // Activating keeps the thread subscribed (new replies, read state, `thread.updated`) even when + // the thread list does not hold it, and marks it read while active. `activate()` returns the + // release. useEffect(() => thread.activate(), [thread]); - // Load the first reply page, but only when the paginator hasn't already been seeded from the - // thread's `latest_replies` (managed/queried threads seed on construction) or loaded/loading. A - // seeded paginator already holds its first page, so we skip the fetch and let scroll-up load older - // replies — mirroring stream-chat-react, whose thread list has no mount-time fetch. The `hasItems` - // guard makes this fire at most once (an unseeded thread fetches, and the fetch defines `items`). + // Load the first reply page unless the paginator is loading or already holds items (threads seed + // from `latest_replies` on construction). `hasItems` makes this fire at most once. useEffect(() => { if (isLoading || hasItems || lastQueryError) { return; } - // A reload in flight (a stale thread's, started by the effect above in this same commit) seeds the - // replies itself; read live, as this commit's render predates it. If it fails, this runs again. + // A reload in flight (started by the stale-state effect above in this commit) seeds the replies + // itself. Read live, because this render predates it. If it fails, this runs again. if (isThreadReloading || thread.state.getLatestValue().isLoading) { return; } void thread.messagePaginator.toTail(); - // `lastQueryError` is load-bearing here, not decorative: a failed query flips `isLoading` back to - // false with `hasItems` still false, which would re-run this effect and refetch forever. The - // retry is the user's to make, through the error indicator below. + // `lastQueryError` must stay a dependency: a failed query sets `isLoading` back to false with + // `hasItems` still false, which would otherwise refetch forever. The error indicator retries. }, [thread, hasItems, isLoading, lastQueryError, isThreadReloading]); - // Deactivation is the activation effect's cleanup; this only notifies the integrator. + // Deactivation is the activation effect's cleanup; this only calls the callback. useEffect( () => () => { if (onThreadDismount) { @@ -133,14 +125,13 @@ export const Thread = (props: PropsWithChildren) => { [], ); - // A jump into the replies (e.g. to a targeted reply) leaves a focus signal behind; the list reads - // this to keep its initial anchor from overriding the jump. + // A jump to a targeted reply leaves a focus signal that stops the list's initial anchor from + // overriding the jump. const hasPendingInitialTargetLoad = useStableCallback( () => !!thread.messagePaginator.messageFocusSignal.getLatestValue().signal, ); - // The thread's own query error: the failure belongs to the reply paginator, and the retry is a - // first-page `toTail`. + // The error belongs to the reply paginator; retrying reloads the first page with `toTail`. if (lastQueryError && !hasItems) { const retry = () => thread.messagePaginator diff --git a/package/src/components/Thread/components/ThreadFooterComponent.tsx b/package/src/components/Thread/components/ThreadFooterComponent.tsx index 846ae0996e..9e4d33cdfb 100644 --- a/package/src/components/Thread/components/ThreadFooterComponent.tsx +++ b/package/src/components/Thread/components/ThreadFooterComponent.tsx @@ -14,9 +14,7 @@ import { useStateStore } from '../../../hooks'; import { primitives } from '../../../theme'; type ThreadFooterComponentPropsWithContext = Pick & { - /** - * Boolean to enable/disable parent message press. Defaults to `true`. - */ + /** Whether pressing the parent message is disabled. Defaults to true. */ parentMessagePreventPress?: boolean; }; @@ -54,8 +52,8 @@ const ThreadFooterComponentWithContext = (props: ThreadFooterComponentPropsWithC const styles = useStyles(); - // The parent message is read reactively from the thread instance so the header updates live when - // it is edited / reacted to / its reply_count changes. + // Read from the thread state so the header updates when the parent message or its reply count + // changes. const { parentMessage, replyCount = parentMessage?.reply_count } = useStateStore(thread?.state, selector) ?? {}; @@ -89,9 +87,8 @@ const areEqual = ( prevProps: ThreadFooterComponentPropsWithContext, nextProps: ThreadFooterComponentPropsWithContext, ) => { - // The parent message + reply count are read reactively from `thread.state` inside the - // component (via useStateStore), so this memo only needs to gate parent-driven re-renders on the - // remaining props: the prevent-press flag and the thread instance identity. + // The parent message and reply count come from `thread.state`, so only the prevent-press flag + // and the thread identity gate re-renders. const { parentMessagePreventPress: prevParentMessagePreventPress, thread: prevThread } = prevProps; const { parentMessagePreventPress: nextParentMessagePreventPress, thread: nextThread } = diff --git a/package/src/components/ThreadList/ThreadMessagePreviewDeliveryStatus.tsx b/package/src/components/ThreadList/ThreadMessagePreviewDeliveryStatus.tsx index 50cbec2564..749a579258 100644 --- a/package/src/components/ThreadList/ThreadMessagePreviewDeliveryStatus.tsx +++ b/package/src/components/ThreadList/ThreadMessagePreviewDeliveryStatus.tsx @@ -12,9 +12,7 @@ import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; import { MessageStatusTypes } from '../../utils/utils'; -/** - * Module scope so the reference stays stable — an inline selector re-subscribes on every render. - */ +// Module scope so the selector stays referentially stable. const readEventsSelector = ({ readEvents }: ChannelConfig) => ({ readEventsEnabled: readEvents.enabled, }); @@ -31,9 +29,9 @@ export const ThreadMessagePreviewDeliveryStatus = ({ const { client } = useChatContext(); const { icons } = useComponentsContext(); const { t } = useTranslationContext(); - // `configState` is absent on the partial channel mocks some tests pass in; a real channel always - // has one. Resolved configuration, so `read_events` is already ANDed with anything registered - // through `client.config.set({ channel: { readEvents } })`. + // `configState` is the resolved channel configuration, so `read_events` already accounts for + // `client.config.set({ channel: { readEvents } })`. Optional chaining covers partial channel + // mocks. const configState = channel?.configState; const { readEventsEnabled } = useStateStore(configState, readEventsSelector) ?? {}; const styles = useStyles(); diff --git a/package/src/components/UIComponents/BottomSheetModal.tsx b/package/src/components/UIComponents/BottomSheetModal.tsx index 58b9f20096..aa54436d54 100644 --- a/package/src/components/UIComponents/BottomSheetModal.tsx +++ b/package/src/components/UIComponents/BottomSheetModal.tsx @@ -59,28 +59,15 @@ const supportedOrientations: ModalProps['supportedOrientations'] = [ const LANDSCAPE_MAX_HEIGHT_RATIO = 0.9; export type BottomSheetModalProps = { - /** - * Function to call when the modal is closed or dismissed. - * @returns void - */ + /** Called when the modal is closed or dismissed. */ onClose: () => void; - /** - * Whether the modal is visible. - */ + /** Whether the modal is visible. */ visible: boolean; - /** - * The height of the modal. - */ + /** Height of the modal, capped to the available window height. Defaults to half the window. */ height?: number; - /** - * Whether the sheet should wrap its content up to the provided `height`. - */ + /** Whether the sheet wraps its content, up to `height`. Defaults to false. */ enableDynamicSizing?: boolean; - /** - * Whether the sheet content should be lazy loaded or not. Particularly - * useful when the content is something heavy and we don't want to disrupt - * the animations while this is happening. - */ + /** Renders the content only after the opening animation finishes. Defaults to false. */ lazy?: boolean; }; @@ -146,12 +133,9 @@ const BottomSheetModalInner = (props: PropsWithChildren) const wasVisibleRef = useRef(false); const [renderContent, setRenderContent] = useState(!lazy); - // We keep the underlying RN `` mounted while it dismisses so we can - // listen for its `onDismiss` (iOS) and only then run the consumer's - // close-finished callback. Without this, callbacks that present another - // native modal (e.g. `UIImagePickerController` for camera capture) can race - // the still-dismissing view controller and crash with "Application tried to - // present modal view controller on top of itself" on iOS. + // Keeps the RN `` mounted while it dismisses so the close callback runs only after its + // `onDismiss` (iOS). Otherwise a callback that presents another native modal (such as the camera) + // races the dismissing view controller and crashes iOS. const [isDismissing, setIsDismissing] = useState(false); const pendingCloseCallbackRef = useRef<(() => void) | undefined>(undefined); @@ -208,9 +192,8 @@ const BottomSheetModalInner = (props: PropsWithChildren) }) => { pendingCloseCallbackRef.current = closeAnimationFinishedCallback; if (Platform.OS !== 'ios') { - // RN's `Modal.onDismiss` is iOS-only, so synthesize the same signal on - // other platforms after a short delay that gives the Dialog time to - // detach. Matches the previous Android behavior. + // `Modal.onDismiss` is iOS-only; elsewhere, signal it after a short delay that lets the + // Dialog detach. setTimeout(handleNativeModalDismiss, 100); } else { if (shouldDismiss) { @@ -274,8 +257,7 @@ const BottomSheetModalInner = (props: PropsWithChildren) closeInternal({ closeAnimationFinishedCallback: dismissFinishedCallback, shouldDismiss: true }); }); - // modal opening layout effect - we make sure to only show the content - // after the animation has finished if `lazy` has been set to true + // With `lazy`, the content is shown only after the opening animation finishes. useLayoutEffect(() => { const wasVisible = hasCommittedVisibilityRef.current ? wasVisibleRef.current : false; hasCommittedVisibilityRef.current = true; @@ -350,7 +332,7 @@ const BottomSheetModalInner = (props: PropsWithChildren) ], ); - // if `visible` gets hard changed, we force a cleanup + // Reset the animation state when the modal is hidden. useEffect(() => { if (visible) return; @@ -520,12 +502,11 @@ const BottomSheetModalInner = (props: PropsWithChildren) } } - // velocity-based snapping, fast upward swipe goes to top snap point + // A fast upward swipe goes to the top snap point. if (hasMultipleSnapPoints && event.velocityY < -800) { nearestIndex = topSnapIndex.value; } - // From top snap, a gentle downward flick should settle to snap 0 - // without requiring a large drag distance. + // From the top snap, a gentle downward flick settles to snap 0. if (hasMultipleSnapPoints && isAtTopSnap && event.velocityY > 120) { nearestIndex = 0; } @@ -671,9 +652,9 @@ const useStyles = () => { }, contentContainer: { flex: 1, - // A RN `Modal` is its own native window, so a consumer's `SafeAreaView` never reaches - // this - it insets itself. Spread rather than assigned: a `paddingLeft: 0` longhand - // would override a themed `paddingHorizontal` for that side. + // A RN `Modal` is its own native window, so a consumer's `SafeAreaView` never reaches it. + // Spread rather than assigned: a `paddingLeft: 0` longhand would override a themed + // `paddingHorizontal` for that side. ...horizontalInsets, }, handle: { diff --git a/package/src/components/UIComponents/ClippingFadeBottom.tsx b/package/src/components/UIComponents/ClippingFadeBottom.tsx index 0c7f025d0e..89a2dac08a 100644 --- a/package/src/components/UIComponents/ClippingFadeBottom.tsx +++ b/package/src/components/UIComponents/ClippingFadeBottom.tsx @@ -7,20 +7,13 @@ const CLIPPING_FADE_HEIGHT = 16; const CLIPPING_FADE_GRADIENT_ID = 'sdk-clipping-fade-bottom'; export type ClippingFadeBottomProps = { - /** - * Color the fade ramps toward at the bottom edge. Typically the - * background color of the surface beneath the fade so the bottom edge of - * scrolling content visually melts into it. - */ + /** Color the fade ramps toward at the bottom edge, usually the background beneath it. */ backgroundColor: string; }; /** - * Bottom edge fade overlay. Draws a 16px tall SVG linear gradient that - * ramps from the supplied background's transparent variant at the top to - * fully opaque at the bottom - visually clipping any content that scrolls - * past the lower edge of its parent. `pointerEvents='none'` so it doesn't - * intercept taps/scrolls on the rows underneath. + * Bottom edge fade overlay: a 16px tall gradient from transparent to `backgroundColor` that hides + * content scrolling past the parent's lower edge. It ignores touches (`pointerEvents='none'`). */ export const ClippingFadeBottom = ({ backgroundColor }: ClippingFadeBottomProps) => ( diff --git a/package/src/components/UIComponents/EmptyList.tsx b/package/src/components/UIComponents/EmptyList.tsx index 679fb8e003..2ea96a1d58 100644 --- a/package/src/components/UIComponents/EmptyList.tsx +++ b/package/src/components/UIComponents/EmptyList.tsx @@ -6,17 +6,11 @@ import { IconProps } from '../../icons/utils/base'; import { primitives } from '../../theme'; export type EmptyListProps = { - /** - * Icon component to render. Its size and color are set by `EmptyList`. - */ + /** Icon component to render. Its size and color are set by `EmptyList`. */ icon: React.ComponentType; - /** - * Title text shown below the icon. - */ + /** Title text shown below the icon. */ title: string; - /** - * Optional supporting text shown below the title. - */ + /** Supporting text shown below the title. */ subtitle?: string; }; diff --git a/package/src/components/UIComponents/GenericListLoadingSkeleton.tsx b/package/src/components/UIComponents/GenericListLoadingSkeleton.tsx index 32a251af85..4bfafde3c2 100644 --- a/package/src/components/UIComponents/GenericListLoadingSkeleton.tsx +++ b/package/src/components/UIComponents/GenericListLoadingSkeleton.tsx @@ -9,7 +9,7 @@ import { primitives } from '../../theme'; const ROW_COUNT = 4; -// `memberListSkeleton` and `userListSkeleton` share an identical shape. +// `memberListSkeleton` and `userListSkeleton` share this shape. export type ListLoadingSkeletonTheme = Theme['memberListSkeleton']; export type GenericListLoadingSkeletonProps = { diff --git a/package/src/components/UIComponents/ImageBackground.tsx b/package/src/components/UIComponents/ImageBackground.tsx index adf8c53381..1edf95f6df 100644 --- a/package/src/components/UIComponents/ImageBackground.tsx +++ b/package/src/components/UIComponents/ImageBackground.tsx @@ -3,32 +3,11 @@ import * as React from 'react'; import { Image, ImageBackgroundProps, ImageProps, StyleSheet, View } from 'react-native'; /** - * DISCLAIMER: This component has been pretty-much copied from react-native's source code + * Drop-in replacement for `Image` that supports nesting views, adapted from React Native's + * `ImageBackground`: * https://github.com/facebook/react-native/blob/main/Libraries/Image/ImageBackground.js - * Few modifications have been done such as converting to functional component, removing ref related logic (since its not required). - * Also support for prop `ImageComponent` has been introduced to allow rendering custom - * component instead of `Image`. - * - * - * Very simple drop-in replacement for which supports nesting views. - * - * ```ReactNativeWebPlayer - * import React, { Component } from 'react'; - * import { AppRegistry, View, ImageBackground, Text } from 'react-native'; - * - * class DisplayAnImageBackground extends Component { - * render() { - * return ( - * - * React - * - * ); - * } - * } * + * It is a function component without ref handling; an optional `ImageComponent` renders the image. */ export const ImageBackground: React.ComponentType< @@ -58,13 +37,8 @@ export const ImageBackground: React.ComponentType< style={[ StyleSheet.absoluteFill, { - // Temporary Workaround: - // Current (imperfect yet) implementation of overwrites width and height styles - // (which is not quite correct), and these styles conflict with explicitly set styles - // of and with our internal layout model here. - // So, we have to proxy/reapply these styles explicitly for actual component. - // This workaround should be removed after implementing proper support of - // intrinsic content size of the . + // `Image` overwrites width and height styles, which conflicts with the container's, so + // they are re-applied here. Can go once `Image` supports intrinsic content size. height: flattenedStyle?.height, width: flattenedStyle?.width, }, diff --git a/package/src/components/UIComponents/PortalWhileClosingView.tsx b/package/src/components/UIComponents/PortalWhileClosingView.tsx index 0da026fb72..e4f99f87d0 100644 --- a/package/src/components/UIComponents/PortalWhileClosingView.tsx +++ b/package/src/components/UIComponents/PortalWhileClosingView.tsx @@ -20,49 +20,25 @@ import { import type { ViewRef } from '../../types/react-native-compat'; type PortalWhileClosingViewProps = { - /** - * Content that should render in place normally and teleport to a closing portal host - * while the message overlay is closing. - */ + /** Content that renders in place and teleports to the closing portal host during close. */ children: ReactNode; - /** - * Name of the closing `PortalHost` in `MessageOverlayHostLayer`. - * This key is also used in the closing layout registry. - */ + /** Name of the closing `PortalHost` in `MessageOverlayHostLayer` and the layout registry key. */ portalHostName: string; - /** - * Stable portal instance name used by `react-native-teleport` to move this content - * between the in-place tree and the closing host. - */ + /** Stable `react-native-teleport` portal name that moves the content to the closing host. */ portalName: string; }; /** - * Keeps wrapped UI above the message overlay during close animation by teleporting it to a closing portal host. + * Keeps wrapped UI above the message overlay while it closes by teleporting it to a closing portal + * host, so the animated message does not pass over fixed UI such as the composer or header. * - * Why this is needed: + * Children render in place normally. The layout is measured with `measureInWindow` and registered + * under `portalHostName`; only entries with a valid measurement can take a host slot. While the + * overlay is `closing`, the children teleport to the matching host and a placeholder keeps their + * space in the layout. * - * When the overlay closes, the animated message can visually pass over fixed UI (for example composer/header). - * This wrapper moves that UI into the overlay host layer for the closing phase, so stacking stays correct. - * - * To use it, simply wrap any view that should remain on top while the overlay is closing, and pass a `portalHostName` - * and a `portalName`. Once the wrapped view has a valid measured layout, it can participate in the closing host layer. - * - * Behavior: - * - renders children in place during normal operation - * - registers absolute layout for `portalHostName` once a valid measurement exists - * - while overlay state is `closing`, teleports children to the matching closing host - * - renders a local placeholder while closing to preserve original layout space - * - * Stack presence only starts after first valid measurement. That prevents unmeasured entries from taking over a host - * slot and rendering with incomplete geometry. - * - * Note: As the `PortalWhileClosingView` relies heavily on being able to calculate the layout and positioning - * properties of its children automatically, make sure that you do not wrap absolutely positioned views with - * it as positioning parameters specifically will not be calculated correctly as the absolute position of - * the immediate child will be towards its immediate parent (which is our `Portal` view). Instead, wrap its - * children directly (the non-absolutely positioned ones). Since we use `measureInWindow` to get a hold of - * the initial measurements, we'll always have the correct position of the relevant content. + * Wrap non-absolutely positioned views only: an absolutely positioned child is positioned against + * the `Portal` view, so its measured position would be wrong. * * @param props.children content to render and teleport while closing * @param props.portalHostName closing host slot name used for layout registration and portal target @@ -143,7 +119,7 @@ const useSyncingApi = ( setClosingPortalLayout(portalHostName, registrationId, rect); }) .catch(() => { - // the container isn't measurable yet; the next layout/effect pass will retry + // Not measurable yet; the next layout or effect pass retries. }); }); diff --git a/package/src/components/UIComponents/SafeAreaViewWrapper.tsx b/package/src/components/UIComponents/SafeAreaViewWrapper.tsx index 99bf59c13f..823a7b44a8 100644 --- a/package/src/components/UIComponents/SafeAreaViewWrapper.tsx +++ b/package/src/components/UIComponents/SafeAreaViewWrapper.tsx @@ -7,15 +7,12 @@ import { } from 'react-native-safe-area-context'; /** - * `react-native-safe-area-context`'s `SafeAreaView`, keeping React Native's own as a defensive - * runtime fallback. + * `react-native-safe-area-context`'s `SafeAreaView`, falling back to React Native's own when it is + * missing at runtime. * - * The type is pinned to the safe-area-context component deliberately. A `A ?? B` expression types as - * the *union* of both components, which makes the resulting `style` prop their intersection - and - * from React Native 0.87 that intersection is unsatisfiable, because RN's own `SafeAreaView` and - * safe-area-context's now describe styles differently. That in turn breaks - * `Animated.createAnimatedComponent()` wrapping (see `ImageGalleryFooter`). The fallback is only - * ever hit when safe-area-context is missing at runtime, so pinning the type costs nothing. + * The type is pinned to the safe-area-context component: typing `A ?? B` as a union makes the + * `style` prop an unsatisfiable intersection on recent React Native versions, which breaks + * `Animated.createAnimatedComponent()` wrapping (see `ImageGalleryFooter`). */ export const SafeAreaView = (SafeAreaViewOriginal ?? RNFSafeAreaView) as typeof SafeAreaViewOriginal; diff --git a/package/src/components/UIComponents/SvgAwareImage.tsx b/package/src/components/UIComponents/SvgAwareImage.tsx index 105ed888f9..9c25d2bd30 100644 --- a/package/src/components/UIComponents/SvgAwareImage.tsx +++ b/package/src/components/UIComponents/SvgAwareImage.tsx @@ -13,11 +13,8 @@ const getSourceUri = (source: ImageProps['source']): string | undefined => { }; /** - * Default `ImageComponent` for the SDK. Behaves exactly like RN's `Image` for - * raster sources, but transparently renders SVG URIs (`.svg`, `image/svg+xml` - * data URIs) via `SvgUri` from `react-native-svg`. Integrators who override - * `ImageComponent` with a custom image library (e.g. FastImage) are - * responsible for SVG handling in their override. + * Default `ImageComponent`: RN's `Image` for raster sources, `SvgUri` from `react-native-svg` for + * SVG URIs (`.svg` or `image/svg+xml` data URIs). A custom `ImageComponent` must handle SVG itself. */ export const SvgAwareImage = (props: ImageProps) => { const uri = getSourceUri(props.source); diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 6fca050ae0..6c5fd974b7 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -35,7 +35,6 @@ export * from './Channel/hooks/useCreateInputMessageInputContext'; export * from './Channel/hooks/useCreateMessagesContext'; export * from './Channel/hooks/useCreateThreadContext'; -/** Channel List exports*/ export * from './ChannelList/ChannelList'; export * from './ChannelList/ChannelListFooterLoadingIndicator'; export * from './ChannelList/ChannelListHeaderErrorIndicator'; @@ -45,7 +44,6 @@ export * from './ChannelList/ChannelListView'; export * from './ChannelList/Skeleton'; export * from './ChannelList/hooks'; -/** Channel Preview exports */ export * from './ChannelPreview/ChannelDetailsBottomSheet'; export * from './ChannelPreview/ChannelLastMessagePreview'; export * from './ChannelPreview/ChannelMessagePreviewDeliveryStatus'; @@ -60,7 +58,6 @@ export * from './ChannelPreview/ChannelPreviewTypingIndicator'; export * from './ChannelPreview/ChannelPreviewUnreadCount'; export * from './ChannelPreview/hooks'; -/** Chat exports */ export * from './Chat/Chat'; export * from './Chat/hooks'; @@ -150,7 +147,7 @@ export * from './MessageInput/hooks/useCooldownRemaining'; export * from './MessageList/DateHeader'; export * from './MessageList/hooks/useMarkRead'; -// Only the constant: integrators running their own `jumpToMessage` need the TTL the SDK's highlight uses. +// Only the constant, for integrators running their own `jumpToMessage`. export { DEFAULT_HIGHLIGHT_DURATION } from './MessageList/hooks/useMessageListFocus'; export * from './MessageList/hooks/useMessageList'; export * from './MessageList/hooks/useTypingString'; diff --git a/package/src/components/ui/Avatar/Avatar.tsx b/package/src/components/ui/Avatar/Avatar.tsx index 980156f1c9..77fbdf8efc 100644 --- a/package/src/components/ui/Avatar/Avatar.tsx +++ b/package/src/components/ui/Avatar/Avatar.tsx @@ -10,18 +10,11 @@ import { primitives } from '../../../theme'; export type AvatarProps = { size: '2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs'; - /** - * Override for the auto-generated accessibility label. When omitted and a - * `name` is provided, the SDK uses `a11y/Avatar of {{name}}`. - */ + /** Overrides the generated accessibility label, which is built from `name` when it is set. */ accessibilityLabel?: string; backgroundColor?: ColorValue; imageUrl?: string; - /** - * Display name of the entity this avatar represents (user, channel). Used to - * compose the default `accessibilityLabel`. Optional — when neither this nor - * `accessibilityLabel` is provided, the avatar is left unlabeled. - */ + /** Display name of the user or channel for the label. Without it the avatar is unlabeled. */ name?: string; placeholder?: React.ReactNode; showBorder?: boolean; diff --git a/package/src/components/ui/Avatar/AvatarGroup.tsx b/package/src/components/ui/Avatar/AvatarGroup.tsx index d561cb4683..ccf27b5dc8 100644 --- a/package/src/components/ui/Avatar/AvatarGroup.tsx +++ b/package/src/components/ui/Avatar/AvatarGroup.tsx @@ -14,17 +14,13 @@ import { useTheme } from '../../../contexts/themeContext/ThemeContext'; import { BadgeCount, BadgeCountProps, OnlineIndicator, OnlineIndicatorProps } from '../Badge'; export type AvatarGroupProps = { - /** - * The size of the avatar group. - */ + /** Size of the avatar group. */ size: 'lg' | 'xl' | '2xl'; - /** - * The items to display in the avatar group. - */ + /** Avatars to display. */ items: React.ReactNode[]; }; -// Sizes accounts for the border width as well +// Sizes include the border width. const sizes = { '2xl': { width: 80, @@ -154,13 +150,9 @@ export const AvatarGroup = (props: AvatarGroupProps) => { }; export type UserAvatarGroupProps = Pick & { - /** - * The users to display in the avatar group. - */ + /** Users to display. */ users: UserResponse[]; - /** - * Whether to show the online indicator. - */ + /** Whether to show the online indicator. */ showOnlineIndicator?: boolean; }; diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index ef2579923a..cd59b9bcaf 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -26,16 +26,9 @@ const pickAvatarFields = (member: ChannelMemberResponse) => ({ export type ChannelAvatarProps = { channel: Channel; - /** - * When true, the avatar renders based on `previewUri` instead of the - * channel's stored image. Useful for previewing a pending image change before - * it is saved. Defaults to false. - */ + /** Renders `previewUri` in place of the channel's stored image. Defaults to false. */ isPreview?: boolean; - /** - * Image to display while in preview mode (`isPreview` is true). A `string` - * shows that image; `null` shows the no-image fallback (member/user avatar). - */ + /** Image shown when `isPreview` is true. `null` shows the fallback member or user avatar. */ previewUri?: string | null; showOnlineIndicator?: boolean; size?: 'lg' | 'xl' | '2xl'; diff --git a/package/src/components/ui/Avatar/constants.ts b/package/src/components/ui/Avatar/constants.ts index 61ececcf2b..72e9d78cc6 100644 --- a/package/src/components/ui/Avatar/constants.ts +++ b/package/src/components/ui/Avatar/constants.ts @@ -67,8 +67,8 @@ const onlineIndicatorSizes: Record< }, }; -// Anchors the presence dot on the avatar's circular edge at 45°: -// offset = avatarWidth / 2 * (1 - Math.SQRT1_2) - indicatorDiameter / 2 (rounded to px) +// Centers the presence dot on the avatar's circular edge at 45 degrees: +// offset = avatarWidth / 2 * (1 - Math.SQRT1_2) - indicatorDiameter / 2, rounded to px. const indicatorPositions = (Object.keys(avatarSizes) as UserAvatarProps['size'][]).reduce( (acc, size) => { const avatarDiameter = avatarSizes[size].width; diff --git a/package/src/components/ui/Badge/BadgeNotification.tsx b/package/src/components/ui/Badge/BadgeNotification.tsx index 3c08dc099a..2ecb955b92 100644 --- a/package/src/components/ui/Badge/BadgeNotification.tsx +++ b/package/src/components/ui/Badge/BadgeNotification.tsx @@ -9,10 +9,7 @@ export type BadgeNotificationProps = { count: number; size: 'sm' | 'xs'; testID?: string; - /** - * Optional accessibility label override. When provided, screen readers - * announce this string instead of the bare count. - */ + /** Accessibility label announced in place of the bare count. */ accessibilityLabel?: string; }; diff --git a/package/src/components/ui/Button/Button.tsx b/package/src/components/ui/Button/Button.tsx index ce024ef4b9..146f3ce5b5 100644 --- a/package/src/components/ui/Button/Button.tsx +++ b/package/src/components/ui/Button/Button.tsx @@ -36,51 +36,28 @@ const getButtonAccessibilityState = ({ }; export type ButtonProps = PressableProps & { - /** - * Translation key used for the button's accessibility label when SDK - * accessibility is enabled. Prefer this for SDK-owned icon-only buttons. - */ + /** Translation key of the accessibility label, resolved only with SDK accessibility enabled. */ accessibilityLabelKey?: string; - /** - * Optional interpolation params for `accessibilityLabelKey`. - */ + /** Interpolation params for `accessibilityLabelKey`. */ accessibilityLabelParams?: Record; - /** - * The style of the button. - */ + /** Color variant of the button. */ variant: 'primary' | 'secondary' | 'destructive'; - /** - * The type of the button. - */ + /** Fill style of the button. */ type: 'solid' | 'outline' | 'ghost' | 'liquidGlass'; - /** - * The size of the button. - */ + /** Size of the button. Defaults to `'md'`. */ size?: 'sm' | 'md' | 'lg'; - /** - * Whether the button is selected. - */ + /** Whether the button is selected. Defaults to false. */ selected?: boolean; - /** - * The icon to display on the leading side of the button. - */ + /** Icon on the leading side. Swaps with `TrailingIcon` in RTL. */ LeadingIcon?: IconRenderer; - /** - * The content to display on the center of the button. - */ + /** Content in the center. Strings are wrapped in a styled `Text`. */ label?: React.ReactNode; - /** - * The icon to display on the trailing side of the button. - */ + /** Icon on the trailing side. Swaps with `LeadingIcon` in RTL. */ TrailingIcon?: IconRenderer; - /** - * Whether the button is only an icon. - */ + /** Renders only an icon (`LeadingIcon`, else `TrailingIcon`). Defaults to false. */ iconOnly?: boolean; - /** - * The style of the button. - */ + /** Style of the button's outer container. */ style?: StyleProp; }; diff --git a/package/src/components/ui/Button/hooks/useButtonStyles.ts b/package/src/components/ui/Button/hooks/useButtonStyles.ts index 9053dfb063..8716147d27 100644 --- a/package/src/components/ui/Button/hooks/useButtonStyles.ts +++ b/package/src/components/ui/Button/hooks/useButtonStyles.ts @@ -27,10 +27,11 @@ export type ButtonStyle = { export type ButtonStylesConfig = Pick; /** - * Returns the styles for the button based on the button style and type. - * @param buttonStyle - The style of the button. - * @param type - The type of the button. - * @returns The styles for the button. + * Returns the colors for a button variant and type. + * + * @param params.variant The button variant. + * @param params.type The button type. + * @returns The button style for that combination. */ export const useButtonStyles = ({ variant, type }: ButtonStylesConfig) => { const { diff --git a/package/src/components/ui/GiphyChip.tsx b/package/src/components/ui/GiphyChip.tsx index 63eae2cd0c..3f410a5173 100644 --- a/package/src/components/ui/GiphyChip.tsx +++ b/package/src/components/ui/GiphyChip.tsx @@ -27,7 +27,6 @@ export const GiphyChip = () => { const onPressHandler = () => { textComposer.clearCommand(); - // messageComposer?.restore(); }; return ( diff --git a/package/src/components/ui/Input/Input.tsx b/package/src/components/ui/Input/Input.tsx index 80a60d8a2b..64b6a171f2 100644 --- a/package/src/components/ui/Input/Input.tsx +++ b/package/src/components/ui/Input/Input.tsx @@ -52,8 +52,8 @@ export type InputProps = TextInputProps & { containerStyle?: StyleProp; }; -// Explicitly annotated: the inferred type of a `forwardRef` component inlines React Native's -// internal style/instance helper types, which cannot be named in the emitted declarations (TS2883). +// Annotated because the inferred `forwardRef` type inlines React Native internal types that the +// emitted declarations cannot name (TS2883). export const Input: React.ForwardRefExoticComponent< InputProps & React.RefAttributes > = React.forwardRef(function Input( diff --git a/package/src/components/ui/SpeedSettingsButton.tsx b/package/src/components/ui/SpeedSettingsButton.tsx index 3a9170465f..c09b1e3fa9 100644 --- a/package/src/components/ui/SpeedSettingsButton.tsx +++ b/package/src/components/ui/SpeedSettingsButton.tsx @@ -6,17 +6,11 @@ import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; export type SpeedSettingsButtonProps = { - /** - * The current playback rate. - */ + /** Current playback rate. */ currentPlaybackRate: number; - /** - * The function to be called when the button is pressed. - */ + /** Called when the button is pressed. */ onPress: () => void; - /** - * The style of the container. - */ + /** Style of the container. */ containerStyle?: StyleProp; }; diff --git a/package/src/contexts/accessibilityContext/AccessibilityContext.tsx b/package/src/contexts/accessibilityContext/AccessibilityContext.tsx index 27281abdc8..c9926b6478 100644 --- a/package/src/contexts/accessibilityContext/AccessibilityContext.tsx +++ b/package/src/contexts/accessibilityContext/AccessibilityContext.tsx @@ -26,31 +26,27 @@ type TimeoutByPriority = { /** Tri-state for gesture-alternative toggles. */ export type A11yMode = 'auto' | 'always' | 'never'; -/** - * Resolved predicate stored on the context - consumers call `(message)` and - * receive the boolean directly. - */ +/** Resolved predicate stored on the context: consumers call it with a message. */ export type HasInteractiveAccessibilityContent = (message: LocalMessage) => boolean; /** - * Integrator facing override shape. Receives the SDK's baseline boolean as the - * second argument so overrides can extend rather than replace: + * Integrator override. Receives the SDK's baseline boolean as the second argument, so an override + * can extend it: * * ``` * hasInteractiveAccessibilityContent: (message, defaultValue) => * defaultValue || !!message.my_custom_field * ``` * - * To replace fully, ignore the second argument. Must be stable across renders - * an unstable function will rerender every Message context consumer on every - * downstream render. + * To replace the baseline, ignore the second argument. Must be stable across renders: an unstable + * function re-renders every message context consumer. */ export type HasInteractiveAccessibilityContentConfig = ( message: LocalMessage, defaultValue: boolean, ) => boolean; -/** SDK baseline — true when the message renders interactive children. */ +/** SDK baseline: true when the message renders interactive children. */ const defaultHasInteractiveAccessibilityContent: HasInteractiveAccessibilityContent = (message) => !!( message.poll_id || @@ -61,36 +57,36 @@ const defaultHasInteractiveAccessibilityContent: HasInteractiveAccessibilityCont export type AccessibilityConfig = { /** - * Master toggle. Default FALSE — integrators must opt in. When false, the SDK - * behaves exactly as it does today; no a11y attributes are added, no announcer - * mounts, no listeners attached. + * Master toggle. Defaults to false, so integrators opt in. When false, no accessibility + * attributes are added, no announcer mounts and no listeners attach. */ enabled?: boolean; - /** For testing — force "screen reader on" UI even when no SR is active. */ + /** For testing: forces the screen reader UI even when no screen reader is active. */ forceScreenReaderMode?: boolean; - /** Announce new messages via the announcer. Default true (when enabled). */ + /** Announces new messages. Defaults to true. */ announceNewMessages?: boolean; - /** Announce typing indicator. Default false (noisy on mobile). */ + /** Announces the typing indicator. Defaults to false, as it is noisy on mobile. */ announceTypingIndicator?: boolean; - /** Announce connection state (offline/online). Default true. */ + /** Announces connection state changes. Defaults to true. */ announceConnectionState?: boolean; - /** Audio recorder gesture-alternative. 'auto' (default), 'always', 'never'. */ + /** Gesture alternative for the audio recorder. Defaults to `'auto'`. */ audioRecorderTapMode?: A11yMode; - /** Image gallery gesture-alternative. 'auto' (default), 'always', 'never'. */ + /** Gesture alternative for the image gallery. Defaults to `'auto'`. */ imageGalleryScreenReaderMode?: A11yMode; - /** Message actions trigger. 'long-press' (no alt button), 'auto' (default — show button when SR is on), 'always-button'. */ + /** + * How message actions open: `'long-press'` (no extra button), `'always-button'`, or `'auto'` + * (a button while a screen reader is on). Defaults to `'auto'`. + */ messageActionsTrigger?: 'long-press' | 'auto' | 'always-button'; /** - * Override the SDK's "this message has interactive children" decision. - * Will control what it means for a message to have interactive content. - * This affects the way we mark the message bubble as accessible and whether - * we let the children handle it on their own or whether we want the bubble to - * handle everything. See {@link HasInteractiveAccessibilityContentConfig}. + * Overrides whether a message counts as having interactive children, which decides if the + * children or the bubble handle accessibility. + * See {@link HasInteractiveAccessibilityContentConfig}. */ hasInteractiveAccessibilityContent?: HasInteractiveAccessibilityContentConfig; }; -/** Fully-resolved config — every field is populated with its default. */ +/** Fully resolved config: every field is populated with its default. */ export type ResolvedAccessibilityConfig = Required< Omit > & { diff --git a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx index a2def88b59..3524ff13e5 100644 --- a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx +++ b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx @@ -21,11 +21,8 @@ export type AttachmentPickerContextValue = Pick< 'attachmentSelectionBarHeight' | 'attachmentPickerBottomSheetHeight' > & { /** - * `bottomInset` determine the height of the `AttachmentPicker` and the underlying shift to the `MessageList` when it is opened. - * This can be set via the `bottomInset` prop on the `Channel` component. - * - * Please check [OverlayProvider](https://github.com/GetStream/stream-chat-react-native/wiki/Cookbook-v3.0#overlayprovider) section in Cookbook - * for more details. + * Determines the height of the `AttachmentPicker` and how far the `MessageList` shifts when it + * opens. Set via the `bottomInset` prop on `Channel`. */ bottomInset: number; bottomSheetRef: React.RefObject; @@ -34,17 +31,15 @@ export type AttachmentPickerContextValue = Pick< topInset: number; /** - * When `true`, the native image picker is used instead of the in-app image gallery. - * The attachment picker itself (with its attachment type selection bar) is still rendered. - * To not render the attachment picker at all, set `shouldRenderAttachmentPicker` to `false`. + * Uses the native image picker in place of the in-app image gallery. The attachment picker and + * its selection bar still render; set `shouldRenderAttachmentPicker` to false to hide them. */ disableAttachmentPicker?: boolean; /** - * Whether the attachment picker should be rendered at all. Defaults to `true`. + * Whether the attachment picker renders at all. Defaults to true. * - * Can be set to `false` if attachment picker is not used. `openAttachmentPicker` then - * does nothing. The attach button is still shown, so it needs to either be hidden it or set - * `handleAttachButtonPress` to override what it does. + * When false, `openAttachmentPicker` does nothing and the attach button is still shown, so hide + * it or override its action with `handleAttachButtonPress`. */ shouldRenderAttachmentPicker?: boolean; attachmentPickerStore: AttachmentPickerStore; diff --git a/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx b/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx index 5bd7f1a0bb..75d35c563f 100644 --- a/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx +++ b/package/src/contexts/bottomSheetContext/BottomSheetContext.tsx @@ -7,29 +7,18 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type BottomSheetContextValue = { /** - * Callback that will safely close the modal while preserving animation - * timing and respecting actions finishing before the close is invoked. + * Closes the modal with its animation. * - * @param closeAnimationFinishedCallback {function} - a callback that is - * going to be invoked when the closing animation finishes (but not necessarily - * when the modal is actually dismissed) - * @returns void + * @param closeAnimationFinishedCallback Runs when the closing animation finishes, not + * necessarily when the modal is dismissed. */ close: (closeAnimationFinishedCallback?: () => void) => void; /** - * A callback that will safely dismiss the modal, while preserving animation timing - * and respecting actions finishing before the close is invoked. Very similar to `close`, - * however it takes an extra step to make sure that the modal is actually dismissed - * before its callback is invoked. This is mostly useful for iOS, where for certain - * libraries (i.e `react-native-image-picker`) you aren't able to open both a RN modal - * and its internal `UIImagePickerController` at the same time and you have to wait - * for `onDismiss` to be fired internally. + * Like `close`, but the callback runs only once the modal is actually dismissed. Use it on iOS to + * present another native modal (such as `react-native-image-picker`) from the callback. It + * behaves like `close` on Android. * - * It will work exactly the same as `close` for Android as this is not an issue there. - * - * @param closeAnimationFinishedCallback {function} - a callback that is - * going to be invoked when the dismissal of the modal finishes - * @returns void + * @param dismissFinishedCallback Runs when the modal is dismissed. */ dismiss: (dismissFinishedCallback?: () => void) => void; currentSnapIndex: SharedValue; diff --git a/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx b/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx index 10e0e3a92b..9d40de3684 100644 --- a/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx +++ b/package/src/contexts/channelAddMembersContext/ChannelAddMembersContext.tsx @@ -14,23 +14,15 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type ChannelAddMembersContextValue = { selectionStore: SelectionStore; searchSource: UserSearchSource; - /** - * Adds the currently selected members to the channel. Resolves on success and - * rejects if the request fails. - */ + /** Adds the selected members to the channel. Rejects if the request fails. */ submit: () => Promise; }; -/** - * Props for the {@link ChannelAddMembersProvider} that seed the add-members - * configuration into the context. - */ +/** Props of the {@link ChannelAddMembersProvider}. */ export type ChannelAddMembersProviderProps = PropsWithChildren<{ /** - * A custom `UserSearchSource` used to query and paginate the users to add. - * Overrides the source the provider creates by default (pre-configured to - * autocomplete by `name`). The provider uses it as-is, so the consumer owns - * its activation/sorting. + * Custom `UserSearchSource` (the default autocompletes by `name`) that queries and paginates the + * users to add. It is used as-is, so the consumer owns its activation and sorting. */ searchSource?: UserSearchSource; }>; @@ -41,8 +33,8 @@ export const ChannelAddMembersContext = React.createContext( /** * Builds the {@link ChannelAddMembersContextValue}. Rendered inside the - * {@link NotificationTargetProvider} so that notifications emitted by `submit` - * (via {@link useChannelActions}) resolve to the add-members host. + * {@link NotificationTargetProvider} so notifications from `submit` resolve to the add-members + * host. */ const ChannelAddMembersContextProviderInner = ({ children, @@ -53,7 +45,6 @@ const ChannelAddMembersContextProviderInner = ({ const { addMembers } = useChannelActions(channel); const [selectionStore] = useState(() => new SelectionStore()); const [searchSource] = useState(() => { - // A custom source is used as-is; the consumer owns its activation/sorting. if (searchSourceProp) { return searchSourceProp; } diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index d927744876..9c0d12875d 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -8,58 +8,30 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type ChannelContextValue = { /** - * Instance of channel object from stream-chat package. - * - * Please check the docs around how to create or query channel - https://getstream.io/chat/docs/javascript/creating_channels/?language=javascript - * - * ``` - * import { StreamChat, Channel } from 'stream-chat'; - * import { Chat, Channel} from 'stream-chat-react-native'; - * - * const client = StreamChat.getInstance('api_key'); - * await client.connectUser('user_id', 'user_token'); - * const channel = client.channel('messaging', 'channel_id'); - * await channel.watch(); - * - * - * - * - * - * ``` + * The `stream-chat` channel instance. * * @overrideType Channel */ channel: Channel; - /** - * When set to true, reactions will be limited to 1 per user. If user selects another reaction - * then his previous reaction will be removed and replaced with new one. - * - * This is similar to reaction UX on [iMessage application](https://en.wikipedia.org/wiki/IMessage). - */ + /** Limits users to one reaction; picking another replaces the previous. Defaults to false. */ enforceUniqueReaction: boolean; /** - * Render the inline date separator above a system message when that message is the first of - * its day. Off by default: without it a system message on a day boundary consumes the boundary - * and renders nothing, so that day gets no separator at all. + * Renders the inline date separator above a system message that is the first of its day. + * Defaults to false: a system message on a day boundary then renders no separator for that day. */ allowDateSeparatorForSystemMessages: boolean; - /** - * Hide inline date separators on channel - */ + /** Hides the inline date separators. Defaults to false. */ hideDateSeparators: boolean; hideStickyDateHeader: boolean; /** - * Returns true when Channel is about to load an initial targeted message. + * Returns true when `Channel` is about to load an initial targeted message. * * @internal */ hasPendingInitialTargetLoad?: () => boolean; disabled?: boolean; enableMessageGroupingByUser?: boolean; - /** - * Maximum time in milliseconds that should occur between messages - * to still consider them grouped together - */ + /** Maximum time in milliseconds between messages that are still grouped together. */ maxTimeBetweenGroupedMessages?: number; }; diff --git a/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx b/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx index 502b14219b..55728fc1b5 100644 --- a/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx +++ b/package/src/contexts/channelDetailsContext/channelDetailsContext.tsx @@ -8,9 +8,7 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type ChannelDetailsContextValue = { channel: Channel; - /** - * Signals all ChannelDetails modals to close themselves. - */ + /** Signals all channel details modals to close. */ closeModals: () => void; signalStore: SignalStore; }; diff --git a/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx b/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx index d7490f9ef3..6696ffb85c 100644 --- a/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx +++ b/package/src/contexts/channelEditDetailsContext/ChannelEditDetailsContext.tsx @@ -15,31 +15,19 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type ChannelEditDetailsContextValue = { store: EditChannelDetailsStore; - /** - * Saves the edited channel details (name and/or image). Resolves once every - * update succeeds and rejects if any of them fail. - */ + /** Saves the edited name and/or image. Rejects with an `AggregateError` if any update fails. */ submit: () => Promise; - /** - * Compress image with quality (from 0 to 1, where 1 is best quality) applied - * to the channel image picked during editing. - */ + /** Compression quality from 0 to 1 (1 is best) for the channel image picked while editing. */ compressImageQuality?: number; - /** Override the upload request used to upload the channel image. */ + /** Overrides the request that uploads the channel image. */ doFileUploadRequest?: GlobalFileUploadRequest; }; -/** - * Props for the {@link ChannelEditDetailsProvider} that seed the edit flow - * configuration into the context. - */ +/** Props of the {@link ChannelEditDetailsProvider}. */ export type ChannelEditDetailsProviderProps = PropsWithChildren<{ - /** - * Compress image with quality (from 0 to 1, where 1 is best quality) applied - * to the channel image picked during editing. - */ + /** Compression quality from 0 to 1 (1 is best) for the channel image picked while editing. */ compressImageQuality?: number; - /** Override the upload request used to upload the channel image. */ + /** Overrides the request that uploads the channel image. */ doFileUploadRequest?: GlobalFileUploadRequest; }>; @@ -49,8 +37,8 @@ export const ChannelEditDetailsContext = React.createContext( /** * Builds the {@link ChannelEditDetailsContextValue}. Rendered inside the - * {@link NotificationTargetProvider} so that notifications emitted by `submit` - * (via {@link useChannelActions}) resolve to the channel edit details host. + * {@link NotificationTargetProvider} so notifications from `submit` resolve to the edit details + * host. */ const ChannelEditDetailsContextProviderInner = ({ children, @@ -97,10 +85,9 @@ const ChannelEditDetailsContextProviderInner = ({ }; /** - * Creates and provides an {@link EditChannelDetailsStore} snapshotted from the - * channel in the {@link ChannelDetailsContext}. Mount this once per edit session — the store captures the - * channel's name/image at construction and does not track later WebSocket - * updates, so an inbound `channel.updated` does not clobber in-progress edits. + * Provides an {@link EditChannelDetailsStore} snapshotted from the channel in the + * {@link ChannelDetailsContext}. Mount it once per edit session: the store does not track later + * `channel.updated` events, so they cannot overwrite in-progress edits. */ export const ChannelEditDetailsProvider = ({ children, diff --git a/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx b/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx index 56c5c9148b..29dd536137 100644 --- a/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx +++ b/package/src/contexts/channelFileAttachmentListContext/ChannelFileAttachmentListContext.tsx @@ -30,7 +30,7 @@ export const ChannelFileAttachmentListProvider = ({ source.messageSearchFilters = { $or: [{ 'attachments.type': 'file' }, { 'attachments.type': 'audio' }], }; - // Newest first so the list groups cleanly under month section headers. + // Newest first, so the list groups under month section headers. source.messageSearchSort = [{ field: 'created_at', direction: -1 }]; source.activate(); return source; diff --git a/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx b/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx index 78c5ba31de..94a76722f3 100644 --- a/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx +++ b/package/src/contexts/channelMediaListContext/ChannelMediaListContext.tsx @@ -31,7 +31,7 @@ export const ChannelMediaListProvider = ({ 'attachments.type': { $in: ['image', 'video'] }, type: undefined, }; - // Newest media first so the grid reads top-to-bottom from most recent. + // Newest first. source.messageSearchSort = [{ field: 'created_at', direction: -1 }]; source.activate(); return source; diff --git a/package/src/contexts/channelsContext/ChannelsContext.tsx b/package/src/contexts/channelsContext/ChannelsContext.tsx index ea729dd77d..fa7c457758 100644 --- a/package/src/contexts/channelsContext/ChannelsContext.tsx +++ b/package/src/contexts/channelsContext/ChannelsContext.tsx @@ -12,39 +12,22 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type ChannelsContextValue = { /** - * Besides the existing default behavior of the ChannelListView component, you can attach - * additional props to the underlying React Native FlatList. - * - * You can find list of all the available FlatList props here - https://facebook.github.io/react-native/docs/flatlist#props - * - * **EXAMPLE:** + * Extra props for the underlying FlatList. Use `setFlatListRef` rather than these to get the ref. * * ``` - * + * * ``` - * - * **Note:** Don't use `additionalFlatListProps` to access the FlatList ref, use `setFlatListRef` */ additionalFlatListProps: Partial>; /** - * The React Native FlatList threshold to fetch more data - * @see See loadMoreThreshold [doc](https://facebook.github.io/react-native/docs/flatlist#onendreachedthreshold) - * */ - loadMoreThreshold: number; - /** - * Loads the next page of `channels`, which is present as a required prop + * The FlatList `onEndReachedThreshold`, in multiples of the visible list length. Defaults to 0.1. */ + loadMoreThreshold: number; + /** Loads the next page of channels. */ loadNextPage: () => Promise; - /** - * Max number to display within notification badge. Default: 255 and it cannot be higher than that for now due to backend limitations - */ + /** Highest unread count shown in the badge, which caps larger counts. Defaults to 255. */ maxUnreadCount: number; - /** - * Number of skeletons that should show when loading. Default: 8 - */ + /** Number of skeleton rows shown while loading. Defaults to 8. */ numberOfSkeletons: number; /** * The list's `ChannelPaginator`. Read the loaded channels and the query state from @@ -52,31 +35,24 @@ export type ChannelsContextValue = { */ paginator: ChannelPaginator; /** - * Function to refresh the channel list that is similar to `reloadList`, but it doesn't wipe out existing channels - * from UI before loading the new ones + * Re-queries the first page while keeping the current channels on screen, unlike `reloadList`. + * Ignored within 5 seconds of the previous refresh unless the list is in an error state. */ refreshList: () => void | Promise; - /** - * Removes all the existing channels from UI and loads fresh channels - * */ + /** Clears the channels from the UI and loads the first page again. */ reloadList: () => Promise; /** - * Function to gain access to the inner FlatList ref - * - * **Example:** + * Receives the inner FlatList ref, and `null` on unmount. * * ``` - * { - * // Use ref for your own good - * }} + * { listRef.current = ref; }} /> * ``` */ setFlatListRef: (ref: FlatList | null) => void; /** - * Function to set the currently active channel, acts as a bridge between ChannelList and Channel components + * Called when a channel is tapped. Bridges `ChannelList` and `Channel`. * - * @param channel A channel object + * @param channel The tapped channel. */ onSelect?: (channel: Channel) => void; getChannelActionItems?: GetChannelActionItems; diff --git a/package/src/contexts/chatConfigContext/ChatConfigContext.tsx b/package/src/contexts/chatConfigContext/ChatConfigContext.tsx index 5d720e74b6..e79f14f84c 100644 --- a/package/src/contexts/chatConfigContext/ChatConfigContext.tsx +++ b/package/src/contexts/chatConfigContext/ChatConfigContext.tsx @@ -1,9 +1,7 @@ import React, { PropsWithChildren, useContext } from 'react'; export type ChatConfigContextValue = { - /** - * This option allows you to specify a list of CDNs that offer image resizing. - */ + /** CDN hosts whose image URLs can be resized. Defaults to `['.stream-io-cdn.com']`. */ resizableCDNHosts?: string[]; }; diff --git a/package/src/contexts/chatContext/ChatContext.tsx b/package/src/contexts/chatContext/ChatContext.tsx index 1b5c97c777..faf82e9f6b 100644 --- a/package/src/contexts/chatContext/ChatContext.tsx +++ b/package/src/contexts/chatContext/ChatContext.tsx @@ -14,13 +14,13 @@ export type ChatContextValue = { */ getAppSettings: () => Promise; /** - * The StreamChat client object + * The `StreamChat` client. * * ``` * import { StreamChat } from 'stream-chat'; * import { Chat } from 'stream-chat-react-native'; * - * const client = StreamChat.getInstance('api_key); + * const client = StreamChat.getInstance('api_key'); * await client.connectUser('user_id', 'userToken'); * * diff --git a/package/src/contexts/componentsContext/ComponentsContext.tsx b/package/src/contexts/componentsContext/ComponentsContext.tsx index 9b1e69be13..7bdbe85ef5 100644 --- a/package/src/contexts/componentsContext/ComponentsContext.tsx +++ b/package/src/contexts/componentsContext/ComponentsContext.tsx @@ -1,15 +1,10 @@ import React, { PropsWithChildren, useContext, useMemo } from 'react'; /** - * All overridable UI components in the SDK. - * Derived from the DEFAULT_COMPONENTS map in defaultComponents.ts. - * Adding a new default automatically makes it available as an override. + * The overridable UI components, derived from `DEFAULT_COMPONENTS` in `defaultComponents.ts`, so a + * new default is overridable automatically. Every key is optional. * - * Every key is optional — only specify the components you want to override. - * - * The `icons` slot is a nested map: it is typed as a *partial* icon map so - * integrators can override individual icons without having to supply them all. - * Sibling defaults are preserved via a deep merge (see below). + * `icons` is a partial map: overriding one icon keeps the other defaults. */ export type ComponentOverrides = Omit< Partial<(typeof import('./defaultComponents'))['DEFAULT_COMPONENTS']>, @@ -20,9 +15,8 @@ export type ComponentOverrides = Omit< }; /** - * Resolved component overrides with defaults filled in. Unlike - * `ComponentOverrides`, the `icons` map is guaranteed fully populated (defaults - * back every key), so consumers can read `icons.Mute` without a null check. + * The component overrides merged over the defaults. Unlike `ComponentOverrides`, every key is + * defined, including each entry of `icons`. */ export type ResolvedComponents = Required> & { icons: import('./defaultComponents').IconsMap; @@ -31,8 +25,9 @@ export type ResolvedComponents = Required> & { const ComponentsContext = React.createContext({}); /** - * Provider to override UI components at any level of the tree. - * Supports nesting — inner overrides merge over outer ones (closest wins). + * Overrides UI components for the tree below it. Nested providers merge, and the closest wins. + * + * `overrides` is read once at mount and must be stable. * * @example * ```tsx @@ -53,8 +48,7 @@ export const WithComponents = ({ () => ({ ...parent, ...overrides, - // Deep-merge the nested `icons` map so overriding one icon keeps the - // rest (a shallow spread would replace the whole map). + // `icons` is merged one level deeper so overriding one icon keeps the rest. icons: { ...parent.icons, ...overrides.icons }, }), // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally stable: overrides are set once at mount @@ -63,8 +57,8 @@ export const WithComponents = ({ return {children}; }; -// Lazy-loaded to break circular dependency: -// defaultComponents.ts → imports components → components import useComponentsContext from this file +// Required lazily to break a circular import: `defaultComponents` imports components, which import +// `useComponentsContext` from this file. let cachedDefaults: ComponentOverrides | undefined; const getDefaults = (): ComponentOverrides => { if (!cachedDefaults) { @@ -75,8 +69,7 @@ const getDefaults = (): ComponentOverrides => { }; /** - * Hook to access resolved component overrides. - * Returns all components with defaults filled in — user overrides merged over defaults. + * Returns every overridable component, with the user's overrides merged over the defaults. */ export const useComponentsContext = () => { const overrides = useContext(ComponentsContext); @@ -85,7 +78,7 @@ export const useComponentsContext = () => { return { ...defaults, ...overrides, - // Deep-merge icons so user overrides layer over the full default map. + // `icons` is merged one level deeper so overriding one icon keeps the rest. icons: { ...defaults.icons, ...overrides.icons }, } as ResolvedComponents; // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally stable: overrides are set once at mount diff --git a/package/src/contexts/componentsContext/defaultComponents.ts b/package/src/contexts/componentsContext/defaultComponents.ts index 0c2efaf9c2..3850ba1f5c 100644 --- a/package/src/contexts/componentsContext/defaultComponents.ts +++ b/package/src/contexts/componentsContext/defaultComponents.ts @@ -270,22 +270,14 @@ import { XCircle } from '../../icons/x-circle'; import { NewClose } from '../../icons/xmark'; import { Cross } from '../../icons/xmark-1'; -/** - * Normalizes each component entry to React.ComponentType

, stripping - * extra inferred properties (like `displayName: string` from runtime - * assignments) that would otherwise leak into the override types and - * force integrators to match them. - */ +/** Reduces each component to `React.ComponentType

`, so inferred extras do not leak out. */ type NormalizeComponents = { [K in keyof T]: T[K] extends React.ComponentType ? React.ComponentType

: T[K]; }; /** - * All overridable icon components in the SDK, keyed by their exported name. - * Exposed via `WithComponents` under the nested `icons` key so integrators can - * swap any icon: ``. - * - * Only icons that are actually rendered by the SDK are registered here. + * The overridable icons, keyed by exported name. Overridden through the nested `icons` key: + * ``. */ export const defaultIcons = { ArrowBoxLeft, @@ -382,10 +374,7 @@ export const defaultIcons = { ZIP, } satisfies Record>; -/** - * Map of all overridable SDK icons. Used to type the `icons` override slot - * and the resolved value returned by `useComponentsContext().icons`. - */ +/** The type of `defaultIcons`, which types the `icons` override slot. */ export type IconsMap = typeof defaultIcons; const components = { @@ -514,10 +503,8 @@ const components = { VideoAttachmentUploadPreview, VideoThumbnail, - // Channel details ChannelDetailsHeader, - // Channel Details Screen ChannelAddMembersButton, ChannelAddMembersForm, ChannelAddMembersFormContent, @@ -547,7 +534,6 @@ const components = { PinnedMessageItem, PinnedMessageList, - // Thread ThreadListComponent, ThreadListEmptyPlaceholder: DefaultThreadListEmptyPlaceholder, ThreadListItem, @@ -557,7 +543,6 @@ const components = { ThreadListUnreadBanner, ThreadMessagePreviewDeliveryStatus, - // Poll PollButtons, PollHeader, PollAllOptionsContent, @@ -565,23 +550,17 @@ const components = { PollResultsContent, PollOptionFullResultsContent, - // ImageGallery ImageGalleryFooter, ImageGalleryGrid, ImageGalleryHeader, ImageGalleryVideoControls: ImageGalleryVideoControl, - // Overlay MessageOverlayBackground: DefaultMessageOverlayBackground, - // Image ImageComponent: SvgAwareImage, }; -/** - * Optional component slots that have no default implementation. - * These are `undefined` unless the integrator provides them via WithComponents. - */ +/** Component slots with no default, `undefined` unless provided via `WithComponents`. */ export interface OptionalComponentOverrides { AttachmentPickerIOSSelectMorePhotos?: React.ComponentType; ChatLoadingIndicator?: React.ComponentType | null; @@ -602,11 +581,5 @@ export interface OptionalComponentOverrides { PollContent?: React.ComponentType; } -/** - * All default component implementations used across the SDK. - * These are the components used when no overrides are provided via WithComponents. - * - * The `NormalizeComponents` cast ensures that internal details like - * `displayName: string` don't leak into the public override types. - */ +/** The default component for every overridable slot, typed through `NormalizeComponents`. */ export const DEFAULT_COMPONENTS: NormalizeComponents = components; diff --git a/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx b/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx index 7544595992..0a69c8fa4d 100644 --- a/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx +++ b/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx @@ -33,7 +33,6 @@ export const LiveLocationManagerProvider = ( if (!client) { return null; } - // Create a new instance of LiveLocationManager with the client and utility functions return new LiveLocationManager({ client, getDeviceId: getDeviceId ?? (() => `react-native-${Platform.OS}-${client.userID}`), @@ -45,7 +44,6 @@ export const LiveLocationManagerProvider = ( if (!liveLocationManager) { return; } - // Initialize the live location manager liveLocationManager .init() .catch((error) => console.warn('Initializing the live location manager failed:', error)); diff --git a/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts b/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts index 39bc98aa3c..e15b032889 100644 --- a/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts +++ b/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts @@ -7,23 +7,22 @@ import { nowNs } from 'stream-chat'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; export type UseLiveLocationsEventsParams = { - /** - * The channel where the live location is shared. - */ + /** The channel where the live location is shared. */ channel: Channel; - /** - * The ID of the message containing the shared location. - */ + /** The ID of the message containing the shared location. */ messageId: string; - /** - * Callback function to handle location updates. - * It receives the updated shared location response. - */ + /** Called with the updated shared location whenever the message's location is updated. */ onLocationUpdate?: (location: SharedLocationResponse) => void; }; /** - * Custom hook to handle live location events. + * Tracks the shared location of one message from its `message.updated` events. + * + * @param params.channel The channel to listen on. + * @param params.messageId The ID of the message carrying the shared location. + * @param params.onLocationUpdate Called with each updated shared location. + * @returns The latest `locationResponse`, and `isLiveLocationStopped`, which is `true` once the + * location's `end_at` has passed and `null` before that. */ export const useHandleLiveLocationEvents = ({ channel, diff --git a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx index fe1fe0d624..6ba9777d50 100644 --- a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx +++ b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx @@ -23,9 +23,7 @@ export type MessageComposerContextValue = { * or the channel's own. */ messageComposer: MessageComposer; - /** - * The message being edited, if any. - */ + /** The message being edited, if any. */ editing?: LocalMessage; }; diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 06c542c041..ee4f686608 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -24,26 +24,22 @@ import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; export type Alignment = 'right' | 'left'; export type MessageContextValue = { - /** Whether or not actions can be performed on message */ + /** True for a received regular message, the only kind that can be acted on. */ actionsEnabled: boolean; - /** Position of the message, either 'right' or 'left' */ + /** Side of the list the message is aligned to. */ alignment: Alignment; - /** - * Function to dismiss the overlay - */ + /** Closes the message overlay. */ dismissOverlay: () => void; - /** The files attached to a message */ + /** The file attachments of the message. */ files: Attachment[]; /** - * Position of message in group - top, bottom, middle, single. - * - * Message group is a group of consecutive messages from same user. groupStyles can be used to style message as per their position in message group - * e.g., user avatar (to which message belongs to) is only showed for last (bottom) message in group. + * Position of the message in its group of consecutive messages from the same user: `top`, + * `bottom`, `middle` or `single`. For example, the avatar shows on the bottom message only. */ groupStyles: GroupType[]; - /** Handler for actions. Actions in combination with attachments can be used to build [commands](https://getstream.io/chat/docs/#channel_commands). */ + /** Handler for attachment actions, such as those of command responses. */ handleAction: ActionHandler; - /** Whether or not any message attachment exposes actions. */ + /** Whether any attachment of the message exposes actions. */ hasAttachmentActions: boolean; handleToggleReaction: (reactionType: string) => Promise; /** @@ -53,104 +49,87 @@ export type MessageContextValue = { */ messageActionHandlers: MessageActionHandlerCallbacks; /** - * Whether the message renders interactive children (poll options, attachment - * cells, the quoted-reply navigator, shared location) and the row should - * therefore drop its single focusstop so VO/TalkBack can drill into them. - * Resolved from `AccessibilityContext.hasInteractiveAccessibilityContent` - - * integrators override there to include their own custom content. + * Whether the message renders interactive children (poll options, attachments, the quoted-reply + * navigator, shared location), so the row drops its single focus stop and VoiceOver/TalkBack can + * reach them. Resolved from `AccessibilityContext.hasInteractiveAccessibilityContent`. */ hasInteractiveAccessibilityContent: boolean; - /** Whether or not message has reactions */ + /** Whether the message has reactions. */ hasReactions: boolean; - /** Whether or not message has only a single attachment */ + /** Whether the message has no text, no quoted message and exactly one attachment. */ messageHasOnlySingleAttachment: boolean; - /** The images attached to a message */ + /** The image attachments of the message. */ images: Attachment[]; - /** Whether or not this is the active user's message */ + /** Whether the message was sent by the current user. */ isMyMessage: boolean; - /** Whether this is the newest message in its list, with nothing newer loaded below it */ + /** Whether this is the newest message in its list, with nothing newer loaded below it. */ isVeryLastMessage?: boolean; - /** Whether or not this is the last message in a group of messages */ + /** Whether this is the last message of its group (`bottom` or `single`). */ lastGroupMessage: boolean; - /** Current [message object](https://getstream.io/chat/docs/#message_format) */ message: LocalMessage; /** - * Ref to the view that the message context menu should align with. - * Custom message renderers can attach this to a different subview if needed. + * Ref to the view the message context menu aligns with. Custom message renderers can attach it + * to a different subview. */ contextMenuAnchorRef: React.RefObject; /** - * Stable UI-instance identifier for the rendered message. - * Used for overlay state so two rendered instances of the same message do not collide. + * Stable ID of this rendered message instance, so two renderings of the same message do not + * collide in the overlay state. */ messageOverlayId: string; - /** Order to render the message content */ + /** Order in which the message content is rendered. */ messageContentOrder: MessageContentType[]; /** - * You can call methods available on the Message - * component such as handleEdit, handleDelete, handleAction etc. - * - * Source - [Message](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/Message/Message.tsx) + * Long-press handler. Defaults to opening the message overlay, except for blocked messages, + * messages with attachment actions, or when `enableLongPress` is false. * - * By default, we show the overlay with all the message actions on long press. - * - * @param payload Payload object for onLongPress event + * @param payload Payload of the long-press event. */ onLongPress: (payload: PressableHandlerPayload) => void; - /** Whether the message is only text and the text is only emojis */ + /** Whether the message is only text and the text is only emojis. */ onlyEmojis: boolean; - /** Handler to open a thread on a message */ + /** Opens the thread of the message. */ onOpenThread: () => void; /** - * You can call methods available on the Message - * component such as handleEdit, handleDelete, handleAction etc. - * - * Source - [Message](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/Message/Message.tsx) - * - * By default, we will dismiss the keyboard on press. + * Press handler. Defaults to dismissing the keyboard when `dismissKeyboardOnMessageTouch` is on, + * and to opening the message overlay or bounce dialog for a failed or errored message. * - * @param payload Payload object for onPress event + * @param payload Payload of the press event. */ onPress: (payload: MessagePressableHandlerPayload) => void; onPressIn: ((payload: PressableHandlerPayload) => void) | null; - /** The images attached to a message */ + /** The attachments that are not files, images or videos. */ otherAttachments: Attachment[]; /** - * Registers the subtree that should be measured and portaled into the message overlay. - * Custom message renderers typically interact with this via `MessageOverlayWrapper`. + * Registers the view to measure and portal into the message overlay. Custom message renderers + * use it through `MessageOverlayWrapper`. */ registerMessageOverlayTarget: (params: { id: string; view: ViewRef | null }) => void; unregisterMessageOverlayTarget: (id: string) => void; reactions: ReactionSummary[]; - /** - * Function to show the menu with all the message actions. - * @param showMessageReactions - * @returns void - */ + /** Opens the message overlay with all the message actions. */ showMessageOverlay: () => void; showReactionsOverlay: (selectedReaction?: string) => void; showMessageStatus: boolean; - /** The videos attached to a message */ + /** The video attachments of the message. */ videos: Attachment[]; goToMessage?: (messageId: string) => void; /** - * Function to handle reaction on message - * @param reactionType - * @returns + * Handles a reaction on the message. + * + * @param reactionType The type of the reaction. */ handleReaction?: (reactionType: string) => Promise; - /** - * Theme provided only to messages that are the current users - */ + /** Theme applied only to messages of the current user. */ myMessageTheme?: DeepPartial; - /** Prevent message being pressed for image viewer view */ + /** Disables pressing the message attachments. Always true while the message overlay is active. */ preventPress?: boolean; - /** Whether or not the avatar show show next to Message */ + /** Whether the avatar shows next to the message. */ showAvatar?: boolean; /** - * Function to handle thread select - * @param thread - The thread of the message - * @returns void + * Selects the thread of the message. + * + * @param thread The thread of the message. */ onThreadSelect?: (thread: Thread) => void; } & Pick & diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 8afa1953f4..46357668c9 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -37,19 +37,13 @@ export type LocalMessageInputContext = { closeAttachmentPicker: () => void; inputBoxRef: React.RefObject; openAttachmentPicker: () => void; - /** - * Function for picking a photo from native image picker and uploading it. - */ + /** Picks images with the native image picker and uploads them. */ pickAndUploadImageFromNativePicker: () => Promise; pickFile: () => Promise; sendMessage: () => Promise; - /** - * Ref callback to set reference on input box - */ + /** Ref callback for the input box. Also forwards the ref to `setInputRef`. */ setInputBoxRef: Ref | undefined; - /** - * Function for taking a photo and uploading it - */ + /** Takes a photo or video with the camera and uploads it. */ takeAndUploadImage: ( mediaType?: MediaTypes, ) => Promise<{ askToOpenSettings?: boolean; canceled?: boolean } | undefined>; @@ -68,32 +62,23 @@ export type InputBoxRef = TextInputRef & { export type InputMessageInputContextValue = { /** - * Controls how many pixels to the top side the user has to scroll in order to lock the recording view and allow the - * user to lift their finger from the screen without stopping the recording. + * Distance in pixels the user slides up to lock the recording, so that lifting the finger does + * not stop it. Defaults to 50. */ asyncMessagesLockDistance: number; - /** - * Controls the minimum duration that the user has to press on the record button in the composer, in order to start - * recording a new voice message. - */ + /** Minimum press duration on the record button to start a voice recording. Defaults to 500. */ asyncMessagesMinimumPressDuration: number; /** - * Controls whether a completed voice recording is sent immediately or added to the composer first. - * When true, the recording is sent immediately on completion. - * When false, the recording is added to the composer and only sent if the user decides to. + * Whether a completed voice recording is sent immediately (true) or added to the composer for the + * user to send (false). Defaults to false. */ audioRecordingSendOnComplete: boolean; - /** - * Controls how many pixels to the leading side the user has to scroll in order to cancel the recording of a voice - * message. - */ + /** Distance in pixels the user slides to the leading side to cancel recording. Defaults to 75. */ asyncMessagesSlideToCancelDistance: number; - /** - * Controls whether the async audio feature is enabled. - */ + /** Whether voice message recording is enabled. Defaults to false. */ audioRecordingEnabled: boolean; /** - * Height of the image picker bottom sheet when opened. + * Height of the attachment picker bottom sheet when open. * @type number * @default 40% of window height */ @@ -107,51 +92,41 @@ export type InputMessageInputContextValue = { attachmentSelectionBarHeight: number; /** - * Controls what happens when the attach button is pressed while the attachment picker is open. - * When true (default), the message input is focused, toggling from the picker to the keyboard. - * When false, the attachment picker is closed without focusing the input. + * Whether pressing the attach button while the attachment picker is open focuses the input, + * switching from the picker to the keyboard. When false, the picker closes without focusing it. * @type boolean * @default true */ focusInputOnPickerClose: boolean; - /** When false, CameraSelectorIcon will be hidden */ + /** Whether the camera picker button shows. */ hasCameraPicker: boolean; - /** When false, CommandsButton will be hidden */ + /** Whether the commands button shows. */ hasCommands: boolean; - /** When false, FileSelectorIcon will be hidden */ + /** Whether the file picker button shows. */ hasFilePicker: boolean; - /** When false, ImageSelectorIcon will be hidden */ + /** Whether the image picker button shows. */ hasImagePicker: boolean; - /** - * Additional props for underlying TextInput component. These props will be forwarded as it is to TextInput component. - * - * @see See https://reactnative.dev/docs/textinput#reference - */ + /** Props forwarded as is to the underlying `TextInput`. */ additionalTextInputProps?: TextInputProps; closePollCreationDialog?: () => void; /** - * Compress image with quality (from 0 to 1, where 1 is best quality). - * On iOS, values larger than 0.8 don't produce a noticeable quality increase in most images, - * while a value of 0.8 will reduce the file size by about half or less compared to a value of 1. - * Image picker defaults to 0.8 for iOS and 1 for Android + * Image compression quality from 0 to 1, where 1 is best. On iOS, values above 0.8 rarely improve + * quality, while 0.8 roughly halves the file size. The image picker defaults to 0.8 on iOS and 1 + * on Android. */ compressImageQuality?: number; - /** - * Vertical gap between poll options in poll creation dialog. - */ + /** Vertical gap between poll options in the poll creation dialog. */ createPollOptionGap?: number; - /** - * Handler for when the attach button is pressed. - */ + /** Called when the attach button is pressed. */ handleAttachButtonPress?: () => void; /** - * Whether the message input is floating or not. + * Whether the message input floats over the content. * @type boolean * @default false */ @@ -159,9 +134,9 @@ export type InputMessageInputContextValue = { openPollCreationDialog?: ({ sendMessage }: Pick) => void; /** - * ref for input setter function + * Receives the ref of the underlying `TextInput`, and `null` on unmount. * - * @param ref [Ref object](https://reactjs.org/docs/refs-and-the-dom.html) for underling `TextInput` component. + * @param ref The `TextInput` ref. * * @overrideType Function */ @@ -205,24 +180,13 @@ export const MessageInputProvider = ({ const messageComposer = useMessageComposer(); const { attachmentManager } = messageComposer; - /** - * Composer middleware this SDK owns. - * - * Sending before uploads finish is deliberately not configured here: it is the composer's own - * `attachments.pendingUploadsEnabled`, defaulted by `` through `client.config` (see - * `usePendingUploadsDefault`). An imperative `updateConfig` here would outrank the integrator's - * `client.config` and setup function, leaving them no way to change it. - * - * The draft composition is deliberately left alone: it keeps successful uploads only, and a - * draft is sent to the server, where a local file URI no other device can read has no business. - */ + // Composer middleware this SDK owns. Sending before uploads finish is not configured here: + // `attachments.pendingUploadsEnabled` is composer config that `` defaults through + // `client.config`, and an imperative update here would outrank the integrator's own config. useEffect(() => { setupVideoAttachmentPreviewMiddleware(messageComposer); }, [messageComposer]); - /** - * Function for capturing a photo and uploading it - */ const takeAndUploadImage = useStableCallback(async (mediaType?: MediaTypes) => { if (!attachmentManager.availableUploadSlots) { Alert.alert(t('attachmentPicker.maxFiles.error', 'Maximum number of files reached')); @@ -258,9 +222,6 @@ export const MessageInputProvider = ({ return file; }); - /** - * Function for picking a photo from native image picker and uploading it - */ const pickAndUploadImageFromNativePicker = useStableCallback(async () => { if (!attachmentManager.availableUploadSlots) { Alert.alert(t('attachmentPicker.maxFiles.error', 'Maximum number of files reached')); @@ -323,12 +284,9 @@ export const MessageInputProvider = ({ }); }); - /** - * Function to open the attachment picker if the MediaLibary is installed. - */ const openAttachmentPicker = useCallback(() => { if (!shouldRenderAttachmentPicker) { - // There is no picker to open, and a selected picker would leave space reserved for it. + // No picker to open, and selecting one would reserve space for it. return; } dismissKeyboard(); @@ -336,9 +294,6 @@ export const MessageInputProvider = ({ openPicker(); }, [attachmentPickerStore, openPicker, shouldRenderAttachmentPicker]); - /** - * Function to close the attachment picker if the MediaLibrary is installed. - */ const closeAttachmentPicker = useCallback(() => { attachmentPickerStore.setSelectedPicker(undefined); closePicker(); @@ -358,7 +313,7 @@ export const MessageInputProvider = ({ inputBoxRef.current?.clearState(); try { - // A bounced message (type 'error') is never updated: editing it sends it again as a new message. + // Editing a bounced message (type 'error') sends it again as a new message. const { editedMessage } = messageComposer; const result = editedMessage && editedMessage.type !== 'error' @@ -402,11 +357,8 @@ export const MessageInputProvider = ({ : normalizedFile.uri; const updatedFile = { ...normalizedFile, uri: fileURI }; const uploadedFiles = await attachmentManager.uploadFiles([updatedFile]); - // A blocked upload (i.e the file exceeds the size limit or has a - // disallowed type) is a permanent validation failure that the server - // will always reject, so remove it from the composer instead of leaving - // an unsendable preview behind. The blocked notification has already been - // surfaced to the user by the attachment manager at this point. + // A blocked upload (over the size limit or of a disallowed type) is rejected for good, so it + // is removed from the composer. The attachment manager has already notified the user. const blockedAttachmentIds = (uploadedFiles ?? []) .filter((attachment) => attachment?.localMetadata?.uploadState === 'blocked') .map((attachment) => attachment?.localMetadata.id) diff --git a/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts b/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts index 92ae4a8c66..5c5697e7ed 100644 --- a/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts +++ b/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts @@ -3,5 +3,7 @@ import { useMessageComposerContext } from '../../messageComposerContext/MessageC /** * The composer in use: the edit composer while a message is being edited, otherwise the thread's or * the channel's own. + * + * @returns The active `MessageComposer`. */ export const useMessageComposer = () => useMessageComposerContext().messageComposer; diff --git a/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts b/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts index acaf77a582..fca2ac33c8 100644 --- a/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts +++ b/package/src/contexts/messageInputContext/hooks/useMessageComposerHasSendableData.ts @@ -11,7 +11,7 @@ export const useMessageComposerHasSendableData = () => { const messageComposer = useMessageComposer(); useStateStore(messageComposer.editingAuditState, editingAuditStateStateSelector); // `hasSendableData` also depends on whether pending uploads may be sent, which is composer config - // rather than composer state — so toggling `attachments.pendingUploadsEnabled` re-renders too. + // rather than composer state, so toggling `attachments.pendingUploadsEnabled` re-renders too. useStateStore(messageComposer.configState, pendingUploadsEnabledSelector); return messageComposer.hasSendableData; }; diff --git a/package/src/contexts/messageInputContext/hooks/usePendingUploadsEnabled.ts b/package/src/contexts/messageInputContext/hooks/usePendingUploadsEnabled.ts index 1ff2595e18..95de069ce3 100644 --- a/package/src/contexts/messageInputContext/hooks/usePendingUploadsEnabled.ts +++ b/package/src/contexts/messageInputContext/hooks/usePendingUploadsEnabled.ts @@ -9,9 +9,11 @@ export const pendingUploadsEnabledSelector = (config: MessageComposerConfig) => }); /** - * Whether the current composer lets a message be sent while its attachments are still uploading — - * `messageComposer.attachments.pendingUploadsEnabled`, resolved per composer, so a thread composer - * answers for itself. + * Whether the current composer lets a message be sent while its attachments are still uploading. + * Read from the composer's `attachments.pendingUploadsEnabled` config, so a thread composer answers + * for itself. + * + * @returns The composer's `attachments.pendingUploadsEnabled` setting. */ export const usePendingUploadsEnabled = () => { const messageComposer = useMessageComposer(); diff --git a/package/src/contexts/messageListItemContext/MessageListItemContext.tsx b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx index 44f1d14199..0313f8e6bd 100644 --- a/package/src/contexts/messageListItemContext/MessageListItemContext.tsx +++ b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx @@ -8,24 +8,19 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type MessageListItemContextValue = { /** - * Handler to go to a particular message when its quoted. + * Goes to a message, used when a quoted message is pressed. * - * @param messageId The id of the message to go to. - * @returns void + * @param messageId The ID of the message to go to. */ goToMessage: (messageId: string) => void; - /** - * Theme to use for the message list item - */ + /** The theme of the list items, with `myMessageTheme` merged in. */ modifiedTheme: Theme; - /** - * Whether to group messages by user - */ + /** Turns off grouping of consecutive messages by user. */ noGroupByUser?: boolean; /** - * Handler to open the thread on message. This is callback for touch event for replies button. + * Called when the replies button is pressed. * - * @param message A message object to open the thread upon. + * @param thread The thread to open. */ onThreadSelect: MessageListProps['onThreadSelect']; setNativeScrollability: (value: boolean) => void; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 74bdfd6ad1..7b79b9b86b 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -38,43 +38,37 @@ export type MessageLocationProps = { }; export type MessagesContextValue = { - /** Should keyboard be dismissed when messaged is touched */ + /** Whether touching a message dismisses the keyboard. */ dismissKeyboardOnMessageTouch: boolean; enableMessageGroupingByUser: boolean; - /** - * The type of URL preview to render. - * Defaults to: 'full' - */ + /** The type of URL preview to render. Defaults to 'full'. */ urlPreviewType: 'compact' | 'full'; FlatList: typeof NativeHandlers.FlatList | undefined; /** - * The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default - * */ + * The Giphy image version to render, one of the keys of the Giphy + * [Image Object](https://developers.giphy.com/docs/api/schema#image-object). + * Defaults to 'fixed_height'. + */ giphyVersion: keyof NonNullable; - /** Order to render the message content */ + /** Order in which the message content types are rendered. */ messageContentOrder: MessageContentType[]; /** - * Overlay target id that should be teleported when a message overlay opens. - * Custom `MessageOverlayWrapper` usages should pass a matching `targetId`. + * Overlay target ID to teleport when a message overlay opens. A custom `MessageOverlayWrapper` + * needs a matching `targetId`. */ messageOverlayTargetId?: string; /** - * Provide any additional props for `Pressable` which wraps inner MessageContent component here. - * Please check docs for Pressable for supported props - https://reactnative.dev/docs/pressable#props + * Extra props for the `Pressable` that wraps the inner `MessageContent`, except `style`. * * @overrideType Object */ additionalPressableProps?: Omit; - /** - * Custom handler to handle message swipe action. - * - * The default behaviour is swipe to reply for this. - */ + /** Overrides the swipe action on a message, which quotes the message by default. */ customMessageSwipeAction?: ({ channel, message, @@ -84,204 +78,85 @@ export type MessagesContextValue = { }) => void; disableTypingIndicator?: boolean; - /** - * Enable swipe to reply on messages. - */ + /** Whether swiping a message replies to it. Defaults to true. */ enableSwipeToReply?: boolean; /** - * Whether messages should be aligned to right or left part of screen. - * By default, messages will be received messages will be aligned to left and - * sent messages will be aligned to right. + * Forces every message to the 'left' or 'right' side of the screen. By default, received messages + * are on the left and sent messages on the right. */ forceAlignMessages?: Alignment | boolean; getMessageGroupStyle?: (params: MessageGroupStylesParams) => GroupStyle[]; /** - * Handler to access when a ban user action is invoked. + * Called when the ban user action runs, before the default action. * @param message */ handleBan?: (message: LocalMessage) => Promise; - /** Handler to access when a copy message action is invoked */ + /** Called when the copy message action runs, before the default action. */ handleCopy?: (message: LocalMessage) => Promise; - /** Handler to access when a delete for me message action is invoked */ + /** Called when the delete for me action runs, before the default action. */ handleDeleteForMe?: (message: LocalMessage) => Promise; - /** Handler to access when a delete message action is invoked */ + /** Called when the delete message action runs, before the default action. */ handleDelete?: (message: LocalMessage) => Promise; - /** Handler to access when an edit message action is invoked */ + /** Called when the edit message action runs, before the default action. */ handleEdit?: (message: LocalMessage) => void; - /** Handler to access when a flag message action is invoked */ + /** Called when the flag message action runs, before the default action. */ handleFlag?: (message: LocalMessage) => Promise; - /** Handler to access when a mark unread action is invoked */ + /** Called when the mark unread action runs, before the default action. */ handleMarkUnread?: (message: LocalMessage) => Promise; - /** Handler to access when a mute user action is invoked */ + /** Called when the mute user action runs, before the default action. */ handleMute?: (message: LocalMessage) => Promise; - /** Handler to access when a pin/unpin user action is invoked*/ + /** Called when the pin/unpin action runs, before the default action. */ handlePinMessage?: ((message: LocalMessage) => MessageActionType) | null; - /** Handler to access when a quoted reply action is invoked */ + /** Called when the quoted reply action runs, before the default action. */ handleQuotedReply?: (message: LocalMessage) => Promise; - /** Handler to process a reaction */ + /** Called when a reaction is toggled, before the default action. */ handleReaction?: (message: LocalMessage, reactionType: string) => Promise; - /** Handler to access when a retry action is invoked */ + /** Called when the retry action runs, with the message stripped of reserved fields. */ handleRetry?: (message: LocalMessage) => Promise; - /** Handler to access when a thread reply action is invoked */ + /** Called when the thread reply action runs, before the default action. */ handleThreadReply?: (message: LocalMessage) => Promise; - /** Handler to access when a blocking user action is invoked */ + /** Called when the block user action runs, before the default action. */ handleBlockUser?: (user: LocalMessage['user']) => Promise; - /** A flag specifying whether the poll creation button is available or not. */ + /** Whether the poll creation button is available. Also requires polls enabled on the channel. */ hasCreatePoll?: boolean; - /** Handler to deal with custom memoization logic of Attachment */ + /** Custom memoization check for an attachment: returns whether the two are equal. */ isAttachmentEqual?: (prevAttachment: Attachment, nextAttachment: Attachment) => boolean; - /** Object specifying rules defined within simple-markdown https://github.com/Khan/simple-markdown#adding-a-simple-extension */ + /** + * Custom rules for the markdown renderer, as defined by simple-markdown: + * https://github.com/Khan/simple-markdown#adding-a-simple-extension + */ markdownRules?: MarkdownRules; /** - * Use this prop to override message actions (which pop-up in message overlay). - * - * You can either completely override the default messageActions object. + * Overrides the message actions shown in the message overlay. Receives the default actions and + * returns the ones to show, in order. For example, to keep only two of them: * * ``` - * { someAction() }; - * title: "Pin Message"; - * icon: PinIcon; - * titleStyle: {}; - * }, - * { - * action: () => { someAction() }; - * title: "Delete Message"; - * icon: PinIcon; - * titleStyle: {}; - * } - * ] - * > - * + * [threadReply, copyMessage]} /> * ``` * - * Or you can selectly keep certain action and remove some: - * - * e.g. Lets say you only want to keep threadReply and copyMessage actions - * - * ``` - * ([ - * threadReply, copyMessage - * ])} - * > - * - * ``` - * * @overrideType Function | Array */ messageActions?: (param: MessageActionsParams) => MessageActionType[]; - /** - * HitSlop for the message swipe to reply gesture - */ + /** Hit slop of the swipe to reply gesture. */ messageSwipeToReplyHitSlop?: ViewProps['hitSlop']; - /** - * The number of lines of the message text to be displayed - */ + /** Maximum number of lines of message text to display. Only the first paragraph is rendered. */ messageTextNumberOfLines?: number; - /** - * Theme provided only to messages that are the current users - */ + /** Theme applied only to the messages of the current user. */ myMessageTheme?: DeepPartial; /** - * Override default handler for onLongPress on message. You have access to payload of that handler as param: - * - * ``` - * Promise; - * editMessage, // () => void; - * quotedReply, // () => void; - * resendMessage, // () => Promise; - * showMessageOverlay, // () => void; - * toggleBanUser, // () => Promise; - * toggleMuteUser, // () => Promise; - * toggleReaction, // (reactionType: string) => Promise; - * }, - * defaultHandler, // () => void - * event, // any event object corresponding to touchable feedback - * emitter, // which component trigged this touchable feedback e.g. card, fileAttachment, gallery, message ... etc - * message // message object on which longPress occured - * }) => { - * // Your custom action - * }} - * /> - * ``` + * Overrides the long press handler of a message, which opens the message overlay by default. The + * payload carries `actionHandlers`, `defaultHandler`, `emitter`, `event` and `message`. */ onLongPressMessage?: (payload: MessagePressableHandlerPayload) => void; - /** - * Add onPressIn handler for attachments. You have access to payload of that handler as param: - * - * ``` - * Promise; - * editMessage, // () => void; - * quotedReply, // () => void; - * resendMessage, // () => Promise; - * showMessageOverlay, // () => void; - * toggleBanUser, // () => Promise; - * toggleMuteUser, // () => Promise; - * toggleReaction, // (reactionType: string) => Promise; - * }, - * defaultHandler, // () => void - * event, // any event object corresponding to touchable feedback - * emitter, // which component trigged this touchable feedback e.g. card, fileAttachment, gallery, message ... etc - * message // message object on which longPress occured - * }) => { - * // Your custom action - * }} - * /> - * ``` - */ + /** Called when a press on a message or its content starts. Takes the same payload. */ onPressInMessage?: (payload: MessagePressableHandlerPayload) => void; /** - * Override onPress handler for message. You have access to payload of that handler as param: - * - * ``` - * Promise; - * editMessage, // () => void; - * quotedReply, // () => void; - * resendMessage, // () => Promise; - * showMessageOverlay, // () => void; - * toggleBanUser, // () => Promise; - * toggleMuteUser, // () => Promise; - * toggleReaction, // (reactionType: string) => Promise; - * }, - * additionalInfo, // emitter-specific info, e.g. `{ reactionType }` when emitter === 'reactionList' - * defaultHandler, // () => void - * event, // any event object corresponding to touchable feedback - * emitter, // which component trigged this touchable feedback e.g. card, fileAttachment, gallery, message ... etc - * message // message object on which longPress occurred - * }) => { - * // Your custom action - * }} - * /> - * ``` + * Overrides the press handler of a message. Takes the same payload as `onLongPressMessage`. * - * By default, pressing a reaction opens the reactions bottom sheet. To instead toggle - * the reaction (add/remove the current user's reaction), intercept the `reactionList` emitter: + * Pressing a reaction opens the reactions bottom sheet by default. To toggle the reaction + * instead, intercept the `reactionList` emitter: * * ``` * void; /** - * Handles a press on the "Also sent in channel" / "Replied to a thread" header's "View" action. - * Inside a thread the payload carries the targeted message id; in a channel it also carries the - * reply's thread. + * Handles a press on the "View" action of the "Also sent in channel" / "Replied to a thread" + * header. The action is only shown when this is set. The payload carries the targeted message ID, + * and in a channel also the reply's thread. */ onAlsoSentToChannelHeaderPress?: (payload: AlsoSentToChannelHeaderPressPayload) => void; - /** - * The position of the reaction list in the message - */ + /** Where the reaction list renders in the message. Defaults to 'top'. */ reactionListPosition?: 'top' | 'bottom'; - /** - * The alignment of the reaction list - */ + /** The layout of the reaction list. Defaults to 'clustered'. */ reactionListType?: 'clustered' | 'segmented'; /** - * Full override of the reaction function on Message and Message Overlay - * - * Please check [cookbook](https://github.com/GetStream/stream-chat-react-native/wiki/Cookbook-v3.0#override-or-intercept-message-actions-edit-delete-reaction-reply-etc) for details. - * */ + * Overrides the reaction handler of the message and the message overlay: takes the message and + * returns the function that receives the reaction type. + */ selectReaction?: (message: LocalMessage) => (reactionType: string) => Promise; - /** - * Boolean to enable/disable the message underlay background when there are unread messages in the Message List. - */ + /** Whether messages get an underlay when the list has unread ones. Defaults to true. */ shouldShowUnreadUnderlay?: boolean; - /** - * The supported reactions that the user can use to react to messages. - */ + /** The reactions users can react to messages with. */ supportedReactions?: ReactionData[]; }; diff --git a/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx b/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx index 3119a96d6a..3547e7391d 100644 --- a/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx +++ b/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx @@ -45,14 +45,9 @@ const ClosingPortalHostSlot = ({ type ClosingPortalHostsLayerProps = { closeCoverOpacity: SharedValue; - /** - * Id of the owning host layer, used to park the slot hosts while that layer is not the top one. - */ + /** ID of the owning host layer, which parks the slot hosts when it is not the top one. */ hostLayerId?: string; - /** - * Whether the owning host layer is the top one. Slots of any other layer park their hosts under - * names unique to that layer, so they never take the shared names from the top layer. - */ + /** Whether the owning host layer is the top one. Other layers park hosts under unique names. */ isTopHostLayer?: boolean; }; diff --git a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx index 7028fdc40f..9b1259a9c6 100644 --- a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx +++ b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx @@ -72,10 +72,7 @@ export type MessageActionsProps = { }; type MessageOverlayHostLayerProps = { - /** - * Id under which this layer registers on the host layer stack. `OverlayProvider` passes its own - * so that views inside the provider can tell whether this layer is the top one. - */ + /** ID under which this layer registers on the host layer stack. */ hostLayerId: string; }; @@ -94,10 +91,8 @@ export const MessageOverlayHostLayer = ({ hostLayerId }: MessageOverlayHostLayer const closeCorrectionY = useSharedValue(0); const topInset = insets.top; - // Due to edge-to-edge in combination with various libraries, Android sometimes reports - // the insets to be 0. If that's the case, we use this as an escape hatch to offset the bottom - // of the overlay so that it doesn't collide with the navigation bar. Worst case scenario, - // if the navigation bar is actually 0 - we end up animating a little bit further. + // With edge-to-edge, Android sometimes reports a bottom inset of 0. The fallback offset keeps the + // overlay clear of the navigation bar; if the bar really is 0, it animates slightly further. const bottomInset = insets.bottom === 0 && Platform.OS === 'android' ? 60 : insets.bottom; const isActive = !!id && isTop; @@ -129,11 +124,8 @@ export const MessageOverlayHostLayer = ({ hostLayerId }: MessageOverlayHostLayer return () => subscription.remove(); }, [isActive]); - // When orientation changes, we dismiss the overlay. While it is possible - // (and I have a working PoC of) a repositioning solution, it is pretty - // complex and probably bug prone. This is anyway how the iOS native contextual - // menu works (as seen on iMessage and Twitch for example, which support landscape - // mode). + // The overlay is dismissed on orientation change: repositioning is complex and error prone, and + // the iOS native context menu behaves the same way. const orientation = useScreenOrientation(); const prevOrientationRef = useRef(orientation); useEffect(() => { @@ -175,8 +167,8 @@ export const MessageOverlayHostLayer = ({ hostLayerId }: MessageOverlayHostLayer return Math.max(0, contentH - available); }); - // On close, freeze any inflight fling (the message closes from where it was scrolled - see - // `hostStyle`) and only reset the offset once fully closed, ready for the next open. + // On close, freeze any in-flight fling (the message closes from where it was scrolled, see + // `hostStyle`); the offset resets only once fully closed. useEffect(() => { if (closing) { cancelAnimation(scrollY); @@ -316,9 +308,9 @@ export const MessageOverlayHostLayer = ({ hostLayerId }: MessageOverlayHostLayer }; }); - // Two composed `translateY`: first is the springed open/close position, second is the raw - // scroll offset. On close we fold the scroll into the first (springed) target so the message - // animates back from where it was scrolled, while the latter stays put. + // Two composed `translateY`: the first is the springed open/close position, the second the raw + // scroll offset. On close the scroll folds into the springed target so the message animates back + // from where it was scrolled, while the raw offset stays put. const hostStyle = useAnimatedStyle(() => { if (!messageH.value) return { height: 0 }; const scroll = scrollGeom.value.needsScroll ? scrollY.value : 0; @@ -341,7 +333,7 @@ export const MessageOverlayHostLayer = ({ hostLayerId }: MessageOverlayHostLayer }); // Drag scrolls the message (clamped to `[0, maxScroll]`); release flings with decay. When the - // content fits, `maxScroll` is 0 so this is a noop. + // content fits, `maxScroll` is 0 and this does nothing. const pan = useMemo( () => Gesture.Pan() @@ -434,8 +426,8 @@ export const MessageOverlayHostLayer = ({ hostLayerId }: MessageOverlayHostLayer ) : ( <> {/* - Message renders first (lowest z) so it sits behind the opaque top/bottom items; - a scrolled/overflowing message passes behind them instead of being clipped. + The message renders first (lowest z) so an overflowing message scrolls behind the + opaque top and bottom items rather than being clipped. */} diff --git a/package/src/contexts/overlayContext/OverlayContext.tsx b/package/src/contexts/overlayContext/OverlayContext.tsx index ab38f5d427..67548104f5 100644 --- a/package/src/contexts/overlayContext/OverlayContext.tsx +++ b/package/src/contexts/overlayContext/OverlayContext.tsx @@ -26,12 +26,11 @@ export const OverlayContext = React.createContext( export type OverlayProviderProps = ImageGalleryProviderProps & { /** - * Opt-in accessibility configuration. A11y is OFF by default — pass - * `accessibility={{ enabled: true }}` to enable screen-reader announcements, - * gesture alternatives, and semantic a11y attributes across the SDK. + * Opt-in accessibility configuration. Disabled by default; `accessibility={{ enabled: true }}` + * turns on screen-reader announcements, gesture alternatives and semantic a11y attributes. */ accessibility?: AccessibilityConfig; - /** https://github.com/GetStream/stream-chat-react-native/wiki/Internationalization-(i18n) */ + /** The `Streami18n` instance that enables internationalization. */ i18nInstance?: Streami18n; value?: Partial; }; diff --git a/package/src/contexts/overlayContext/OverlayHostLayerContext.ts b/package/src/contexts/overlayContext/OverlayHostLayerContext.ts index a40fb81565..6765adfaa9 100644 --- a/package/src/contexts/overlayContext/OverlayHostLayerContext.ts +++ b/package/src/contexts/overlayContext/OverlayHostLayerContext.ts @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; /** - * The id of the `MessageOverlayHostLayer` rendered by the nearest `OverlayProvider`. Views that + * The ID of the `MessageOverlayHostLayer` rendered by the nearest `OverlayProvider`. Views that * teleport into that layer use it to tell whether their provider currently owns the overlay. */ export const OverlayHostLayerContext = React.createContext(undefined); diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 1c53be687c..679662957e 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -26,22 +26,8 @@ import { } from '../translationContext/TranslationContext'; /** - * - The highest level of these components is the `OverlayProvider`. The `OverlayProvider` allows users to interact with messages on long press above the underlying views, use the full screen image viewer, and use the `AttachmentPicker` as a keyboard-esk view. - * Because these views must exist above all others `OverlayProvider` should wrap your navigation stack as well. Assuming [`React Navigation`](https://reactnavigation.org/) is being used, your highest level navigation stack should be wrapped in the provider: - * - * ```js - * - * - * - * - * - * - * - * ``` - * - * - Don't forget to check our cookbook section of [OverlayProvider](https://github.com/GetStream/stream-chat-react-native/wiki/Cookbook-v3.0#overlayprovider) - * - * - Also check the [visual component guide](https://github.com/GetStream/stream-chat-react-native/wiki/Cookbook-v3.0#custom-components), to learn about component customizations. + * Hosts the message overlay and the full screen image gallery above the rest of the tree, so it + * must wrap the whole navigation stack (inside `NavigationContainer` with React Navigation). * * @example ./OverlayProvider.md */ @@ -61,7 +47,6 @@ export const OverlayProvider = (props: PropsWithChildren) const overlayOpacity = useSharedValue(0); - // Setup translators const translators = useStreami18n(i18nInstance); useEffect(() => { diff --git a/package/src/contexts/pollContext/createPollContentContext.tsx b/package/src/contexts/pollContext/createPollContentContext.tsx index 7a55d39f53..ffd1f2fa34 100644 --- a/package/src/contexts/pollContext/createPollContentContext.tsx +++ b/package/src/contexts/pollContext/createPollContentContext.tsx @@ -16,11 +16,7 @@ export type CreatePollContentContextValue = { sendMessage: MessageInputContextValue['sendMessage']; closePollCreationDialog?: () => void; modalStateStore?: StateStore; - /** - * Vertical gap between poll options in the poll creation screen. - * - * **Default: ** 8 - */ + /** Vertical gap between poll options in the poll creation screen. Defaults to 8. */ createPollOptionGap?: number; }; diff --git a/package/src/contexts/screenReaderContext/ScreenReaderContext.tsx b/package/src/contexts/screenReaderContext/ScreenReaderContext.tsx index 08e285a6c0..daed969a79 100644 --- a/package/src/contexts/screenReaderContext/ScreenReaderContext.tsx +++ b/package/src/contexts/screenReaderContext/ScreenReaderContext.tsx @@ -4,7 +4,7 @@ import { AccessibilityInfo } from 'react-native'; import { useAccessibilityContext } from '../accessibilityContext/AccessibilityContext'; export type ScreenReaderContextValue = { - /** True when an OS screen reader (VoiceOver / TalkBack) is currently active. */ + /** Whether an OS screen reader (VoiceOver or TalkBack) is active. */ enabled: boolean; }; @@ -15,12 +15,8 @@ export const ScreenReaderContext = React.createContext ); /** - * Owns the single, app wide screen reader subscription. One `isScreenReaderEnabled()` - * query and one `screenReaderChanged` listener for the whole tree; every consumer reads - * the resulting boolean from context instead of attaching its own listener per mount. - * - * Gated on the AccessibilityContext `enabled` flag: when the SDK's a11y is opted out, no - * query is made and no listener is attached. + * Owns the app-wide screen reader subscription, so consumers read one boolean from context instead + * of each attaching a listener. Nothing is queried or subscribed while the SDK's a11y is disabled. */ export const ScreenReaderProvider = ({ children }: PropsWithChildren) => { const { enabled: a11yEnabled } = useAccessibilityContext(); @@ -38,7 +34,7 @@ export const ScreenReaderProvider = ({ children }: PropsWithChildren) => { if (!cancelled) setScreenReaderEnabled(value); }) .catch(() => { - // Some platforms / environments may not implement this; fall back to false. + // Some platforms do not implement this; stay at false. }); const subscription = AccessibilityInfo.addEventListener( diff --git a/package/src/contexts/themeContext/ThemeContext.tsx b/package/src/contexts/themeContext/ThemeContext.tsx index 9115e01f80..5e698b772f 100644 --- a/package/src/contexts/themeContext/ThemeContext.tsx +++ b/package/src/contexts/themeContext/ThemeContext.tsx @@ -20,11 +20,9 @@ export type DeepPartial = { * A custom theme passed to `` / ``: either a partial * override tree or a complete `Theme`. * - * `Theme` is listed first deliberately. A full `Theme` *is* structurally a valid `DeepPartial`, - * but from React Native 0.87 the style types underpinning `Theme` are deep enough that asking - * TypeScript to prove it exceeds its instantiation depth limit (TS2589). Accepting `Theme` directly - * means neither the SDK nor integrators need the `as DeepPartial` cast that used to be - * required, and the cheap branch is matched first. + * `Theme` is listed first because a full `Theme` is structurally a valid `DeepPartial`, but + * with React Native 0.87 style types, proving that exceeds TypeScript's instantiation depth limit + * (TS2589). Matching `Theme` directly avoids the `as DeepPartial` cast. */ export type ThemeStyle = Theme | DeepPartial; diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 4bb03625c8..f1a730cc7f 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -25,7 +25,6 @@ export const Colors = { bg_user: '#F7F7F8', black: '#000000', blue_alice: '#E9F2FF', - // border: '#00000014', // 14 = 8% opacity; top: x=0, y=-1; bottom: x=0, y=1 code_block: '#DDDDDD', disabled: '#B4BBBA', grey: '#7A7A7A', @@ -1210,7 +1209,7 @@ export type Theme = { thumb: ViewStyle; waveform: ViewStyle; }; - semantics: typeof lightSemantics; // themed semantics have the same type + semantics: typeof lightSemantics; }; export const defaultTheme: Theme = { @@ -1874,7 +1873,7 @@ export const defaultTheme: Theme = { container: {}, spacer: { height: BASE_AVATAR_SIZE, - width: BASE_AVATAR_SIZE, // same as BASE_AVATAR_SIZE + width: BASE_AVATAR_SIZE, }, }, card: { diff --git a/package/src/contexts/threadContext/ThreadContext.tsx b/package/src/contexts/threadContext/ThreadContext.tsx index 8e4c4044d7..a43ef0c071 100644 --- a/package/src/contexts/threadContext/ThreadContext.tsx +++ b/package/src/contexts/threadContext/ThreadContext.tsx @@ -9,16 +9,11 @@ export type AlsoSentToChannelHeaderPressPayload = { export type ThreadContextValue = { allowThreadMessagesInChannel: boolean; - /** - * The thread this subtree renders. Set by ``; `undefined` everywhere else. - */ + /** The thread this subtree renders. Set by ``, `undefined` elsewhere. */ thread?: Thread; }; -/** - * What `useThreadContext()` returns outside a ``. Only `` provides this context, so - * its absence is how a component knows it is not rendering a thread. - */ +/** What `useThreadContext()` returns outside a ``, the only provider of this context. */ const NO_THREAD_CONTEXT_VALUE: ThreadContextValue = { allowThreadMessagesInChannel: false, thread: undefined, @@ -34,7 +29,7 @@ export const ThreadProvider = ({ }>) => {children}; /** - * The thread the calling component renders, if any. Unlike the other context hooks this one does not - * throw outside its provider: outside a `` it returns `{ thread: undefined }`. + * The thread the calling component renders, if any. Unlike the other context hooks it does not + * throw outside its provider: it returns `{ thread: undefined }`. */ export const useThreadContext = () => useContext(ThreadContext); diff --git a/package/src/contexts/translationContext/TranslationContext.tsx b/package/src/contexts/translationContext/TranslationContext.tsx index 0b13ab1954..236c35df76 100644 --- a/package/src/contexts/translationContext/TranslationContext.tsx +++ b/package/src/contexts/translationContext/TranslationContext.tsx @@ -9,13 +9,9 @@ export const DEFAULT_USER_LANGUAGE = 'en'; export type TranslationContextValue = TranslatorFunctions & { /** - * The language the SDK's own copy renders in. - * - * Plain `string`, not `stream-chat`'s `TranslationLanguage`. That union enumerates the languages - * the *message auto-translation* endpoint supports, which is a different question from which - * languages this SDK's UI can be translated into — any language an integrator registers a - * dictionary for is valid here. It also does not contain `pt-BR`, which this SDK itself shipped - * a locale for. + * The language the SDK's own copy renders in. A plain `string` rather than `stream-chat`'s + * `TranslationLanguage` (the message auto-translation languages): any language with a registered + * dictionary is valid. */ userLanguage: string; }; diff --git a/package/src/contexts/translationContext/types.ts b/package/src/contexts/translationContext/types.ts index 0fb946a4a5..4225a48109 100644 --- a/package/src/contexts/translationContext/types.ts +++ b/package/src/contexts/translationContext/types.ts @@ -1,23 +1,14 @@ import type { StreamTFunction } from '../../i18n/types'; -/** - * The date/time parser types come from `@stream-io/i18n`, shared with the React SDK. - * - * They used to be declared here, and `TDateTimeParserOutput` referenced `Moment` from - * `moment-timezone` — a *devDependency*, so the type leaked into the published `.d.ts` and failed to - * resolve for any consumer who had not separately installed it. Core's structural `DateTimeLike` - * covers a Dayjs or a Moment without depending on either. - */ +/** The date/time parser types come from `@stream-io/i18n`, shared with the React SDK. */ export type { TDateTimeParser, TDateTimeParserInput, TDateTimeParserOutput } from '@stream-io/i18n'; export type TranslatorFunctions = { /** - * The SDK's translation function. - * - * Typed as {@link StreamTFunction} rather than i18next's `TFunction`, so a key is checked against - * the generated catalog and prose call sites are required to pass their English copy inline. The - * typing is deliberately local: installing it through i18next's `CustomTypeOptions` would be - * global and would force an integrator's own unrelated `t()` calls to satisfy our key union. + * The SDK's translation function. Typed as {@link StreamTFunction} so keys are checked against + * the generated catalog and prose call sites pass their English copy inline. The typing is local, + * not installed through i18next's `CustomTypeOptions`, which would constrain an app's own `t()` + * calls. */ t: StreamTFunction; tDateTimeParser: import('@stream-io/i18n').TDateTimeParser; diff --git a/package/src/hooks/actions/useChannelActionItems.tsx b/package/src/hooks/actions/useChannelActionItems.tsx index c97d746cd8..d7407c417b 100644 --- a/package/src/hooks/actions/useChannelActionItems.tsx +++ b/package/src/hooks/actions/useChannelActionItems.tsx @@ -22,10 +22,8 @@ import { useChannelMembershipState } from '../useChannelMembershipState'; import { useIsDirectChat } from '../useIsDirectChat'; import { useStateStore } from '../useStateStore'; -// Lazily resolved to avoid a static import cycle with defaultComponents.ts -// (defaultComponents → ChannelDetails*/ChannelPreview* → this file). Statically -// importing defaultIcons corrupts module-init order; require it at call time -// instead. Same pattern as ComponentsContext's getDefaults(). +// Required lazily: a static import of defaultComponents.ts would form a cycle +// (defaultComponents -> ChannelDetails*/ChannelPreview* -> this file) and corrupt init order. let cachedDefaultIcons: IconsMap | undefined; const getDefaultIcons = (): IconsMap => { if (!cachedDefaultIcons) { @@ -40,37 +38,22 @@ export type ChannelActionItem = ActionItem< 'mute' | 'muteUser' | 'block' | 'leave' | 'deleteChannel' | 'pin' | string > & { /** - * Per item routing **within a channel preview interaction** (swipe row vs - * the sheet that opens from it). Only meaningful when the items are consumed - * from ``: - * - * - `'swipe'`: shown only in the swipe-row chips. - * - `'sheet'`: shown only in the swipe-triggered options sheet. - * - `'both'`: shown in both swipe row and sheet. - * - * The standalone Channel Details screen does **not** filter by `placement` - - * use {@link ChannelActionSurface} (`surface`) instead to vary items between - * the channel list and the Channel Details screen. + * Where the item shows in a channel preview: `'swipe'` (swipe-row chips only), `'sheet'` (the + * options sheet opened from the swipe row only) or `'both'`. Only `` + * reads it; the Channel Details screen does not, so use `surface` to vary items there. */ placement: 'both' | 'sheet' | 'swipe'; }; /** - * Identifies which top level UI surface is requesting channel action items. - * Passed verbatim into {@link ChannelActionItemsParams} so the default builder - * and any integrator supplied `getChannelActionItems` can branch on it - i.e. - * to drop or relabel an item on a specific surface or to provide an entirely - * different builder per surface. + * The UI surface requesting channel action items, passed through to `getChannelActionItems` so it + * can drop or relabel items per surface. * - * - `'list'`: anything driven by a ChannelList interaction, the swipe row - * chips on a channel preview and the bottom sheet that opens from them. - * Subrouting between the swipe row and the sheet is handled by the per item - * {@link ChannelActionItem.placement} field. - * - `'details'`: items for the standalone Channel Details screen. + * - `'list'`: ChannelList interactions, the swipe row of a channel preview and its options sheet + * (routed between the two by {@link ChannelActionItem.placement}). + * - `'details'`: the standalone Channel Details screen. * - * `surface` operates at the call site level (which UI is asking). It is - * optional and when omitted, the default builder applies no surface specific - * filtering and returns every item it would otherwise produce. + * When omitted, the default builder applies no surface filtering. */ export type ChannelActionSurface = 'list' | 'details'; diff --git a/package/src/hooks/actions/useChannelMemberActionItems.tsx b/package/src/hooks/actions/useChannelMemberActionItems.tsx index ede287e635..51fc2834a8 100644 --- a/package/src/hooks/actions/useChannelMemberActionItems.tsx +++ b/package/src/hooks/actions/useChannelMemberActionItems.tsx @@ -16,10 +16,8 @@ import { IconProps } from '../../icons'; import { useChannelOwnCapabilities } from '../useChannelOwnCapabilities'; import { useStateStore } from '../useStateStore'; -// Lazily resolved to avoid a static import cycle with defaultComponents.ts -// (defaultComponents → ChannelDetails*/ChannelPreview* → this file). Statically -// importing defaultIcons corrupts module-init order; require it at call time -// instead. Same pattern as ComponentsContext's getDefaults(). +// Required lazily: a static import of defaultComponents.ts would form a cycle +// (defaultComponents -> ChannelDetails*/ChannelPreview* -> this file) and corrupt init order. let cachedDefaultIcons: IconsMap | undefined; const getDefaultIcons = (): IconsMap => { if (!cachedDefaultIcons) { @@ -81,8 +79,7 @@ export const buildDefaultChannelMemberActionItems: BuildDefaultChannelMemberActi const actionItems: ChannelMemberActionItem[] = []; - // Muting or blocking yourself is meaningless, so these actions are only - // added for other members. + // Muting or blocking yourself is meaningless, so these actions are only for other members. if (!isCurrentUser) { actionItems.push( { diff --git a/package/src/hooks/messagePreview/useGroupedAttachments.ts b/package/src/hooks/messagePreview/useGroupedAttachments.ts index b60f0d43f2..d57bd7a6b9 100644 --- a/package/src/hooks/messagePreview/useGroupedAttachments.ts +++ b/package/src/hooks/messagePreview/useGroupedAttachments.ts @@ -17,15 +17,15 @@ type GroupedAttachments = { images: Attachment[]; videos: Attachment[]; files: Attachment[]; - // Narrowed by `isVoiceRecordingAttachment` below so consumers can read `.custom.duration` - // without re-asserting the subtype. + // Narrowed by `isVoiceRecordingAttachment` so consumers can read `.custom.duration`. voiceRecordings: VoiceRecordingAttachment[]; }; /** - * Hook to group attachments by type. - * @param {Attachment[]} attachments - * @returns {GroupedAttachments} + * Groups attachments by type. Attachments matching no group are dropped. + * + * @param attachments The attachments to group. + * @returns The attachments per type. */ export const useGroupedAttachments = (attachments?: Attachment[]) => { return useMemo(() => { diff --git a/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts b/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts index f3f571eb87..b890afc11a 100644 --- a/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts +++ b/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts @@ -20,9 +20,7 @@ export enum MessageDeliveryStatus { type MessageDeliveryStatusProps = { channel: Channel; - // Only `created_at`/`id`/`user` are read here (never the LocalMessage-only `status`), so a - // plain `MessageResponse` is accepted too — lets callers pass the channel's last message - // without asserting it is a `LocalMessage`. + // Only `created_at`, `id` and `user` are read, so a plain `MessageResponse` is accepted too. lastMessage: LocalMessage | MessageResponse; isReadEventsEnabled: boolean; }; @@ -31,11 +29,14 @@ const hasOtherUser = (users: UserResponse[] | undefined, currentUserId: string | !!users && (users.length > 1 || (users.length === 1 && users[0].id !== currentUserId)); /** - * Delivery/read status of the last own message, sourced reactively from the channel's - * `messageReceiptsTracker.snapshotStore` (`readersByMessageId` / `deliveredByMessageId`) — the same - * store the in-list read/delivered receipt hooks use. Replaces the previous manual - * `channel.on('message.new'/'message.delivered'/'message.read')` + `useState` machinery so there is - * a single reactive source of truth for receipts. + * Delivery and read status of the last message when it is the current user's, derived from the + * channel's `messageReceiptsTracker.snapshotStore`, the same store the in-list receipt hooks use. + * + * @param params.channel The channel the message belongs to. + * @param params.lastMessage The channel's last message. + * @param params.isReadEventsEnabled When false, the status is `NOT_SENT_BY_CURRENT_USER`. Defaults + * to true. + * @returns An object with the `status`, `undefined` when the message is not the current user's. */ export const useMessageDeliveryStatus = ({ channel, diff --git a/package/src/hooks/messagePreview/useMessagePreviewText.tsx b/package/src/hooks/messagePreview/useMessagePreviewText.tsx index 3e6f139969..90bbf2d465 100644 --- a/package/src/hooks/messagePreview/useMessagePreviewText.tsx +++ b/package/src/hooks/messagePreview/useMessagePreviewText.tsx @@ -48,10 +48,8 @@ export const useMessagePreviewText = ({ } if (message?.shared_location) { - // Draft messages type `shared_location` loosely, hence the cast. The RESPONSE shape, not the - // request one: the value here came off a message, so `end_at` is the wire number rather than - // the `Date` an outgoing request would carry. `end_at` is optional because a static location - // has no expiry — only a live one does. + // Draft messages type `shared_location` loosely, hence the cast to the response shape, where + // `end_at` is a number. It is optional: only a live location expires. const { end_at: endAt } = message.shared_location as SharedLocationResponseData; if (endAt != null && endAt > nowNs()) { return t('messagePreview.liveLocation.label', 'Live Location'); diff --git a/package/src/hooks/useAfterKeyboardOpenCallback.ts b/package/src/hooks/useAfterKeyboardOpenCallback.ts index 9ac6a78a8e..23590a4556 100644 --- a/package/src/hooks/useAfterKeyboardOpenCallback.ts +++ b/package/src/hooks/useAfterKeyboardOpenCallback.ts @@ -6,18 +6,18 @@ import { useStableCallback } from './useStableCallback'; import { useMessageInputContext } from '../contexts/messageInputContext/MessageInputContext'; /** - * A utility hook that returns a stable callback which focuses the message input - * and invokes the callback once the keyboard is open. + * Returns a stable callback that focuses the message input and runs `callback` once the keyboard is + * open. * - * @param callback - callback we want to run once the keyboard is ready - * @returns A stable callback that will wait for the keyboard to be open before executing. + * @param callback Runs once the keyboard is open. + * @returns A stable callback that waits for the keyboard before running `callback`. */ export const useAfterKeyboardOpenCallback = ( callback: (...args: T) => void, ) => { const { inputBoxRef } = useMessageInputContext(); const keyboardSubscriptionRef = useRef(undefined); - // This callback runs from a keyboard event listener, so it must stay fresh across rerenders. + // Runs from a keyboard event listener, so it must read the latest callback across re-renders. const stableCallback = useStableCallback(callback); /** Clears the pending keyboard listener, if any. */ diff --git a/package/src/hooks/useAppStateListener.ts b/package/src/hooks/useAppStateListener.ts index 309ba23a07..70fcfa72f1 100644 --- a/package/src/hooks/useAppStateListener.ts +++ b/package/src/hooks/useAppStateListener.ts @@ -2,24 +2,23 @@ import { useEffect, useRef } from 'react'; import { AppState, AppStateStatus } from 'react-native'; export const useAppStateListener = (onForeground?: () => void, onBackground?: () => void) => { - // React Native 0.87 widened `AppState.currentState` to `string | null | undefined` (it was - // `AppStateStatus` up to 0.86). Normalise once here so the comparisons below stay total on - // every supported version. + // `AppState.currentState` is `string | null | undefined` from React Native 0.87 and + // `AppStateStatus` before. Normalized so the comparison holds on every supported version. const isBackgroundedRef = useRef( ((AppState.currentState as AppStateStatus | null | undefined) ?? 'unknown') === 'background', ); const onForegroundRef = useRef(onForeground); const onBackgroundRef = useRef(onBackground); - // setting refs to avoid passing the functions as dependencies to useEffect + // Refs keep the handlers out of the effect's dependencies. onForegroundRef.current = onForeground; onBackgroundRef.current = onBackground; useEffect(() => { const handleAppStateChange = (nextAppState: AppStateStatus) => { - // Only `background` is backgrounded. `inactive` is visible-but-unfocused, and from iOS 27 an - // app in Split View stays `inactive` the whole time it is side by side. Transitions pass - // through `inactive` in both directions, so ignoring it still observes both. + // Only `background` counts as backgrounded. `inactive` is visible-but-unfocused, and from + // iOS 27 an app in Split View stays `inactive` the whole time it is side by side. Transitions + // pass through `inactive` in both directions, so ignoring it still observes both. if (nextAppState === 'background') { if (!isBackgroundedRef.current) { isBackgroundedRef.current = true; @@ -36,8 +35,7 @@ export const useAppStateListener = (onForeground?: () => void, onBackground?: () const subscription = AppState.addEventListener('change', handleAppStateChange); return () => { - // Following if-else logic is to support RN >= 0.65 and RN < 0.65 versions. - // https://github.com/react-native-community/releases/blob/master/CHANGELOG.md#:~:text=EventEmitter%23removeSubscription%20is%20now%20deprecated.%20(cb6cbd12f8%20by%20%40yungsters) + // React Native < 0.65 has no `subscription.remove`; fall back to `removeEventListener`. if (subscription?.remove) { subscription.remove(); } else { diff --git a/package/src/hooks/useAttachmentPickerBottomSheet.ts b/package/src/hooks/useAttachmentPickerBottomSheet.ts index 6841934731..1adbd17965 100644 --- a/package/src/hooks/useAttachmentPickerBottomSheet.ts +++ b/package/src/hooks/useAttachmentPickerBottomSheet.ts @@ -4,11 +4,10 @@ import BottomSheet from '@gorhom/bottom-sheet'; import { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; /** - * This hook is used to manage the state of the attachment picker bottom sheet. - * It provides functions to open and close the bottom sheet, as well as a reference to the bottom sheet itself. - * The bottom sheet is used to display the attachment picker UI. - * The `openPicker` function opens the bottom sheet, and the `closePicker` function closes it. - * The `bottomSheetRef` is a reference to the bottom sheet component, which allows for programmatic control of the bottom sheet. + * Manages the attachment picker bottom sheet: a ref to the sheet plus `openPicker` and + * `closePicker`, which open or close the sheet behind the ref they are given. + * + * @returns The `bottomSheetRef`, `openPicker` and `closePicker`. */ export const useAttachmentPickerBottomSheet = () => { const bottomSheetRef = useRef(null); diff --git a/package/src/hooks/useAudioPlayer.ts b/package/src/hooks/useAudioPlayer.ts index 6b3e19a6cd..3e2d5d034d 100644 --- a/package/src/hooks/useAudioPlayer.ts +++ b/package/src/hooks/useAudioPlayer.ts @@ -8,16 +8,9 @@ import { AudioPlayerErrorCode, AudioPlayerOptions } from '../state-store/audio-p export type UseAudioPlayerProps = { /** - * Identifier of the entity that requested the audio playback, e.g. message ID. - * Asset to specific audio player is a many-to-many relationship - * - one URL can be associated with multiple UI elements, - * - one UI element can display multiple audio sources. - * Therefore, the AudioPlayer ID is a combination of request:src. - * - * The requester string can take into consideration whether there are multiple instances of - * the same URL requested by the same requester (message has multiple attachments with the same asset URL). - * In reality the fact that one message has multiple attachments with the same asset URL - * could be considered a bad practice or a bug. + * Identifier of the entity that requested the playback, such as a message ID. It scopes the + * audio player ID (`requester:src:id`), as one URL can back several UI elements and one UI + * element can show several sources. */ requester?: string; } & Partial; diff --git a/package/src/hooks/useCanAddMembersToChannel.ts b/package/src/hooks/useCanAddMembersToChannel.ts index 4a4f315e62..1fb347210e 100644 --- a/package/src/hooks/useCanAddMembersToChannel.ts +++ b/package/src/hooks/useCanAddMembersToChannel.ts @@ -4,6 +4,9 @@ import { useChannelOwnCapabilities } from './useChannelOwnCapabilities'; /** * Whether the current user can add members to the channel. + * + * @param channel The channel to check. + * @returns True when the user has the `update-channel-members` capability. */ export const useCanAddMembersToChannel = (channel?: Channel) => useChannelOwnCapabilities(channel)?.includes('update-channel-members') ?? false; diff --git a/package/src/hooks/useCanEditChannel.ts b/package/src/hooks/useCanEditChannel.ts index 07da9b5c0d..1379f407a6 100644 --- a/package/src/hooks/useCanEditChannel.ts +++ b/package/src/hooks/useCanEditChannel.ts @@ -3,7 +3,10 @@ import type { Channel } from 'stream-chat'; import { useChannelOwnCapabilities } from './useChannelOwnCapabilities'; /** - * Whether the current user can update (edit) the channel. + * Whether the current user can update the channel. + * + * @param channel The channel to check. + * @returns True when the user has the `update-channel` capability. */ export const useCanEditChannel = (channel?: Channel) => useChannelOwnCapabilities(channel)?.includes('update-channel') ?? false; diff --git a/package/src/hooks/useChannel.ts b/package/src/hooks/useChannel.ts index 29261950d7..56d6394203 100644 --- a/package/src/hooks/useChannel.ts +++ b/package/src/hooks/useChannel.ts @@ -4,12 +4,11 @@ import { useChannelContext } from '../contexts/channelContext/ChannelContext'; import { useThreadContext } from '../contexts/threadContext/ThreadContext'; /** - * Resolves the active `Channel` instance: the thread's channel when rendered inside a - * thread, otherwise the `ChannelContext` channel. + * Resolves the active channel: the thread's channel inside a ``, otherwise the + * `ChannelContext` channel. Prefer it over `useChannelContext()` in code shared between the main + * list and a thread. * - * Thread-aware analogue of stream-chat-react's `useChannel()`. Prefer this over reading - * `channel` from `useChannelContext()` directly in message/thread interaction code, so the - * same code path targets the correct channel in both the main list and a thread. + * @returns The active channel. */ export const useChannel = (): Channel => { const { channel } = useChannelContext(); diff --git a/package/src/hooks/useChannelImage.ts b/package/src/hooks/useChannelImage.ts index 5c92fa214a..203d6d8b1b 100644 --- a/package/src/hooks/useChannelImage.ts +++ b/package/src/hooks/useChannelImage.ts @@ -7,9 +7,13 @@ const selector = (state: { data: Channel['data'] }) => ({ }); /** - * Returns the channel's image, sourced reactively from `channel.state` (updates on `channel.updated`). + * Returns the channel's image, read reactively from `channel.state` (updates on `channel.updated`). + * + * @param channel The channel to read from. + * @returns The channel image. */ export function useChannelImage(channel: Channel): string | undefined; +/** Same as above, for a channel that may be undefined. */ export function useChannelImage(channel?: Channel): string | undefined; export function useChannelImage(channel?: Channel) { return useStateStore(channel?.state, selector)?.image; diff --git a/package/src/hooks/useChannelMemberCount.ts b/package/src/hooks/useChannelMemberCount.ts index e6cf428b99..86241c0963 100644 --- a/package/src/hooks/useChannelMemberCount.ts +++ b/package/src/hooks/useChannelMemberCount.ts @@ -7,10 +7,14 @@ const selector = (state: { memberCount: number }) => ({ }); /** - * Returns the channel's member count, sourced reactively from `channel.state` (updates on + * Returns the channel's member count, read reactively from `channel.state` (updates on * `channel.updated`). + * + * @param channel The channel to read from. + * @returns The member count. */ export function useChannelMemberCount(channel: Channel): number; +/** Same as above, for a channel that may be undefined. */ export function useChannelMemberCount(channel?: Channel): number | undefined; export function useChannelMemberCount(channel?: Channel) { return useStateStore(channel?.state, selector)?.memberCount; diff --git a/package/src/hooks/useChannelMembershipState.ts b/package/src/hooks/useChannelMembershipState.ts index 541cb0b100..b4083fb7f5 100644 --- a/package/src/hooks/useChannelMembershipState.ts +++ b/package/src/hooks/useChannelMembershipState.ts @@ -7,10 +7,14 @@ const selector = (state: { membership: ChannelMemberResponse }) => ({ }); /** - * Returns the current user's membership for the channel, sourced reactively from `channel.state` + * Returns the current user's membership for the channel, read reactively from `channel.state` * (updates on `member.added`/`member.updated`). + * + * @param channel The channel to read from. + * @returns The current user's membership. */ export function useChannelMembershipState(channel: Channel): ChannelMemberResponse; +/** Same as above, for a channel that may be undefined. */ export function useChannelMembershipState(channel?: Channel): ChannelMemberResponse | undefined; export function useChannelMembershipState(channel?: Channel) { return useStateStore(channel?.state, selector)?.membership; diff --git a/package/src/hooks/useChannelName.ts b/package/src/hooks/useChannelName.ts index 5a7584a113..1e1440ef26 100644 --- a/package/src/hooks/useChannelName.ts +++ b/package/src/hooks/useChannelName.ts @@ -7,9 +7,13 @@ const selector = (state: { data: Channel['data'] }) => ({ }); /** - * Returns the channel's name, sourced reactively from `channel.state` (updates on `channel.updated`). + * Returns the channel's name, read reactively from `channel.state` (updates on `channel.updated`). + * + * @param channel The channel to read from. + * @returns The channel name. */ export function useChannelName(channel: Channel): string | undefined; +/** Same as above, for a channel that may be undefined. */ export function useChannelName(channel?: Channel): string | undefined; export function useChannelName(channel?: Channel) { return useStateStore(channel?.state, selector)?.name; diff --git a/package/src/hooks/useChannelOwnCapabilities.ts b/package/src/hooks/useChannelOwnCapabilities.ts index 21e4f0963b..16cd1b6a5e 100644 --- a/package/src/hooks/useChannelOwnCapabilities.ts +++ b/package/src/hooks/useChannelOwnCapabilities.ts @@ -7,8 +7,10 @@ const selector = (state: { ownCapabilities: string[] }) => ({ }); /** - * Returns the current user's capabilities for the channel, sourced reactively from - * `channel.state`. + * Returns the current user's capabilities for the channel, read reactively from `channel.state`. + * + * @param channel The channel to read from. + * @returns The own capabilities. */ export function useChannelOwnCapabilities(channel?: Channel): string[] | undefined { return useStateStore(channel?.state, selector)?.ownCapabilities; diff --git a/package/src/hooks/useClientNotifications.ts b/package/src/hooks/useClientNotifications.ts index dca807148e..50dd98030c 100644 --- a/package/src/hooks/useClientNotifications.ts +++ b/package/src/hooks/useClientNotifications.ts @@ -9,9 +9,9 @@ const selector = (state: NotificationManagerState) => ({ }); /** - * This hook is used to get the notifications from the client. - * @returns {Object} - An object containing the notifications. - * @returns {Notification[]} notifications - The notifications. + * Returns the notifications held by the client's notification manager. + * + * @returns An object with the `notifications`. */ export const useClientNotifications = () => { const { client } = useChatContext(); diff --git a/package/src/hooks/useHorizontalInsets.ts b/package/src/hooks/useHorizontalInsets.ts index d28772b310..fdce3060e5 100644 --- a/package/src/hooks/useHorizontalInsets.ts +++ b/package/src/hooks/useHorizontalInsets.ts @@ -5,24 +5,18 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; /** * Horizontal safe area padding for a surface that spans the full window width. * - * **Only for surfaces an integrator cannot wrap.** Everything the SDK renders in-tree is left - * alone: the integrator wraps their screen in a `SafeAreaView` and the whole subtree - including - * absolutely positioned children, which track their containing block's border box - narrows with - * it. Applying this to an in-tree surface double-pads it for every integrator who already wraps. + * Only for surfaces an integrator cannot wrap, such as React Native's `Modal`, which renders in a + * separate native window. In-tree surfaces already narrow with the integrator's `SafeAreaView`, so + * this would double-pad them; a bottom sheet or overlay qualifies only if it mounts outside that + * tree. Prefer a `SafeAreaView` where one fits (see `ImageGalleryHeader`): it compares the window + * inset with its own frame, whereas this hook reads the window-global `useSafeAreaInsets` and has + * no such protection when nested. * - * In practice that leaves React Native's own `Modal`, which renders in a separate native window. - * A bottom sheet or an overlay is *not* automatically out of reach - check where it actually - * mounts. Prefer a `SafeAreaView` (see `ImageGalleryHeader`/`Footer`) wherever one fits: it - * compares the window inset against its own measured frame, so a nested instance contributes - * nothing once an ancestor has narrowed the subtree. This hook reads `useSafeAreaInsets`, which is - * window-global and has no such protection. + * `left` and `right` stay separate because from iOS 27 the system can stack its controls down a + * single edge (measured 0 and 84 on iPhone Duo). A side whose inset is 0 is omitted, as a + * `paddingLeft: 0` longhand would override a `padding` shorthand set elsewhere. * - * `left` and `right` are never collapsed into one value: from iOS 27 the system stacks its controls - * down a single edge, so the two differ (measured 0 and 84 on iPhone Duo). - * - * A side whose inset is 0 is omitted rather than emitted as `paddingLeft: 0`, because a longhand - * overrides a `padding` shorthand for that side - emitting a zero would silently drop padding the - * component or a consumer's theme had already set. + * @returns A style with `paddingLeft` and `paddingRight` for the non-zero insets. */ export const useHorizontalInsets = (): ViewStyle => { const { left, right } = useSafeAreaInsets(); diff --git a/package/src/hooks/useIsChannelMember.ts b/package/src/hooks/useIsChannelMember.ts index 58f8e4ddae..2b8dd35f54 100644 --- a/package/src/hooks/useIsChannelMember.ts +++ b/package/src/hooks/useIsChannelMember.ts @@ -5,7 +5,11 @@ import type { Channel } from 'stream-chat'; import { useChannelMembersState } from '../components/ChannelList/hooks/useChannelMembersState'; /** - * Determines whether the user with the given id is already a member of the channel. + * Whether the user with the given ID is a member of the channel. + * + * @param channel The channel to check. + * @param userId The ID of the user. Without it the result is false. + * @returns True when the user is a member. */ export const useIsChannelMember = (channel: Channel, userId?: string) => { const members = useChannelMembersState(channel); diff --git a/package/src/hooks/useIsMountedRef.ts b/package/src/hooks/useIsMountedRef.ts index 831043527a..c0a8546133 100644 --- a/package/src/hooks/useIsMountedRef.ts +++ b/package/src/hooks/useIsMountedRef.ts @@ -1,8 +1,7 @@ import { useEffect, useRef } from 'react'; /** - * Returns mount status of component. This hook can be used for purpose of avoiding any - * setState calls (within async operation) after component gets unmounted. + * Returns a ref that turns false once the component unmounts, to skip state updates in async work. * * @example * ``` @@ -16,13 +15,10 @@ import { useEffect, useRef } from 'react'; * }) * ``` * - * @returns isMounted {Object} Mount status ref for the component. + * @returns A ref whose `current` is true until the component unmounts. */ export const useIsMountedRef = () => { - // Initial value has been set to true, since this hook exist only for sole purpose of - // avoiding any setState calls (within async operation) after component gets unmounted. - // Basically we don't need to know about state change from component being initialized -> mounted. - // We only need a state change from initialized or mounted state -> unmounted state. + // Starts true: only the transition to unmounted matters, not the one from initialized to mounted. const isMounted = useRef(true); useEffect( diff --git a/package/src/hooks/useIsSvg.ts b/package/src/hooks/useIsSvg.ts index 9efe93cc2a..787006c327 100644 --- a/package/src/hooks/useIsSvg.ts +++ b/package/src/hooks/useIsSvg.ts @@ -1,10 +1,11 @@ import { useMemo } from 'react'; /** - * Returns true if `uri` points to an SVG (either by `.svg` extension or by - * `image/svg+xml` data-URI prefix). Exported as a pure function for non-React - * callers; React components should prefer `useIsSvg` so the check is memoized - * per URI. + * Whether `uri` points to an SVG, by `.svg` extension (ignoring query and fragment) or by + * `image/svg+xml` data URI. The non-hook form of `useIsSvg`. + * + * @param uri The URI to check. + * @returns True when the URI is an SVG. */ export const isSvgUri = (uri: string | null | undefined): boolean => { if (typeof uri !== 'string' || uri.length === 0) { @@ -19,10 +20,10 @@ export const isSvgUri = (uri: string | null | undefined): boolean => { }; /** - * Memoized variant of `isSvgUri`. Re-runs the string check only when `uri` - * actually changes — useful in hot render paths like the gallery where the - * surrounding component re-renders frequently (animated styles, swipe state) - * but the URI is stable. + * Memoized `isSvgUri`, re-evaluated only when `uri` changes. + * + * @param uri The URI to check. + * @returns True when the URI is an SVG. */ export const useIsSvg = (uri: string | null | undefined): boolean => useMemo(() => isSvgUri(uri), [uri]); diff --git a/package/src/hooks/useIsTargetedMessage.ts b/package/src/hooks/useIsTargetedMessage.ts index f50bf5db5a..58066e596b 100644 --- a/package/src/hooks/useIsTargetedMessage.ts +++ b/package/src/hooks/useIsTargetedMessage.ts @@ -6,14 +6,14 @@ import { useStateStore } from './useStateStore'; type MessageFocusSignalState = { signal: { messageId?: string } | null }; /** - * Whether this message is the one the list is currently focused on — the target of a - * `jumpToMessage` / `jumpToTheFirstUnreadMessage`, for the lifetime of the signal's TTL. + * Whether the message is the target of the active paginator's `messageFocusSignal` (set by + * `jumpToMessage` and `jumpToTheFirstUnreadMessage`, for the signal's TTL). * - * Read per row, off the active paginator's `messageFocusSignal`, rather than from a - * `highlightedMessageId` on the channel context. Both cost one subscription per row in practice - * (every row already consumes that context), but a context value that changes on every jump - * re-renders every row; a boolean scoped to one id re-renders only the row losing the highlight - * and the row gaining it. + * Each row selects its own boolean, so a jump re-renders only the row losing the highlight and the + * row gaining it, not every row as a shared context value would. + * + * @param messageId The ID of the message. + * @returns True when the message is the focus target. */ export const useIsTargetedMessage = (messageId: string) => { const paginator = useMessagePaginator(); diff --git a/package/src/hooks/useKeyboardVisibility.ts b/package/src/hooks/useKeyboardVisibility.ts index 2218f6321a..a8686fc3b0 100644 --- a/package/src/hooks/useKeyboardVisibility.ts +++ b/package/src/hooks/useKeyboardVisibility.ts @@ -2,8 +2,9 @@ import { useEffect, useState } from 'react'; import { Keyboard, Platform } from 'react-native'; /** - * A custom hook that provides a boolean value indicating whether the keyboard is visible. - * @returns A boolean value indicating whether the keyboard is visible. + * Tracks whether the keyboard is visible. + * + * @returns True while the keyboard is visible. */ export const useKeyboardVisibility = () => { const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); diff --git a/package/src/hooks/useLazyRef.ts b/package/src/hooks/useLazyRef.ts index a7e9ea61b8..5ee7a71c68 100644 --- a/package/src/hooks/useLazyRef.ts +++ b/package/src/hooks/useLazyRef.ts @@ -1,8 +1,10 @@ import { type RefObject, useRef } from 'react'; /** - * Creates a stable ref whose initial value is computed only once. - * Use this when the initial ref value is expensive or allocates an object. + * Creates a stable ref whose initial value is computed once, for values that are costly to build. + * + * @param getInitialValue Computes the initial value on the first render. Must not return `null`. + * @returns The ref holding the value. */ export const useLazyRef = (getInitialValue: () => T) => { const ref = useRef(null); diff --git a/package/src/hooks/useLoadingImage.tsx b/package/src/hooks/useLoadingImage.tsx index 18383cd5e1..39e53a5f6e 100644 --- a/package/src/hooks/useLoadingImage.tsx +++ b/package/src/hooks/useLoadingImage.tsx @@ -52,13 +52,13 @@ export const useLoadingImage = () => { ); const isNetworkOnline = useNetworkConnectionState()?.isOnline; - // storing the value of isLoadingImageError in a ref to avoid passing as a dep to useEffect + // A ref keeps `isLoadingImageError` out of the effect's dependencies. const hasImageLoadedErroredRef = useRef(isLoadingImageError); hasImageLoadedErroredRef.current = isLoadingImageError; useEffect(() => { if (isNetworkOnline && hasImageLoadedErroredRef.current) { - // if there was an error previously, reload the image automatically when user comes back online + // Retry a failed image automatically when the network comes back. onReloadImageRef.current(); } }, [isNetworkOnline]); diff --git a/package/src/hooks/useMessagePaginator.ts b/package/src/hooks/useMessagePaginator.ts index 25fb0072b1..7a51fe71be 100644 --- a/package/src/hooks/useMessagePaginator.ts +++ b/package/src/hooks/useMessagePaginator.ts @@ -2,8 +2,10 @@ import { useChannelContext } from '../contexts/channelContext/ChannelContext'; import { useThreadContext } from '../contexts/threadContext/ThreadContext'; /** - * The message paginator this subtree is rendering: the thread's reply list inside a ``, the - * channel's main list otherwise. + * The message paginator this subtree renders: the thread's inside a ``, otherwise the + * channel's. + * + * @returns The active message paginator. */ export const useMessagePaginator = () => { const { channel } = useChannelContext(); diff --git a/package/src/hooks/usePendingAttachmentUpload.ts b/package/src/hooks/usePendingAttachmentUpload.ts index 048e8e0a19..fa6bbed030 100644 --- a/package/src/hooks/usePendingAttachmentUpload.ts +++ b/package/src/hooks/usePendingAttachmentUpload.ts @@ -7,11 +7,11 @@ import { useStateStore } from './useStateStore'; import { useChatContext } from '../contexts/chatContext/ChatContext'; export type PendingAttachmentUpload = { - /** True when `client.uploadManager` has an in-flight upload for this attachment local id. */ + /** Whether `client.uploadManager` has an in-flight upload for this attachment local ID. */ isUploading: boolean; /** - * Upload percent **0–100** from `client.uploadManager` (same scale as `attachmentManager` - * `onProgress` / `localMetadata.uploadProgress`). `undefined` when not computable or not uploading. + * Upload progress from `client.uploadManager` as a percentage from 0 to 100, the same scale as + * `localMetadata.uploadProgress`. `undefined` when not uploading or not computable. */ uploadProgress: number | undefined; }; @@ -32,7 +32,11 @@ const COMPLETION_READY_PROGRESS = 90; const now = () => Date.now(); /** - * Subscribes to `client.uploadManager` for the pending attachment identified by `localId`. + * Subscribes to `client.uploadManager` for the pending attachment identified by `localId`. An + * upload that finished after reaching 90% keeps reporting `isUploading` at 100% for 350 ms. + * + * @param localId The local ID of the attachment. Without it the upload is reported as idle. + * @returns The upload state of the attachment. */ export function usePendingAttachmentUpload(localId: string | undefined): PendingAttachmentUpload { const { client } = useChatContext(); diff --git a/package/src/hooks/usePortalSettledCallback.ts b/package/src/hooks/usePortalSettledCallback.ts index 64847edb28..b48ff84186 100644 --- a/package/src/hooks/usePortalSettledCallback.ts +++ b/package/src/hooks/usePortalSettledCallback.ts @@ -3,22 +3,10 @@ import { Platform } from 'react-native'; import { useStableCallback } from './useStableCallback'; -/** - * Number of frames we wait before invoking input focus sensitive work after the - * overlay closes. - */ +/** Frames to wait after the overlay closes before running focus-sensitive work (iOS needs none). */ const SETTLE_FRAMES = Platform.OS === 'android' ? 2 : 0; -/** - * Runs a callback after a fixed number of animation frames. - * - * We use RAFs here because the settling work we care about is tied to the next - * rendered frames after the overlay close transition. - * - * @param callback - callback to run once the frame budget has elapsed - * @param frames - number of frames to wait - * @param rafIds - accumulator used for later cancellation/cleanup - */ +/** Runs `callback` after `frames` animation frames, collecting the RAF IDs in `rafIds`. */ const scheduleAfterFrames = (callback: () => void, frames: number, rafIds: number[]) => { if (frames <= 0) { callback(); @@ -30,38 +18,22 @@ const scheduleAfterFrames = (callback: () => void, frames: number, rafIds: numbe }; /** - * Returns a stable callback that is safe to run after a `PortalWhileClosingView` - * has settled back into its original tree. - * - * Some followup actions are sensitive to that handoff window. If they run - * while a view is still being returned from a portal host to its in place host, - * they can target a node that is about to be reattached. On Android, that is - * especially noticeable with focus sensitive work, where the target can lose - * focus again mid keyboard animation. - * - * Two frames are intentional here: - * - frame 1 lets the portal retarget and React commit the component tree - * - frame 2 lets the native view hierarchy settle in its final host - * - * iOS does not currently need this settle window for this flow. - * - * A good example is the message composer edit action: after closing the message - * overlay, we wait for the portal handoff to settle before focusing the input - * and opening the keyboard. Doing this prematurely will result in the keyboard - * being immediately closed. - * - * Another good example would be having a button wrapped in a `PortalWhileClosingView`, - * that possibly renders (or morphs into) something when pressed. Handling `onPress` - * prematurely here may lead to the morphed button rendering into a completely different - * part of the UI hierarchy, causing unknown behaviour. This hook prevents that from - * happening. - * - * @param callback - callback we want to invoke once the portal handoff has settled - * @returns A stable callback gated behind the portal settle window. + * Returns a stable callback that runs once a `PortalWhileClosingView` has settled back into its + * original tree. + * + * While a view returns from a portal host to its in-place host, follow-up work can target a node + * that is about to be reattached. On Android, focus work loses focus again mid keyboard animation + * (focusing the input right after the message overlay closes dismisses the keyboard at once), and + * a pressed button that morphs can render into the wrong part of the hierarchy. The callback waits + * two frames on Android, the first for the portal to retarget and React to commit, the second for + * the native hierarchy to settle. On iOS it runs immediately. + * + * @param callback Runs once the portal handoff has settled. + * @returns A stable callback gated behind the settle window. */ export const usePortalSettledCallback = (callback: (...args: T) => void) => { const rafIdsRef = useRef([]); - // This callback runs from deferred RAF work, so it must stay fresh across rerenders. + // Runs from deferred RAF work, so it must read the latest callback across re-renders. const stableCallback = useStableCallback(callback); const clearScheduledFrames = useStableCallback(() => { diff --git a/package/src/hooks/usePrunableMessageList.ts b/package/src/hooks/usePrunableMessageList.ts index 399f5717b1..ef5ae99a61 100644 --- a/package/src/hooks/usePrunableMessageList.ts +++ b/package/src/hooks/usePrunableMessageList.ts @@ -13,7 +13,7 @@ export type ViewabilityChangedCallbackInput = { inverted: boolean; }; -// The number of messages from an edge we want to be viewing before we stop pruning +// Distance in messages from an edge within which pruning is suspended: 20% of the cap. const calculateSafeGap = (maximumMessages: number) => 0.2 * maximumMessages; const isNearEnd = ({ @@ -37,20 +37,19 @@ const maxLoadedItemsSelector = (state: { maxLoadedItems?: number }) => ({ }); /** - * Drives the message list's window cap. + * Tells the paginator when the message list may prune its oldest messages. * - * The cap itself lives in `stream-chat` — configure it with - * `channel.messagePaginator.updateConfig({ maxLoadedItems })`, or declaratively via - * `client.config.set({ channel: { messagePaginator: { maxLoadedItems } } })`. The paginator drops the - * oldest messages as new ones arrive and re-opens its "load older" edge, so scrolling back re-fetches. + * The cap (`maxLoadedItems`) lives in `stream-chat`: set it with + * `channel.messagePaginator.updateConfig({ maxLoadedItems })` or + * `client.config.set({ channel: { messagePaginator: { maxLoadedItems } } })`. This hook owns + * whether pruning is safe: it tracks the visible range and suspends pruning while the user is near + * the pruned edge. The paginator reads that boolean on ingest and nothing subscribes to it, so + * scrolling costs no render. * - * What this hook owns is the one part the state layer cannot know: **whether pruning is safe right - * now**. Dropping the oldest messages while the user is reading near them would pull content out from - * under them, so viewability is tracked here and pushed down as a single boolean. The paginator reads - * it on ingest; nothing subscribes to it, so scrolling can never cost a render. + * @param params.paginator The message paginator of the list. + * @returns The `maxLoadedItems` cap and the `viewabilityChangedCallback` to wire to the list. */ export function usePrunableMessageList({ paginator }: { paginator?: MessagePaginator }) { - // Track visible index range (index in the rendered list) const visibleRangeConfigRef = useRef({ first: 0, inverted: true, last: -1 }); const { maxLoadedItems } = useStateStore(paginator?.configState, maxLoadedItemsSelector) ?? {}; @@ -74,10 +73,8 @@ export function usePrunableMessageList({ paginator }: { paginator?: MessagePagin }, ); - /** - * The paginator outlives this list, so a suspension must not outlive it either — leaving it set - * would keep the window growing unbounded long after the user navigated away. - */ + // The paginator outlives this list, so a suspension must not: left set, the window would grow + // unbounded after the user navigated away. useEffect(() => () => paginator?.setPruningSuspended(false), [paginator]); return { maxLoadedItems, viewabilityChangedCallback }; diff --git a/package/src/hooks/useQueryReminders.ts b/package/src/hooks/useQueryReminders.ts index 41ba523268..5864f77ca9 100644 --- a/package/src/hooks/useQueryReminders.ts +++ b/package/src/hooks/useQueryReminders.ts @@ -14,18 +14,15 @@ const selector = (nextValue: PaginatorState) => items: nextValue.items, }) as const; -// Utility to sort reminders by remind_at date in ascending order const sortRemindersByDate = (reminders: ReminderResponseData[]) => { return reminders.sort((a, b) => { if (a.remind_at == null || b.remind_at == null) { - return 0; // If either remind_at is missing, keep original order + return 0; // Keep the original order when either remind_at is missing } - // Sort by remind_at date return a.remind_at - b.remind_at; }); }; -// Utility functions to check reminder status const isReminderOverdue = (reminder?: ReminderResponseData) => { return reminder?.remind_at != null && reminder.remind_at < nowNs(); }; @@ -34,24 +31,21 @@ const isReminderUpcoming = (reminder?: ReminderResponseData) => { return reminder?.remind_at != null && reminder.remind_at > nowNs(); }; -// Utility to check if all reminders should be shown based on filters const showAllReminders = (filters?: ReminderFilters) => { return filters && Object.keys(filters).length === 0; }; /** - * Custom hook to query reminders from the Stream Chat client. - * It handles fetching, updating, and deleting reminders, and provides - * a way to refresh the list and load more reminders. + * Queries reminders from the client and keeps the list in sync with reminder events. * - * @returns {Object} - Contains data, isLoading, onEndReached, onRefresh, and setData. + * @returns The reminders as `data`, `isLoading`, `loadNext` to fetch the next page and `setData`. */ export const useQueryReminders = () => { const { client } = useChatContext(); const { isLoading, items } = useStateStore(client.reminders.paginator.state, selector); const [data, setData] = useState(items ?? []); - // The deletion and updates are not handled by the paginator, so we need to cache them - // to avoid showing deleted or updated reminders in the list. + // The paginator does not handle deletions and updates, so they are cached to keep stale + // reminders out of the list. const deletedOrUpdatedRemindersCache = useRef>({}); useEffect(() => { @@ -97,13 +91,13 @@ export const useQueryReminders = () => { const handleReminderUpdated = (event: EventPayload<'reminder.updated'>) => { const { reminder } = event; if (!reminder || showAllReminders(client.reminders.paginator.filters)) { - return; // No update needed if reminder is undefined or filters is empty + return; // Nothing to update without a reminder, or when the filters match everything } deletedOrUpdatedRemindersCache.current[reminder.message_id] = reminder; setData((prevData) => { const existingReminder = prevData.find((item) => item.message_id === reminder?.message_id); if (!existingReminder) { - return prevData; // No update needed if reminder not found + return prevData; } if (existingReminder.remind_at != null && event.reminder?.remind_at == null) { diff --git a/package/src/hooks/useRAFCoalescedValue.ts b/package/src/hooks/useRAFCoalescedValue.ts index eb5a4b3f1d..de5b05c42a 100644 --- a/package/src/hooks/useRAFCoalescedValue.ts +++ b/package/src/hooks/useRAFCoalescedValue.ts @@ -1,45 +1,19 @@ import { useEffect, useRef, useState } from 'react'; /** - * A utility hook that coalesces a fast changing value to the display’s frame rate. - * It accepts any “noisy” input (arrays, objects, numbers, etc.) and exposes a value - * that React consumers will see at most once per animation frame (via - * `requestAnimationFrame`). This is useful when upstream sources (selectors, sockets, - * DB listeners) can fire multiple times within a single paint and you want to avoid - * extra renders and layout churn. + * Coalesces a fast-changing value to at most one update per animation frame, so sources that fire + * several times within a paint (selectors, sockets, DB listeners) cause one render, not many. * - * How it works: - * - Keeps track of the latest incoming value - * - Ensures there is **at most one** pending RAF at a time - * - When the RAF fires, commits the **latest** value to state (`emitted`) - * - If additional changes arrive before the RAF runs, they are merged (the last write - * operation wins) and no new RAF is scheduled + * Intermediate values within a frame are skipped, so it suits visual coalescing, not logic that + * must observe every transition. Equality is referential: a producer that recreates arrays or + * objects still publishes once per frame, so stabilize them upstream. It follows the frame cadence + * and is not a throttle or debounce. A pending frame is cancelled only on unmount, not on value + * changes. * - * With this hook you can: - * - Feed a `FlatList`/`SectionList` from fast changing sources without spamming re-renders - * - Align React updates to the paint cadence (one publish per frame) - * - Help preserve item anchoring logic (e.g., MVCP) by reducing in-frame updates - * - * **Caveats:** - * - This hook intentionally skips intermediate states that occur within the same - * frame. If you must observe every transition (e.g., for analytics/reducers), do that - * upstream; this hook is for visual coalescing - * - Equality checks are simple referential equalities. If your producer recreates arrays - * or objects each time, you’ll still publish once per frame. To avoid even those - * emissions, stabilize upstream - * - This is not a silver bullet for throttle/debounce; it uses the screen’s refresh cycle; - * If you need “no more than once per X ms”, layer that upstream - * - * Usage tips: - * - Prefer passing already-memoized values when possible (e.g., stable arrays by ID). - * - Pair with a stable `keyExtractor` in lists so coalesced updates map cleanly to rows. - * - Do not cancel/reschedule on prop changes; cancellation is handled on unmount only. - * - * @param value The upstream value that may change multiple times within a single frame. - * @param isEnabled Determines whether the hook should be run or not (useful for cases where - * we want to conditionally use RAF when certain feature feature flags are enabled). If `false`, - * it will simply pass the data through (maintaining the reference as well). - * @returns A value that updates **at most once per frame** with the latest input. + * @param value The upstream value that may change several times within a frame. + * @param isEnabled When false, `value` is returned as is with the same reference, to gate the RAF + * behind a feature flag. + * @returns The latest value, updated at most once per frame. */ export const useRAFCoalescedValue = (value: S, isEnabled: boolean | undefined): S => { const [emitted, setEmitted] = useState(value); @@ -51,7 +25,7 @@ export const useRAFCoalescedValue = (value: S, isEnabled: boolean | undefined if (value === pendingRef.current || !isEnabled) return; pendingRef.current = value; - // already scheduled the next frame, skip + // A frame is already scheduled. if (rafIdRef.current) return; const run = () => { @@ -64,7 +38,7 @@ export const useRAFCoalescedValue = (value: S, isEnabled: boolean | undefined useEffect(() => { return () => { - // cancel the frame if it exists only on unmount + // Cancel a pending frame on unmount. if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; diff --git a/package/src/hooks/useScreenDimensions.ts b/package/src/hooks/useScreenDimensions.ts index b361dedfed..6f55343ff7 100644 --- a/package/src/hooks/useScreenDimensions.ts +++ b/package/src/hooks/useScreenDimensions.ts @@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from 'react'; import { Dimensions, ScaledSize } from 'react-native'; /** - * A custom hook that provides functions to calculate dimensions based on - * a percentage of the screen height (vh) and viewport width (vw). It - * dynamically updates dimensions on changes in device orientation. + * Returns `vh` and `vw` helpers that convert a percentage of the screen height or width to points, + * updating when the screen dimensions change. + * + * @param rounded Rounds the results to whole points. Defaults to false. * * @deprecated These are percentages of the *screen*, not of the window the app is laid out in. The * two diverge once the window is resizable (Split View, fold/unfold, Android multi-window): we @@ -13,7 +14,7 @@ import { Dimensions, ScaledSize } from 'react-native'; * Use {@link useViewport}, the same API measured against the window, or `onLayout` on your own view * inside a component that does not fill the window. * - * @returns {Object} An object containing functions vh and vw. + * @returns An object with the `vh` and `vw` functions. */ export const useScreenDimensions = (rounded?: boolean) => { const [screenDimensions, setScreenDimensions] = useState(() => Dimensions.get('screen')); @@ -30,10 +31,8 @@ export const useScreenDimensions = (rounded?: boolean) => { }; const subscription = Dimensions.addEventListener('change', handleChange); - // We might have missed an update between calling `get` in render and - // `addEventListener` in this handler, so we set it here. If there was - // no change, React will filter out this update as a no-op. - // pattern ref: react-native-repo/packages/react-native/Libraries/Utilities/useWindowDimensions.js + // An update may have landed between `get` in render and `addEventListener`, so re-read here. + // An unchanged value is a no-op for React. handleChange({ screen: Dimensions.get('screen') }); return () => { diff --git a/package/src/hooks/useScreenOrientation.ts b/package/src/hooks/useScreenOrientation.ts index f7ae97022c..bccd3b6cf5 100644 --- a/package/src/hooks/useScreenOrientation.ts +++ b/package/src/hooks/useScreenOrientation.ts @@ -3,11 +3,9 @@ import { useWindowDimensions } from 'react-native'; export type ScreenOrientation = 'landscape' | 'portrait'; /** - * A custom hook that derives the current screen orientation from the window - * dimensions. It updates automatically on device rotation via - * `useWindowDimensions`. + * Derives the orientation from the window dimensions, updating on rotation. * - * @returns {ScreenOrientation} Either `'landscape'` or `'portrait'`. + * @returns `'landscape'` when the window is wider than tall, otherwise `'portrait'`. */ export const useScreenOrientation = (): ScreenOrientation => { const { height, width } = useWindowDimensions(); diff --git a/package/src/hooks/useStableCallback.ts b/package/src/hooks/useStableCallback.ts index 8ef86ffb05..934e51752d 100644 --- a/package/src/hooks/useStableCallback.ts +++ b/package/src/hooks/useStableCallback.ts @@ -3,27 +3,17 @@ import { useCallback, useRef } from 'react'; export type StableCallback = (...args: A) => R; /** - * A utility hook implementing a stable callback. It takes in an unstable method that - * is supposed to be invoked somewhere deeper in the DOM tree without making it - * change its reference every time the parent component rerenders. It will also return - * the value of the callback if it does return one. - * A common use-case would be having a function whose invocation depends on state - * somewhere high up in the DOM tree and wanting to use the same function deeper - * down, for example in a leaf node and simply using useCallback results in - * cascading dependency hell. If we wrap it in useStableCallback, we would be able - * to: - * - Use the same function as a dependency of another hook (since it is stable) - * - Still invoke it and get the latest state + * Returns a function with a stable reference that always calls the latest `callback` and returns + * its result. It can be a dependency of other hooks without invalidating them on every parent + * render. * - * **Caveats:** - * - Never wrap a function that is supposed to return a React.ReactElement in - * useStableCallback, since React will not know that the DOM needs to be updated - * whenever the callback value changes (for example, renderItem from FlatList must - * never be wrapped in this hook) - * - Always prefer using a standard useCallback/stable function wherever possible - * (the purpose of useStableCallback is to bridge the gap between top level contexts - * and cascading rereders in downstream components - **not** as an escape hatch) - * @param callback - the callback we want to stabilize + * Do not wrap a function that returns elements (such as FlatList's `renderItem`): React would not + * see an update when the callback changes. Prefer a plain `useCallback` where it works; this + * bridges top-level contexts and cascading re-renders in downstream components and is not an + * escape hatch. + * + * @param callback The callback to stabilize. + * @returns The stable callback. */ export const useStableCallback = ( callback: StableCallback, diff --git a/package/src/hooks/useStreami18n.ts b/package/src/hooks/useStreami18n.ts index a5af702eed..00422897db 100644 --- a/package/src/hooks/useStreami18n.ts +++ b/package/src/hooks/useStreami18n.ts @@ -10,11 +10,9 @@ import type { TranslationCatalog } from '../i18n/types'; import { Streami18n } from '../utils/i18n/Streami18n'; /** - * This SDK's instantiation of core's state shape. - * - * Spelled out rather than left to `Streami18nState`'s defaults: those default the catalog to - * `AnyTranslationCatalog`, and `t` is contravariant in its options, so the concrete store is not - * assignable to the default-parameterized one. + * This SDK's instantiation of the `@stream-io/i18n` state shape. `Streami18nState` defaults its + * catalog to `AnyTranslationCatalog`, and `t` is contravariant in its options, so the concrete + * store is not assignable to the default-parameterized type. */ type SDKStreami18nState = Streami18nState; diff --git a/package/src/hooks/useTranslatedMessage.ts b/package/src/hooks/useTranslatedMessage.ts index 18f2ee3e48..fb3e6d6a7f 100644 --- a/package/src/hooks/useTranslatedMessage.ts +++ b/package/src/hooks/useTranslatedMessage.ts @@ -3,17 +3,19 @@ import type { LocalMessage, MessageResponse } from 'stream-chat'; import { useTranslationContext } from '../contexts/translationContext/TranslationContext'; /** - * The key auto-translated text lands under in `message.i18n`. - * - * Built from the UI language, which is any string an integrator registered, while `message.i18n` - * only ever carries the languages the auto-translation endpoint supports. The `in` check below is - * what reconciles the two: a UI language outside that set simply finds nothing and falls through - * to the original text, which is the correct behaviour. + * The key under which auto-translated text lives in `message.i18n`. The UI language can be any + * registered string, while `message.i18n` only carries the languages the auto-translation endpoint + * supports: the `in` check below makes an unsupported language fall through to the original text. */ type TranslationKey = `${string}_text`; /** - * `message` with its text swapped for the auto-translation into `userLanguage`, when there is one. + * Returns a copy of `message` with its text swapped for the auto-translation into `userLanguage`, + * when there is one. Deleted messages keep their text. + * + * @param message The message to translate. + * @param userLanguage The language to translate into. + * @returns The message copy, or `undefined` without a message. */ export const getTranslatedMessage = ( message: LocalMessage | MessageResponse | undefined, diff --git a/package/src/hooks/useViewport.ts b/package/src/hooks/useViewport.ts index 7f1f887c71..ae203e3b5b 100644 --- a/package/src/hooks/useViewport.ts +++ b/package/src/hooks/useViewport.ts @@ -2,11 +2,11 @@ import { useCallback } from 'react'; import { useWindowDimensions } from 'react-native'; /** - * A custom hook that provides functions to calculate dimensions based on - * a percentage of the viewport height (vh) and viewport width (vw). It - * dynamically updates dimensions on changes in device orientation. + * Returns `vh` and `vw` helpers that convert a percentage of the window height or width to points, + * updating when the window dimensions change. * - * @returns {Object} An object containing functions vh and vw. + * @param rounded Rounds the results to whole points. Defaults to false. + * @returns An object with the `vh` and `vw` functions. */ export const useViewport = (rounded?: boolean) => { const viewportDimensions = useWindowDimensions(); diff --git a/package/src/hooks/useWindowContentWidth.ts b/package/src/hooks/useWindowContentWidth.ts index 1ed823826e..8ffbb69108 100644 --- a/package/src/hooks/useWindowContentWidth.ts +++ b/package/src/hooks/useWindowContentWidth.ts @@ -3,21 +3,19 @@ import { useSafeAreaFrame, useSafeAreaInsets } from 'react-native-safe-area-cont /** * Width available to a surface that spans the window, minus the horizontal safe area. * - * Deliberately *not* measured. `onLayout` reports a frame after the window has already resized, so - * anything sized from it - tile sizes in a grid, for instance - visibly resizes a frame late on a - * rotation or a fold. + * The width is computed rather than measured: `onLayout` reports a frame after the window resizes, + * so anything sized from it (such as grid tiles) resizes a frame late on rotation or fold. * - * The frame comes from `useSafeAreaFrame`, not `useWindowDimensions`: the frame and the insets are - * delivered by the *same* native event and set in the same handler, so they update together in one - * render. Mixing `Dimensions` with the insets desyncs them - the window width lands first and the - * insets a render later, producing an extra intermediate size (and, for anything deriving an image - * URL from it, an extra fetch). + * The frame comes from `useSafeAreaFrame`, not `useWindowDimensions`: the frame and the insets + * arrive in the same native event and update in one render. Mixing `Dimensions` with the insets + * lands the width a render before the insets, giving an extra intermediate size (and an extra fetch + * for anything that derives an image URL from it). * - * Assumes the caller fills the window. A component rendered inside a narrower container must pass - * that container's width via `containerWidth`, or measure it itself and accept the frame of lag - * that comes with measuring. + * Assumes the caller fills the window. A component inside a narrower container passes that + * container's width via `containerWidth`. * * @param containerWidth Width of the surface, when it is known and narrower than the window. + * @returns The available width. * * @example * const contentWidth = useWindowContentWidth(); diff --git a/package/src/i18n/runtimeDefaults.ts b/package/src/i18n/runtimeDefaults.ts index b96c0a47f4..114b56d297 100644 --- a/package/src/i18n/runtimeDefaults.ts +++ b/package/src/i18n/runtimeDefaults.ts @@ -1,38 +1,33 @@ /** - * The only translation data bundled with the SDK. Hand-maintained. + * The only translation data bundled with the SDK, maintained by hand. * - * Prose keys are not here — they pass their English copy inline at the call site - * (`t('message.status.sent.text', 'Sent')`). These keys have no inline copy to fall back on: - * they are formatter expressions, passed around as prop values and resolved by name at runtime - * (`timestampTranslationKey`), so the extractor never sees a literal call site for them. + * Prose keys are not here: they pass their English copy inline at the call site. These keys have no + * inline copy because they are formatter expressions passed around as prop values and resolved by + * name at runtime (`timestampTranslationKey`), so the extractor never sees a literal call site. * - * The leaf segment is the consuming component's name and stays PascalCase, matching the web SDK - * and the `timestamp/` values these keys replace. Every other namespace is lower - * camelCase. + * The leaf segment is the consuming component's name and stays PascalCase; every other namespace is + * lower camelCase. * - * Two namespaces are typed into the catalog but supplied by `@stream-io/i18n` rather than declared - * here. `language.*` (ISO language names, generated from core's `TranslationLanguage` union) is - * merged in by the `Streami18n` subclass in `utils/i18n/Streami18n.ts`, underneath this file, so an - * individual name stays overridable. `relativeTime.*` needs no data at all: core's - * `timestampFormatter(relativeCompact: true)` passes its English inline the same way a call site - * does. There is - * no `translationBuilderTopic.*` — this SDK registers no i18next postProcessor; the formatter - * registry in `@stream-io/i18n` is the whole extension surface. + * `language.*` and `relativeTime.*` are typed into the catalog but not declared here. `language.*` + * comes from `stream-chat` and is merged in by the `Streami18n` subclass in + * `utils/i18n/Streami18n.ts`, underneath this file, so individual names stay overridable. + * `relativeTime.*` needs no data: `@stream-io/i18n`'s `timestampFormatter(relativeCompact: true)` + * passes its English inline. The SDK registers no i18next postProcessor, so there is no + * `translationBuilderTopic.*`. * * `yarn build-translations` joins this file with the inline defaults to generate * `src/i18n/keys.ts`; `yarn i18n:export` writes the joined catalog as JSON. * - * Three entries hide English inside a formatter expression, so `dayjsLocaleConfigForLanguage` - * cannot reach them and an integrator has to override the key itself: + * Three entries hide English inside a formatter expression, out of reach of + * `dayjsLocaleConfigForLanguage`, so an integrator overrides the key itself: * * - `timestamp.ChannelPreviewStatus` and `timestamp.ThreadListItem` carry day words inside their * own `calendarFormats` argument. A per-key `calendarFormats` replaces the locale's calendar - * wholesale — `getDateString` short-circuits to `t()` before the Day.js calendar path, and - * `timestampFormatter` then parses the calendar config out of the translation value itself. + * wholesale: `getDateString` short-circuits to `t()` before the Day.js calendar path, and + * `timestampFormatter` parses the calendar config out of the translation value itself. * - `timestamp.UserActivityStatus` puts prose (`Last seen`) beside the interpolation. * - * Adding a fourth fails a guard in `src/utils/__tests__/Streami18n.test.ts` that keeps - * `ai-docs/i18n-v10-migration.md` in sync. + * Adding a fourth fails a guard in `src/utils/__tests__/Streami18n.test.ts`. */ export const runtimeDefaults = { 'duration.messageReminder': '{{ milliseconds | durationFormatter(withSuffix: true) }}', @@ -49,9 +44,9 @@ export const runtimeDefaults = { '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {"lastDay":"[Yesterday]", "lastWeek":"dddd", "nextDay":"[Tomorrow]", "nextWeek":"dddd [at] LT", "sameDay":"LT", "sameElse":"L"}) }}', 'timestamp.UserActivityStatus': 'Last seen {{ timestamp | fromNowFormatter }}', - // Screen-reader labels and lookup-table entries. These reach `t()` as runtime values — from a - // JSX prop, a ternary branch, or a table keyed by something other than the copy — so there is no - // call site at which an inline default could be written. + // Screen-reader labels and lookup-table entries. These reach `t()` as runtime values (from a JSX + // prop, a ternary branch, or a table keyed by something other than the copy), so no call site + // can carry an inline default. 'attachment.gallery.doubleTapToOpen.accessibilityLabel': 'Double tap to open', 'attachment.gallery.image.accessibilityLabel': 'Gallery image', 'attachment.gallery.video.accessibilityLabel': 'Gallery video', diff --git a/package/src/i18n/types.ts b/package/src/i18n/types.ts index da5544bdd9..0ac8185150 100644 --- a/package/src/i18n/types.ts +++ b/package/src/i18n/types.ts @@ -12,15 +12,14 @@ import type { BundledTranslationKey, TranslationCatalog as GeneratedCatalog } fr /** * The SDK's i18n types, instantiated from the generic helpers in `@stream-io/i18n`. * - * The derivations live in core so both UI SDKs share one implementation; the *catalog* stays here, - * because it is generated from this SDK's own `t()` call sites. That split is why core's helpers are - * generic over the catalog rather than driven by module augmentation — two catalogs have to be able to - * coexist in one TypeScript program. + * The derivations live in `@stream-io/i18n` so both UI SDKs share one implementation, while the + * catalog is generated from this SDK's own `t()` call sites. The helpers are generic over the + * catalog rather than driven by module augmentation so that two catalogs can coexist in one + * TypeScript program. * - * Two slices come from core, because core owns the code that renders them: `language.*` (derived from - * the same `TranslationLanguage` union the API uses) and `relativeTime.*` (used by - * `timestampFormatter(relativeCompact: true)`). Intersecting them in keeps both compile-checked and - * overridable. + * `language.*` (from `stream-chat`, derived from the `TranslationLanguage` union) and + * `relativeTime.*` (from `@stream-io/i18n`, used by `timestampFormatter(relativeCompact: true)`) + * are intersected in, which keeps both compile-checked and overridable. */ export type TranslationCatalog = GeneratedCatalog & LanguageNameCatalog & RelativeTimeCatalog; @@ -29,16 +28,13 @@ export type TranslationDictionary = TranslationDictionaryOf; export type LooseTranslationDictionary = LooseTranslationDictionaryOf; /** - * The SDK's translation function. - * - * `BundledTranslationKey` is generated: the screen-reader labels and lookup-table entries that reach - * `t()` as runtime values, so there is nowhere to write an inline default. They are callable without one. + * The SDK's translation function. `BundledTranslationKey` covers the generated screen-reader labels + * and lookup-table entries that reach `t()` as runtime values, so they are callable without an + * inline default. */ export type StreamTFunction = StreamTFunctionFor; export type { - // Both are documented in `ai-docs/i18n-v10-migration.md`'s type table and were exported before the - // runtime moved; re-exporting them here keeps the public surface unchanged. CalendarFormats, CustomFormatters, DateTimeParserModule, diff --git a/package/src/i18n/utils.ts b/package/src/i18n/utils.ts index 9f0708d5df..09d1cd3cf0 100644 --- a/package/src/i18n/utils.ts +++ b/package/src/i18n/utils.ts @@ -8,24 +8,16 @@ import { import type { StreamTFunction } from './types'; /** - * The `t` in force before i18next has initialized, and the default value of the translation context. + * The `t` used before i18next has initialized, and the default value of the translation context. * - * Core's factory, instantiated against this SDK's catalog so it is typed the same as the real `t`. It - * honours the inline `defaultValue` at each call site, which is what stops raw dotted keys flashing on - * the first frame — or rendering permanently for a component used outside ``. + * The default translator from `@stream-io/i18n`, typed against this SDK's catalog. It honors the + * inline `defaultValue` at each call site, so raw dotted keys neither flash on the first frame nor + * render permanently in a component used outside ``. */ export const defaultTranslatorFunction: StreamTFunction = createDefaultTranslatorFunction() as StreamTFunction; -/** - * The date/time and key helpers now live in `@stream-io/i18n`, shared with the React SDK. - * - * Re-exported from here rather than rewritten at ~15 call sites, so the internal module path stays - * stable. `getDateString` and the type guards behave identically; `predefinedFormatters` gains - * `fromNowFormatter` and loses `relativeCompactDateFormatter` — that behaviour is - * `timestampFormatter` with `relativeCompact: true`, whose wording goes through `t()` rather than being - * hardcoded English as it was here. - */ +/** The date/time and key helpers come from `@stream-io/i18n`, shared with the React SDK. */ export { asDynamicKey, defaultDateTimeParser, @@ -36,21 +28,21 @@ export { } from '@stream-io/i18n'; /** - * `null` means "nothing renderable", which every caller already handles by omitting the element. - * `"Invalid Date"` is what Day.js formats an unparseable instance into, and it reaches the screen as - * copy. Whatever shape produced it, showing nothing is the better failure. + * `null` means nothing renderable, which every caller handles by omitting the element. Day.js + * formats an unparseable instance as "Invalid Date", which must not reach the screen as copy. */ const withoutInvalidDate = (result: T) => typeof result === 'string' && result.includes('Invalid Date') ? null : result; /** - * This SDK's date formatters: core's, with an invalid result suppressed on the way out. + * This SDK's date formatters: the `@stream-io/i18n` ones, with an invalid result suppressed. + * + * Timestamps are not rescaled: server timestamps are nanosecond numbers converted with + * `convertTimestampToDate` where server data enters the tree, so a missed conversion surfaces + * instead of being masked. * - * These used to also rescale a nanosecond timestamp on the way in, because `latest_votes_by_option` - * reached the UI as a raw integer core's decoders did not convert. That rescale is gone: every - * server-sent timestamp is now a nanosecond number by contract, and each call site converts with - * `convertTimestampToDate` where core data enters the tree. Guessing from the magnitude here would - * hide a missed conversion instead of surfacing it. + * @param params Same options as `getDateString` from `@stream-io/i18n`. + * @returns The formatted date, or `null` when there is nothing renderable. */ export const getDateString: typeof coreGetDateString = ({ messageCreatedAt, ...rest }) => withoutInvalidDate(coreGetDateString({ ...rest, messageCreatedAt })); diff --git a/package/src/index.ts b/package/src/index.ts index a373f461d7..eacc920cb7 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,4 +1,4 @@ -/** i18next polyfill to handle intl format for pluralization. For more info see https://www.i18next.com/misc/json-format#i-18-next-json-v4 */ +// Polyfill for i18next pluralization: https://www.i18next.com/misc/json-format#i-18-next-json-v4 import 'intl-pluralrules'; import './polyfills'; diff --git a/package/src/middlewares/attachments.ts b/package/src/middlewares/attachments.ts index 00bbeb7766..41d5e7c1cb 100644 --- a/package/src/middlewares/attachments.ts +++ b/package/src/middlewares/attachments.ts @@ -5,11 +5,8 @@ import { } from 'stream-chat'; /** - * Supplies a local video's preview URI. - * - * Every other attachment type gets `localMetadata.previewUri` from `toLocalUploadAttachment`, - * which points it at the picked file itself. A video file is not renderable, so the preview has - * to be the thumbnail the native picker extracted (`thumb_url`) instead. + * Sets a local video's `localMetadata.previewUri` to its `thumb_url`, because the picked video + * file itself cannot be rendered as a preview (other attachments preview the file URI). */ const createVideoAttachmentPreviewMiddleware = (): AttachmentPreUploadMiddleware => ({ id: 'stream-io/message-composer-ui-middleware/video-attachment-preview', diff --git a/package/src/middlewares/emojiControl.ts b/package/src/middlewares/emojiControl.ts index 776848cbfb..b69ae97f36 100644 --- a/package/src/middlewares/emojiControl.ts +++ b/package/src/middlewares/emojiControl.ts @@ -72,21 +72,12 @@ export type EmojiMiddleware = Middleware< >; /** - * TextComposer middleware for mentions - * Usage: + * Creates the TextComposer middleware that suggests emoji after the trigger character. * - * const textComposer = new TextComposer(options); - * - * textComposer.use(new createTextComposerEmojiMiddleware(emojiSearchIndex, { - * minChars: 2 - * })); - * - * @param emojiSearchIndex - * @param {{ - * minChars: number; - * trigger: string; - * }} options - * @returns + * @param params.emojiSearchIndex Index searched for emoji matching the typed token. + * @param params.options Overrides for `minChars` and `trigger`. Defaults to + * `{ minChars: 1, trigger: ':' }`. + * @returns The emoji middleware. */ export const createTextComposerEmojiMiddleware = ({ emojiSearchIndex, @@ -154,7 +145,7 @@ export const createTextComposerEmojiMiddleware = ({ if (textWithReplacedWord !== state.text) { return complete({ ...state, - suggestions: undefined, // to prevent the TextComposerMiddlewareExecutor to run the first page query + suggestions: undefined, // Prevents the executor from running the first page query text: textWithReplacedWord, }); } @@ -183,7 +174,7 @@ export const createTextComposerEmojiMiddleware = ({ text: state.text, trigger: finalOptions.trigger, }), - suggestions: undefined, // Clear suggestions after selection + suggestions: undefined, }); }, }, diff --git a/package/src/native.ts b/package/src/native.ts index f039ca9c80..1398f95240 100644 --- a/package/src/native.ts +++ b/package/src/native.ts @@ -95,8 +95,7 @@ type SetClipboardStringOptions = { onSuccess?: () => void; }; // TODO(next-major): make this handler async and return the write outcome directly. -// It's kept synchronous (void) for backwards compatibility, so success/failure are -// reported via the `onSuccess`/`onFailure` callbacks instead of a Promise. +// The handler is synchronous (void), so the outcome is reported via `onSuccess`/`onFailure`. type SetClipboardString = (text: string, options?: SetClipboardStringOptions) => void; type ShareOptions = { @@ -243,9 +242,7 @@ export type AudioReturnType = { }; export type RecordingOptions = { - /** - * A boolean that determines whether audio level information will be part of the status object under the "metering" key. - */ + /** Whether the status object includes audio level information under the `metering` key. */ isMeteringEnabled?: boolean; }; @@ -313,8 +310,8 @@ export type VideoType = { replayAsync?: () => void; resizeMode?: string; /** - * Absolute playback position in seconds. Assigning to it performs an absolute - * seek (expo-video's `VideoPlayer.currentTime`). + * Absolute playback position in seconds. Assigning to it seeks to that position (expo-video's + * `VideoPlayer.currentTime`). */ currentTime?: number; seek?: (seconds: number) => void; @@ -335,8 +332,8 @@ export type NativeShimmerViewProps = ViewProps & { }; /** - * Props of the Android-only native `PerfText` component used by `stream-chat-react-native/ai-components` - * to render syntax highlighted code as a single native text view with colour spans. + * Props of the Android-only native `PerfText` view, which the `ai-components` entry point uses to + * render syntax-highlighted code as a single native text view with color spans. */ export type NativePerfTextProps = ViewProps & { text: string; diff --git a/package/src/nativeMultipartUpload.ts b/package/src/nativeMultipartUpload.ts index 8888bafcfb..c14e391ddf 100644 --- a/package/src/nativeMultipartUpload.ts +++ b/package/src/nativeMultipartUpload.ts @@ -37,7 +37,7 @@ export type NativeMultipartUploaderProgressConfig = { export type NativeMultipartUploadProgressConfig = NativeMultipartUploaderProgressConfig & { /** * Maximum progress percentage reported while the native request body is still being sent. - * Completion is represented by the upload request resolving and the upload indicator being removed. + * Completion is signaled by the upload request resolving. * * @default 90 */ @@ -250,9 +250,9 @@ export const createNativeMultipartUploader = ( return undefined; } - // Annotated with the SDK's own structural emitter type: React Native 0.87 made - // `NativeEventEmitter` generic over an event -> args map, so the inferred union of - // `options.eventEmitter` and a raw `NativeEventEmitter` no longer accepts a typed listener. + // Typed with the SDK's structural emitter type: from React Native 0.87 `NativeEventEmitter` is + // generic over an event map, so the inferred union with `options.eventEmitter` rejects typed + // listeners. const multipartUploadEventEmitter: NativeMultipartUploadEventEmitter = options.eventEmitter ?? (new NativeEventEmitter(nativeModule) as unknown as NativeMultipartUploadEventEmitter); diff --git a/package/src/polyfills.ts b/package/src/polyfills.ts index c687cae293..3526a006b5 100644 --- a/package/src/polyfills.ts +++ b/package/src/polyfills.ts @@ -10,21 +10,17 @@ import structuredClone from '@ungap/structured-clone'; configurable: true, enumerable: false, value: function at(index: number) { - // Convert to integer if index is not provided const len = this.length; let relativeIndex = Number(index) || 0; - // Handle negative indices if (relativeIndex < 0) { relativeIndex += len; } - // Return undefined if index is out of bounds if (relativeIndex < 0 || relativeIndex >= len) { return undefined; } - // Return the element at the calculated index return this[relativeIndex]; }, writable: true, diff --git a/package/src/state-store/audio-player.ts b/package/src/state-store/audio-player.ts index 924bbe5481..0aba7445a6 100644 --- a/package/src/state-store/audio-player.ts +++ b/package/src/state-store/audio-player.ts @@ -63,10 +63,7 @@ export class AudioPlayer { private lastPlaybackStatusError?: string; private lastSeekNotSupportedNotificationAt?: number; - /** - * This keeps the composer preview on the recorder-backed player until preview - * and attachment playback are fully unified under the same sound service. - */ + /** Keeps the composer preview on the recorder-backed player. */ private previewVoiceRecording?: boolean; constructor(options: AudioPlayerOptions) { @@ -199,10 +196,9 @@ export class AudioPlayer { } }; - // This should be a arrow function to avoid binding the function to the instance + // An arrow function so `this` stays bound when it is passed as a callback. private onPlaybackStatusUpdate = async (playbackStatus: PlaybackStatus) => { if (!playbackStatus.isLoaded) { - // Update your UI for the unloaded state if (playbackStatus.error) { if (this.lastPlaybackStatusError !== playbackStatus.error) { this.lastPlaybackStatusError = playbackStatus.error; @@ -211,30 +207,26 @@ export class AudioPlayer { } } else { const { durationMillis, positionMillis } = playbackStatus; - // Update your UI for the loaded state - // This is done for Expo CLI where we don't get file duration from file picker + // Expo CLI file pickers give no duration, so non-recording audio takes it from the player. if (this.type !== 'voiceRecording') { this.duration = durationMillis; } - // Update the position of the audio player when it is playing if (playbackStatus.isPlaying) { - // The duration given by the expo-av is not same as the one of the voice recording, so we take the actual duration for voice recording. + // expo-av reports a different duration than the recording, so voice recordings keep theirs. const duration = this.type === 'voiceRecording' ? this.duration : durationMillis; if (positionMillis <= duration) { this.position = positionMillis; } } - // Update the UI when the audio is finished playing if (playbackStatus.didJustFinish && !playbackStatus.isLooping) { await this.stop(); } } }; - // Getters get isPlaying() { return this.state.getLatestValue().isPlaying; } @@ -263,7 +255,6 @@ export class AudioPlayer { return this._id; } - // Setters set pool(pool: AudioPlayerPool) { this._pool = pool; } @@ -294,7 +285,6 @@ export class AudioPlayer { }); } - // Methods async changePlaybackRate() { let currentPlaybackRateIndex = this.state .getLatestValue() @@ -440,7 +430,7 @@ export class AudioPlayer { } async stop() { - // First seek to 0 to stop the audio and then pause it + // Seek to 0 to stop the audio, then pause it. await this.seek(0); this.pause(); } diff --git a/package/src/state-store/audio-recorder-manager.ts b/package/src/state-store/audio-recorder-manager.ts index 864d35caa7..d81055be50 100644 --- a/package/src/state-store/audio-recorder-manager.ts +++ b/package/src/state-store/audio-recorder-manager.ts @@ -32,20 +32,9 @@ const INITIAL_STATE: AudioRecorderManagerState = { export class AudioRecorderManager { state: StateStore; - /** - * A Set signifying whether we've tried to stop a recording session while the - * startRecording method is still executing. It is useful to identify race - * conditions where we try to cancel the recording altogether while setting - * up the recording session finishes (which can take up to 700ms on some - * slower devices). - * @private - */ + /** Request IDs whose recording was stopped while `startRecording` was still setting it up. */ private pendingStopRequestIds = new Set(); - /** - * A request ID for this recording session. Used to identify stale recording - * sessions and respond adequately, avoiding race conditions. - * @private - */ + /** ID of the latest `startRecording` call, so stale start completions can be ignored. */ private startRequestId = 0; constructor() { @@ -61,7 +50,8 @@ export class AudioRecorderManager { return; } this.state.partialNext({ duration: status?.currentPosition || status.durationMillis }); - // For expo android the lower bound is -120 so we need to normalize according to it. The `status.currentMetering` is undefined for Expo CLI apps, so we can use it. + // Expo Android meters down to -120. `status.currentMetering` is undefined in Expo apps, which + // tells them apart from native CLI apps. const lowerBound = Platform.OS === 'ios' || status.currentMetering ? -60 : -120; const normalizedAudioLevel = normalizeAudioLevel( status.currentMetering || status.metering, @@ -111,8 +101,7 @@ export class AudioRecorderManager { try { await NativeHandlers.Audio.stopRecording(); } catch { - // If stopRecording fails, the native implementation has already stopped the - // recorder and so we do nothing. + // A failed stop means the native recorder has already stopped, so there is nothing to do. } } this.pendingStopRequestIds.delete(requestId); @@ -123,7 +112,7 @@ export class AudioRecorderManager { } if (requestId !== this.startRequestId) { - // Stale start completion, ignore to avoid affecting newer attempts + // Ignore a stale start completion so it cannot affect a newer attempt. return accessGranted; } @@ -158,7 +147,7 @@ export class AudioRecorderManager { try { await NativeHandlers.Audio.stopRecording(); } catch { - // Best effort cleanup, native implementation may already be stopped. + // Best effort: the native recorder may already be stopped. } if (withDelete) { diff --git a/package/src/state-store/edit-channel-details-store.ts b/package/src/state-store/edit-channel-details-store.ts index dcad7a2b5a..473143cc3c 100644 --- a/package/src/state-store/edit-channel-details-store.ts +++ b/package/src/state-store/edit-channel-details-store.ts @@ -9,13 +9,9 @@ export type EditChannelImagePendingAction = 'camera' | 'library' | 'reset'; export type EditChannelDetailsState = { /** Current value of the name input. */ currentName: string; - /** - * Channel image URL snapshotted at construction; not updated by WS events. - */ + /** Channel image URL snapshotted at construction; WebSocket events do not update it. */ initialImage: string | undefined; - /** - * Channel name snapshotted at construction; not updated by WS events. - */ + /** Channel name snapshotted at construction; WebSocket events do not update it. */ initialName: string; /** Pending action from the {@link ChannelEditImageSheet}, or `null` when idle. */ pendingAction: EditChannelImagePendingAction | null; @@ -24,12 +20,9 @@ export type EditChannelDetailsState = { }; /** - * Holds the editable state for the channel details form (name + image) plus the - * pending image-picker action. The channel's name and image are snapshotted - * once at construction and are intentionally **not** updated by WebSocket - * events, so an inbound `channel.updated` does not clobber the user's - * in-progress edits. Leaf components can subscribe to narrow slices via - * {@link useStateStore}. + * Holds the editable state of the channel details form (name and image) plus the pending + * image-picker action. The channel's name and image are snapshotted at construction, so a + * `channel.updated` event does not overwrite in-progress edits. */ export class EditChannelDetailsStore { public state: StateStore; @@ -47,33 +40,49 @@ export class EditChannelDetailsStore { }); } - /** Updates the current value of the name input. */ + /** + * Updates the current value of the name input. + * + * @param currentName The new name input value. + */ setCurrentName(currentName: string) { this.state.partialNext({ currentName }); } /** - * Updates the picked image. `undefined` = untouched, `File` = newly picked, - * `null` = reset. + * Updates the picked image. + * + * @param updatedImage `undefined` when untouched, a `File` when newly picked, `null` for reset. */ setUpdatedImage(updatedImage: File | null | undefined) { this.state.partialNext({ updatedImage }); } - /** Sets the pending image-picker action, or `null` to clear it. */ + /** + * Sets the pending image-picker action. + * + * @param pendingAction The action to run, or `null` to clear it. + */ setPendingAction(pendingAction: EditChannelImagePendingAction | null) { this.state.partialNext({ pendingAction }); } } -/** Whether the name input differs from the channel's initial name. */ +/** + * Whether the name input differs from the channel's initial name. + * + * @param state The store state. + * @returns True when the name was edited. + */ export const isNameEdited = (state: EditChannelDetailsState) => state.currentName !== state.initialName; /** - * Whether the image has unsaved changes. The image is edited once touched - * (`updatedImage !== undefined`), except when both the initial and updated - * image are falsy (no image before, none now). + * Whether the image has unsaved changes: it was touched and the result is not "no image before and + * none now". + * + * @param state The store state. + * @returns True when the image was edited. */ export const isImageEdited = (state: EditChannelDetailsState) => !(state.updatedImage === undefined || (!state.updatedImage && !state.initialImage)); @@ -91,22 +100,28 @@ const selectAreChannelDetailsEdited = (state: EditChannelDetailsState) => ({ }); /** - * Subscribes to an {@link EditChannelDetailsStore} and returns whether the name - * input has unsaved changes. + * Subscribes to the store and returns whether the name input has unsaved changes. + * + * @param store The store to subscribe to. + * @returns True when the name was edited. */ export const useIsNameEdited = (store: EditChannelDetailsStore) => useStateStore(store.state, selectIsNameEdited).isNameEdited; /** - * Subscribes to an {@link EditChannelDetailsStore} and returns whether the image - * has unsaved changes. + * Subscribes to the store and returns whether the image has unsaved changes. + * + * @param store The store to subscribe to. + * @returns True when the image was edited. */ export const useIsImageEdited = (store: EditChannelDetailsStore) => useStateStore(store.state, selectIsImageEdited).isImageEdited; /** - * Subscribes to an {@link EditChannelDetailsStore} and returns whether the form - * has any unsaved changes (name or image). + * Subscribes to the store and returns whether the name or the image has unsaved changes. + * + * @param store The store to subscribe to. + * @returns True when either was edited. */ export const useAreChannelDetailsEdited = (store: EditChannelDetailsStore) => useStateStore(store.state, selectAreChannelDetailsEdited).areChannelDetailsEdited; diff --git a/package/src/state-store/image-gallery-state-store.ts b/package/src/state-store/image-gallery-state-store.ts index 0e6ad27b7f..a2008d2030 100644 --- a/package/src/state-store/image-gallery-state-store.ts +++ b/package/src/state-store/image-gallery-state-store.ts @@ -70,10 +70,8 @@ export class ImageGalleryStateStore { options: ImageGalleryOptions; videoPlayerPool: VideoPlayerPool; /** - * SharedValue mirror of `state.currentIndex` for worklet consumers (gesture - * handlers, animated styles). Maintained in lockstep with the setter and the - * reset path in `clear()`. Reads from worklets bypass the JS bridge entirely; - * any code path that updates `currentIndex` must keep this mirror in sync. + * Shared value mirroring `state.currentIndex` for worklets (gesture handlers, animated styles). + * Every path that updates `currentIndex` must keep it in sync. */ currentIndexShared: SharedValue; @@ -84,7 +82,6 @@ export class ImageGalleryStateStore { this.currentIndexShared = makeMutable(INITIAL_STATE.currentIndex); } - // Getters get messages() { return this.state.getLatestValue().messages; } @@ -155,7 +152,6 @@ export class ImageGalleryStateStore { }); } - // Setters set messages(messages: LocalMessage[]) { this.state.partialNext({ messages }); } @@ -168,12 +164,8 @@ export class ImageGalleryStateStore { const previousIndex = this.state.getLatestValue().currentIndex; this.state.partialNext({ currentIndex }); this.currentIndexShared.value = currentIndex; - // When the user moves off the current slide, pause whatever video was - // playing. Moved here from a useEffect in ImageGallery so the invariant - // lives next to the state it depends on — and the parent no longer needs - // to subscribe to currentIndex purely to drive this side effect. - // `clear()` is the other reset path; it bypasses this setter but already - // pauses everything through `videoPlayerPool.clear()`. + // Moving off the current slide pauses its video. `clear()` bypasses this setter but pauses + // everything through `videoPlayerPool.clear()`. if (previousIndex !== currentIndex) { const activePlayer = this.videoPlayerPool.getActivePlayer(); if (activePlayer) { @@ -186,7 +178,6 @@ export class ImageGalleryStateStore { this.state.partialNext({ requesterNode }); } - // APIs for managing messages appendMessages = (messages: LocalMessage[]) => { this.state.partialNext({ messages: [...this.messages, ...messages] }); }; @@ -237,7 +228,7 @@ export class ImageGalleryStateStore { (asset) => stripQueryFromUrl(asset.uri) === stripQueryFromUrl(selectedAttachmentUrl ?? ''), ); - // Route through the setter so currentIndexShared stays in sync. + // Goes through the setter so `currentIndexShared` stays in sync. this.currentIndex = index === -1 ? 0 : index; }, ); diff --git a/package/src/state-store/in-app-notifications-store.ts b/package/src/state-store/in-app-notifications-store.ts index ef2acb343f..ed9706ec73 100644 --- a/package/src/state-store/in-app-notifications-store.ts +++ b/package/src/state-store/in-app-notifications-store.ts @@ -18,7 +18,6 @@ export const openInAppNotification = (notification: Notification) => { } const { notifications } = inAppNotificationsStore.getLatestValue(); - // Prevent duplicate notifications if (notifications.some((n) => n.id === notification.id)) { console.warn('Notification with the same id already exists!'); return; diff --git a/package/src/state-store/message-overlay-store.ts b/package/src/state-store/message-overlay-store.ts index 5e73396461..f3bdcb7fe7 100644 --- a/package/src/state-store/message-overlay-store.ts +++ b/package/src/state-store/message-overlay-store.ts @@ -151,12 +151,14 @@ export const createOverlayHostLayerId = () => `overlay-host-layer-${overlayHostLayerRegistrationCounter++}`; /** - * Registers a `MessageOverlayHostLayer` (one per `OverlayProvider`) using stack semantics. + * Registers a `MessageOverlayHostLayer` (one per `OverlayProvider`) on a stack. * - * Only the most recently registered layer drives the overlay and owns the shared portal host names. - * Portal hosts are looked up by name in a single native registry, so two layers using the same - * names would take them over from each other. The other layers park their hosts under names unique - * to them, and when the top layer unregisters the previous one takes the shared names back. + * Only the top layer drives the overlay and owns the shared portal host names, because portal hosts + * live in a single native registry by name. Other layers park their hosts under unique names, and + * the previous layer takes the shared names back when the top one unregisters. + * + * @param id The layer ID, from `createOverlayHostLayerId`. + * @returns A function that unregisters the layer. */ export const registerOverlayHostLayer = (id: string) => { overlayHostLayersStore.partialNext({ @@ -176,9 +178,11 @@ const topOverlayHostLayer = { isTop: true }; const notTopOverlayHostLayer = { isTop: false }; /** - * Whether the host layer with the given id is the one that currently owns the overlay. A missing - * id (used outside of an `OverlayProvider`) or an empty stack counts as the top, which keeps the - * single provider setup identical to before the stack existed. + * Whether the host layer with the given ID currently owns the overlay. A missing ID (outside an + * `OverlayProvider`) or an empty stack counts as the top. + * + * @param id The layer ID. + * @returns True when the layer is the top one. */ export const useIsTopOverlayHostLayer = (id: string | undefined) => { const isTopOverlayHostLayerSelector = useCallback( @@ -278,7 +282,6 @@ overlayStore.subscribeWithSelector(actionQueueSelector, async ({ active }) => { const actions = actionQueue; actionQueue = []; - // flush the queue for (const action of actions) { try { await action(); @@ -330,14 +333,14 @@ export const useShouldTeleportToClosingPortal = (hostName: string, id: string) = }; /** - * Registers a screen-level blacklist of closing portal hosts that should not render while this hook is active. + * Registers a screen-level blacklist of closing portal hosts that must not render while this hook + * is active. * - * The blacklist uses stack semantics: - * - mounting/enabling a new instance makes its blacklist active - * - unmounting/disabling restores the previous active blacklist automatically + * Blacklists form a stack: the most recently mounted or enabled one is active, and unmounting or + * disabling it restores the previous one without the earlier screens re-running effects. * - * This keeps stacked screens predictable without requiring previous screens to rerun effects when the top screen - * disappears. + * @param hostNames Names of the portal hosts to blacklist. + * @param enabled Whether the blacklist is registered. Defaults to true. */ export const useClosingPortalHostBlacklist = (hostNames: string[], enabled = true) => { const registrationIdRef = useRef(null); @@ -367,23 +370,14 @@ export const useClosingPortalHostBlacklist = (hostNames: string[], enabled = tru }; /** - * NOTE: - * Do not swap this back to `useStateStore(closingPortalLayoutsStore, selector)`. - * - * Why this is special: - * - `layouts` is a dynamic-key map (hosts are added/removed at runtime) - * - each host key maintains a stack of registrations - * - We only need React updates when the key set changes (add/remove/reset) - * - Per-layout movement is already on UI thread via the top `entry.layout.value` + * Subscribes to the closing portal layouts, keyed by host name. * - * Why `useStateStore` is unsafe here: - * - Both `stream-chat`'s `subscribeWithSelector` and our `useStateStore` snapshot - * comparator use an asymmetric key comparison (they iterate previous keys only) - * - That means `{}` -> `{ newHost: entry }` can be treated as "no change" - * - When that happens, overlay slots never mount even though registration has run + * Do not replace the raw-store `useSyncExternalStore` with `useStateStore`: its selector comparison + * only iterates the previous keys, so `{}` -> `{ newHost: entry }` reads as unchanged and overlay + * slots never mount. Only adding or removing hosts needs a React update, since layout movement runs + * on the UI thread through `entry.layout.value`. * - * `useSyncExternalStore` with raw store subscription avoids that selector compare path, - * so add/remove of hosts is always treated as observable. + * @returns The layout entries per host name. */ export const useClosingPortalLayouts = () => { const subscribe = useCallback( diff --git a/package/src/state-store/selection-store.ts b/package/src/state-store/selection-store.ts index 8c27b6dc19..c1c4a7e432 100644 --- a/package/src/state-store/selection-store.ts +++ b/package/src/state-store/selection-store.ts @@ -5,7 +5,7 @@ import { StateStore } from '@stream-io/state-store'; import { useStateStore } from '../hooks/useStateStore'; export type SelectionState = { - /** Ids of the currently selected items. */ + /** IDs of the currently selected items. */ selectedIds: Set; }; @@ -13,12 +13,7 @@ const createInitialState = (): SelectionState => ({ selectedIds: new Set(), }); -/** - * Holds a generic selection state (a set of selected ids). Leaf components can - * subscribe to narrow slices via `useStateStore`. The id type defaults to - * `string` (e.g. user ids in the "add members to channel" flow) but can be - * specialised for any other selectable entity. - */ +/** Holds a set of selected item IDs, of type `T` (defaults to `string`). */ export class SelectionStore { public state = new StateStore>(createInitialState()); @@ -66,15 +61,20 @@ const selectIsSelectionEmpty = (state: SelectionState) => ({ }); /** - * Subscribes to a {@link SelectionStore} and returns whether the selection is - * currently empty. + * Subscribes to the store and returns whether the selection is empty. + * + * @param store The store to subscribe to. + * @returns True when no item is selected. */ export const useIsSelectionEmpty = (store: SelectionStore) => useStateStore(store.state, selectIsSelectionEmpty).isSelectionEmpty; /** - * Subscribes to a {@link SelectionStore} and returns whether the given id is - * currently selected. + * Subscribes to the store and returns whether an item is selected. + * + * @param store The store to subscribe to. + * @param id The item ID. + * @returns True when the item is selected. */ export const useIsSelected = (store: SelectionStore, id: T) => { const selector = useCallback( diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index 975e620125..b4c69214b4 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -18,10 +18,7 @@ export class OfflineDB extends AbstractOfflineDB { maxSyncEventsLimit, }: { client: StreamChat; - /** - * Supplies the SQLCipher key the offline database is opened with. See - * {@link SqliteClient.getEncryptionKey} for the stability requirement. - */ + /** Supplies the SQLCipher key. See {@link SqliteClient.getEncryptionKey}. */ getEncryptionKey?: () => Promise; maxSyncEventsLimit?: number | false; }) { @@ -29,9 +26,8 @@ export class OfflineDB extends AbstractOfflineDB { client, syncMaxEventCount: maxSyncEventsLimit === false ? undefined : maxSyncEventsLimit, }); - // Assigned unconditionally: SqliteClient holds this statically, so leaving a - // previous instance's getter in place would keep encrypting after the caller - // stopped asking for it. + // Assigned even when undefined: `SqliteClient` holds it statically, so an earlier instance's + // getter would otherwise keep encrypting. SqliteClient.getEncryptionKey = getEncryptionKey; } @@ -115,12 +111,8 @@ export class OfflineDB extends AbstractOfflineDB { executeSqlBatch = SqliteClient.executeSqlBatch; /** - * Why the most recent {@link initializeDB} failed, if it did. - * - * `AbstractOfflineDB.init` catches whatever `initializeDB` throws and does not - * re-throw it, so a caller has no way to see the reason. Recording it here on the - * way out gives the caller something to read back once `init` has settled. Kept on - * the instance rather than a static so two clients cannot overwrite each other. + * Why the most recent {@link initializeDB} failed, if it did. `AbstractOfflineDB.init` swallows + * the error, so this is how a caller reads it back once `init` has settled. */ initializationError: SqliteClientError | undefined; diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index e289020788..eaa848655b 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -4,16 +4,13 @@ let sqlite: OPSQLiteProxy; try { sqlite = require('@op-engineering/op-sqlite'); } catch (e) { - // We want to throw the original error when remote debugger (e.g. Chrome) is enabled. - // SQLite can only be used when synchronous method invocations (JSI) are possible. - // e.g on-device debugger (e.g. Flipper). + // Rethrow when a remote debugger (e.g. Chrome) is attached: SQLite needs synchronous JSI calls. const isRemoteDebuggerError = e instanceof Error && e.message.includes('Failed to install'); if (isRemoteDebuggerError) { throw e; } - // Reaching here will mean that QuickSQLite is not installed for one of the reasons - // 1. Running on regular expo, where we don't support offline storage yet. - // 2. Offline support is disabled, in which case this library is not installed. + // op-sqlite is not installed (regular Expo, which has no offline storage, or offline support is + // disabled), so `sqlite` stays undefined. } import type { Sink } from 'stream-chat'; @@ -24,9 +21,9 @@ import { createCreateTableQuery } from './sqlite-utils/createCreateTableQuery'; import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './types'; /** - * Why the offline database could not be opened. The first two only arise when - * {@link SqliteClient.getEncryptionKey} is set; `OFFLINE_DB_UNREADABLE` can also mean - * plain corruption, or a database left behind from the other encryption mode. + * Why the offline database could not be opened. The first two only occur when + * {@link SqliteClient.getEncryptionKey} is set; `OFFLINE_DB_UNREADABLE` can also mean corruption or + * a database left over from the other encryption mode. */ export type SqliteClientErrorCode = | 'SQLCIPHER_BUILD_MISSING' @@ -40,16 +37,12 @@ export class SqliteClientError extends Error { super(message); this.name = 'SqliteClientError'; this.code = code; - // Assigned here rather than passed through `super(message, { cause })` because - // Hermes does not reliably honour the ErrorOptions overload. + // Assigned directly because Hermes does not reliably honor the `ErrorOptions` overload. this.cause = options?.cause; } } -/** - * SqliteClient takes care of any direct interaction with sqlite. - * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. - */ +/** Wraps all direct use of `@op-engineering/op-sqlite`. */ export class SqliteClient { static dbVersion = 18; @@ -59,39 +52,33 @@ export class SqliteClient { static db: _InternalDB | undefined; /** - * Supplies the SQLCipher key the offline database is opened with; `undefined` - * opens it unencrypted, which is the default. The key must be stable for the - * lifetime of the database file - there is no rekey path, so a database this key - * cannot read raises `OFFLINE_DB_UNREADABLE` on the first page read. The file is - * left untouched; recovery is `SqliteClient.deleteDatabase()` and a re-mount. + * Supplies the SQLCipher key the offline database opens with. Defaults to `undefined`, which + * opens it unencrypted. The key must stay the same for the life of the database file (there is no + * rekey): a database this key cannot read raises `OFFLINE_DB_UNREADABLE` and is left untouched. + * Recover with `SqliteClient.deleteDatabase()` and a re-mount. */ static getEncryptionKey: (() => Promise) | undefined; /** Key resolved by {@link preflightEncryption}, consumed by the next {@link openDB}. */ private static preflightedKey: string | undefined; - /** Busy/disk/memory failures. Checked first: wiping over these destroys a good db. */ + /** Transient failures (busy, disk, memory). Checked first so they never read as unreadable. */ private static TRANSIENT_ERROR = /database is locked|SQLITE_BUSY|SQLITE_LOCKED|disk i\/o|SQLITE_IOERR|unable to open|SQLITE_CANTOPEN|out of memory|readonly/i; /** - * The bytes on disk cannot be read with the key we have: wrong/rotated key, - * plaintext-encrypted mismatch or corruption. SQLCipher has no decrypt specific - * code and overloads NOTADB (26), occasionally CORRUPT (11). + * The file cannot be read with the current key: wrong or rotated key, an encrypted/plaintext + * mismatch, or corruption. SQLCipher has no decrypt-specific code, so it overloads NOTADB (26) + * and occasionally CORRUPT (11). */ private static UNREADABLE_ERROR = /not a database|file is encrypted|malformed|disk image is malformed|SQLite (?:error )?code:?\s*(?:26|11)\b|NOTADB|SQLITE_CORRUPT/i; static getDbVersion = () => SqliteClient.dbVersion; - // Force a specific db version. This is mainly useful for testsuit. + // Overrides the DB version, mainly for tests. static setDbVersion = (version: number) => (SqliteClient.dbVersion = version); - /** - * Records and re-throws. Deliberately does not write to the console: the error is - * thrown, so logging it here would duplicate whatever the caller's error boundary - * reports - and in dev React already logs every boundary-caught error, which is what - * LogBox turns red. - */ + /** Logs the error to the logger and re-throws it, without writing to the console. */ private static recordError = (e: SqliteClientError) => { SqliteClient.logger?.('error', e.message, { tag: e.code }); @@ -99,13 +86,12 @@ export class SqliteClient { }; /** - * Resolves the encryption key without opening the database, so callers can decide - * whether to attach an `OfflineDB` at all. Parts of the client write through - * `client.offlineDb` without checking that it initialized (`queryChannels` upserts - * into it), so attaching one we cannot open turns those writes into rejections. + * Resolves the encryption key without opening the database, so callers can decide whether to + * attach an `OfflineDB` at all: the client writes through `client.offlineDb` (for example in + * `queryChannels`) without checking it initialized, so one that cannot open rejects those writes. + * The next {@link openDB} reuses the key and does not call `getEncryptionKey` again. * - * Throws {@link SqliteClientError}. The key is handed to the next - * {@link openDB} rather than read from `getEncryptionKey` twice. + * @throws {SqliteClientError} When the key cannot be resolved. */ static preflightEncryption = async () => { try { @@ -119,10 +105,8 @@ export class SqliteClient { }; /** - * The key to open with, or `undefined` when the database is meant to be - * unencrypted. Throws rather than silently falling back to an unencrypted - * database, which would hand an integration that asked for encryption a plaintext - * cache of its users' messages. + * The key to open with, or `undefined` when the database is meant to be unencrypted. Throws when + * encryption was requested but is unavailable, and never falls back to an unencrypted database. */ private static resolveEncryptionKey = async () => { const { getEncryptionKey } = SqliteClient; @@ -131,11 +115,8 @@ export class SqliteClient { return undefined; } - // A non-SQLCipher build accepts `encryptionKey` at the JSI boundary and then - // drops it - plaintext database, no error anywhere. `isSQLCipher` has existed - // since op-sqlite 9, well below the peer floor, so the typeof check is not really - // necessary but we'll keep it in case something changes in the future so that - // we at least have a clearer error. + // A non-SQLCipher build accepts `encryptionKey` and silently drops it, writing a plaintext + // database. The `typeof` check is defensive, for a clearer error if `isSQLCipher` is missing. if (sqlite === undefined) { throw new SqliteClientError( 'SQLCIPHER_BUILD_MISSING', @@ -165,8 +146,8 @@ export class SqliteClient { ); } - // Not being handed a key is not the same as being handed the wrong one, so a locked - // keychain must not cost us a database we can still read later. + // A missing key is not a wrong key: throwing leaves the database untouched, so a locked + // keychain does not cost a database that is readable later. if (!encryptionKey) { throw new SqliteClientError( 'ENCRYPTION_KEY_UNAVAILABLE', @@ -195,10 +176,8 @@ export class SqliteClient { ...(encryptionKey ? { encryptionKey } : {}), }); - // Note: this will not fail on an encryption key mismatch, as we do not read - // any pages, but rather look at a connection level flag. The first failure - // is going to be whatever actually reads something, which is going to be - // the user_version read in initializeDatabase. + // Does not fail on an encryption key mismatch because no pages are read here. The first + // failure comes from the first real read: the `user_version` read in `initializeDatabase`. await SqliteClient.db?.execute('PRAGMA foreign_keys = ON', []); } catch (e) { if (e instanceof SqliteClientError) { @@ -235,8 +214,7 @@ export class SqliteClient { if (!SqliteClient.db) { throw new Error('DB is not open or initialized.'); } - // This is a workaround to make the executeBatch method work. - // It expects an empty array as the second argument in the individual queries if nothing present. + // `executeBatch` expects an empty params array in queries that have none. // Discussion - https://discord.com/channels/1301463257722126357/1324262993780932688/1330846910596251711 const finalQueries = queries.map((query) => { if (query.length === 1) { @@ -306,10 +284,13 @@ export class SqliteClient { }; /** - * Whether the file cannot be read with the key we have, as opposed to being - * temporarily unavailable (busy, locked, disk). Works off message text because - * op-sqlite rejects with a plain Error and this class re-wraps those messages, so - * no numeric code survives. Drives `OFFLINE_DB_UNREADABLE`. + * Whether the file cannot be read with the current key, as opposed to being temporarily + * unavailable (busy, locked, disk). Matches on the message text because op-sqlite rejects with a + * plain `Error` and this class re-wraps it, so no numeric code survives. Drives + * `OFFLINE_DB_UNREADABLE`. + * + * @param e The error thrown while opening or reading the database. + * @returns True when the database is unreadable rather than temporarily unavailable. */ static isUnreadableDbError = (e: unknown) => { const message = String((e as Error)?.message ?? e); diff --git a/package/src/store/apis/addPendingTask.ts b/package/src/store/apis/addPendingTask.ts index 8aabf100fe..f6598931c5 100644 --- a/package/src/store/apis/addPendingTask.ts +++ b/package/src/store/apis/addPendingTask.ts @@ -6,21 +6,19 @@ import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; import { SqliteClient } from '../SqliteClient'; import type { PreparedQueries } from '../types'; -/* - * addPendingTask - Adds a pending task to the database +/** + * Adds a pending task to the database. * - * @param {PendingTask} task - The task to add - * - * @return {() => void} - A function that can be called to remove the task from the database + * @param task The task to add. + * @returns A function that removes the task from the database. */ export const addPendingTask = async (task: PendingTask) => { const storable = mapTaskToStorable(task); const { channelId, channelType, threadId, payload, type } = storable; const queries: PreparedQueries[] = []; if (type === 'create-draft' || type === 'delete-draft') { - // Only one draft pending task is allowed per entity (i.e thread, channel etc). - // If multiple arrive, we'll simply take the last one (since deleteDraft does not - // fail as an API if a draft doesn't exist). + // One draft task is kept per entity (thread, channel, ...): the last one wins, since deleting a + // draft that does not exist does not fail. queries.push( createDeleteQuery('pendingTasks', { channelId, diff --git a/package/src/store/apis/channelExists.ts b/package/src/store/apis/channelExists.ts index 5b57015719..8c9272e5cb 100644 --- a/package/src/store/apis/channelExists.ts +++ b/package/src/store/apis/channelExists.ts @@ -4,10 +4,11 @@ import { SqliteClient } from '../SqliteClient'; * Whether a channel row exists, which callers use to avoid writing a row whose `cid` foreign key * would not resolve. * - * Deliberately `SELECT 1 ... LIMIT 1` rather than `SELECT EXISTS(...)`: `EXISTS` always returns - * exactly one row (holding `0` or `1`), so the row COUNT carries no information and the previous - * implementation reported `true` for every cid, existing or not. Returning zero rows for a miss is - * what makes the answer readable without depending on the result column's name. + * It selects `1 ... LIMIT 1` because `SELECT EXISTS(...)` always returns one row (`0` or `1`), so + * the row count could not tell a hit from a miss. + * + * @param params.cid The channel CID. + * @returns True when the channel exists. */ export const channelExists = async ({ cid }: { cid: string }) => { const channels = await SqliteClient.executeSql('SELECT 1 FROM channels WHERE cid = ? LIMIT 1', [ diff --git a/package/src/store/apis/dropPendingTasks.ts b/package/src/store/apis/dropPendingTasks.ts index f9aaa2be1e..b54aa428d8 100644 --- a/package/src/store/apis/dropPendingTasks.ts +++ b/package/src/store/apis/dropPendingTasks.ts @@ -2,15 +2,12 @@ import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; import { SqliteClient } from '../SqliteClient'; /** - * dropPendingTasks - Drops all pending tasks from the DB given a specific messageId. - * Useful for when we do some message actions on a failed message and then we decide to - * delete it afterwards, removing it from the state. + * Drops all pending tasks of a message, for example when a failed message that had actions queued + * on it is deleted. * - * @param {Object} param - * @param {string} param.messageId The messageId for which we want to remove the pending tasks. - * @param {boolean} param.execute Whether we should immediately execute the query or return it as a prepared one. - * - * @return {() => void} - A function that can be called to remove the task from the database + * @param params.messageId The ID of the message whose pending tasks are removed. + * @param params.execute Whether to run the query now or only return it. Defaults to true. + * @returns The prepared delete queries. */ export const dropPendingTasks = async ({ messageId, diff --git a/package/src/store/apis/getChannelActiveLocations.ts b/package/src/store/apis/getChannelActiveLocations.ts index f2529c776b..49130b49ad 100644 --- a/package/src/store/apis/getChannelActiveLocations.ts +++ b/package/src/store/apis/getChannelActiveLocations.ts @@ -11,7 +11,7 @@ export const getChannelActiveLocations = async ({ }): Promise> => { const cidVsLiveLocations: Record = {}; - // Query to select active live locations for the given channel ids where the end_at is not empty and it is greater than the current date. + // Active means `endAt` is set and in the future. const locations = await selectActiveLocationsForChannels(channelIds); locations.forEach((location) => { diff --git a/package/src/store/apis/getChannelMessages.ts b/package/src/store/apis/getChannelMessages.ts index a279629f16..6a3a3a80c6 100644 --- a/package/src/store/apis/getChannelMessages.ts +++ b/package/src/store/apis/getChannelMessages.ts @@ -23,7 +23,6 @@ export const getChannelMessages = async ({ const messageRows = await selectMessagesForChannels(channelIds); const messageIds = messageRows.map(({ id }) => id); - // Populate the message reactions. const reactionRows = await selectReactionsForMessages(messageIds, null); const messageIdVsReactions: Record[]> = {}; reactionRows.forEach((reaction) => { @@ -33,7 +32,6 @@ export const getChannelMessages = async ({ messageIdVsReactions[reaction.messageId].push(reaction); }); - // Populate the polls. const messageIdsVsPolls: Record> = {}; const pollsById: Record> = {}; const messagesWithPolls = messageRows.filter((message) => !!message.poll_id); @@ -74,7 +72,6 @@ export const getChannelMessages = async ({ messageIdsVsLocations[location.messageId] = location; }); - // Populate the messages. const cidVsMessages: Record = {}; messageRows.forEach((m) => { if (!cidVsMessages[m.cid]) { diff --git a/package/src/store/apis/getChannels.ts b/package/src/store/apis/getChannels.ts index a08db1cd18..edbbdf4f16 100644 --- a/package/src/store/apis/getChannels.ts +++ b/package/src/store/apis/getChannels.ts @@ -11,13 +11,12 @@ import { mapStorableToChannel } from '../mappers/mapStorableToChannel'; import { SqliteClient } from '../SqliteClient'; /** - * Returns the list of channels with state enriched for given channel ids. + * Returns the channels with the given IDs, including their members, reads, messages, draft and + * active live locations. * - * @param {Object} param - * @param {Array} param.channelIds List of channel ids to fetch. - * @param {Array} param.userId Id of the current logged in user. - * - * @returns {Array} Channels with enriched state. + * @param params.channelIds The channel IDs to fetch. + * @param params.userId The ID of the current user. + * @returns The channels with their state. */ export const getChannels = async ({ channelIds, @@ -41,7 +40,6 @@ export const getChannels = async ({ getChannelActiveLocations({ channelIds }), ]); - // Enrich the channels with state return channels.map((c) => ({ ...mapStorableToChannel(c), active_live_locations: cidVsActiveLocations[c.cid] || [], diff --git a/package/src/store/apis/getChannelsForFilterSort.ts b/package/src/store/apis/getChannelsForFilterSort.ts index a34e5bb611..6e4e3f9a78 100644 --- a/package/src/store/apis/getChannelsForFilterSort.ts +++ b/package/src/store/apis/getChannelsForFilterSort.ts @@ -11,15 +11,14 @@ import { selectChannelQueryForFilterSort } from './queries/selectChannelQueryFor import { SqliteClient } from '../SqliteClient'; /** - * Gets the channels from database for given filter and sort query. + * Gets the cached channels for a filter, sort and options query. * - * @param {Object} param - * @param {string} param.userId Id of current logged in user - * @param {Object} param.filters Filters for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters - * @param {Object} param.sort Sort for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters - * - * @returns The channels corresponding to filters & sort, together with the predefined-filter metadata - * they were cached with. Returns null if filters + sort query doesn't exist in "channelQueries" table. + * @param params.userId The ID of the current user. + * @param params.filters Channel filters: https://getstream.io/chat/docs/javascript/query_channels/ + * @param params.options Query options, including `predefined_filter`. + * @param params.sort Channel sort: https://getstream.io/chat/docs/javascript/query_channels/ + * @returns The channels with the predefined-filter metadata they were cached with, or null when + * no query is given or it is not in the `channelQueries` table. */ export const getChannelsForFilterSort = async ({ userId, diff --git a/package/src/store/apis/getDraftsForChannels.ts b/package/src/store/apis/getDraftsForChannels.ts index 91d538f649..1f15d80249 100644 --- a/package/src/store/apis/getDraftsForChannels.ts +++ b/package/src/store/apis/getDraftsForChannels.ts @@ -19,9 +19,8 @@ export const getDraftForChannels = async ({ const draftRowsWithMessage = await selectDraftMessageFromDraftForChannels(channelIds); - /** - * Filter out drafts without a parent ID, as we will not show them in the channel. - * The above `createSelectQuery` was not able to filter out drafts without a parent ID. + /* + * Keeps only drafts without a parent ID: thread drafts are not shown in the channel. * TODO: Fix the query to filter out drafts without a parent ID. This can be a bit faster. */ const rowsWithoutParentID = draftRowsWithMessage.filter((row) => row.parentId === null); diff --git a/package/src/store/apis/getReactions.ts b/package/src/store/apis/getReactions.ts index e43562646e..29973e6f8e 100644 --- a/package/src/store/apis/getReactions.ts +++ b/package/src/store/apis/getReactions.ts @@ -11,7 +11,6 @@ export const getReactions = ({ }): ReactionResponse[] => { SqliteClient.logger?.('info', 'getReactions', { reactions }); - // Enrich the channels with state return reactions.map((reaction) => ({ ...mapStorableToReaction(reaction), })); diff --git a/package/src/store/apis/getReactionsforFilterSort.ts b/package/src/store/apis/getReactionsforFilterSort.ts index 51e50a2436..b279452189 100644 --- a/package/src/store/apis/getReactionsforFilterSort.ts +++ b/package/src/store/apis/getReactionsforFilterSort.ts @@ -6,11 +6,13 @@ import { selectReactionsForMessages } from './queries/selectReactionsForMessages import { SqliteClient } from '../SqliteClient'; /** - * Fetches reactions for a message from the database based on the provided filters and sort. - * @param currentMessageId The message ID for which reactions are to be fetched. - * @param filters The filters to be applied while fetching reactions. - * @param sort The sort to be applied while fetching reactions. - * @param limit The limit of how many reactions should be returned. + * Fetches a message's reactions from the database, filtered and sorted. + * + * @param params.messageId The ID of the message to fetch reactions for. + * @param params.filters Filters to apply. Only `type` is supported. + * @param params.sort Sort to apply. + * @param params.limit Maximum number of reactions to return. Defaults to 25. + * @returns The reactions, or null when neither filters nor sort are given. */ export const getReactionsForFilterSort = async ({ messageId, diff --git a/package/src/store/apis/insertReaction.ts b/package/src/store/apis/insertReaction.ts index 72f218e232..c0194ec223 100644 --- a/package/src/store/apis/insertReaction.ts +++ b/package/src/store/apis/insertReaction.ts @@ -19,10 +19,9 @@ export const insertReaction = async ({ const storableReaction = mapReactionToStorable(reaction); - // Only a channel's cached window of messages is stored, so a reaction can arrive for a message - // this database has never held - an old one someone reacts to, or a `/sync` replay after a cold - // start. Writing it anyway violates the `reactions.messageId` foreign key and aborts the whole - // batch it travels in. + // A reaction can arrive for a message this database never held (only a channel's recent window + // is cached). Writing it would violate the `reactions.messageId` foreign key and abort the whole + // batch. queries.push( createUpsertQueryIfParentExists('reactions', storableReaction, { column: 'id', diff --git a/package/src/store/apis/queries/selectActiveLocationsForChannels.ts b/package/src/store/apis/queries/selectActiveLocationsForChannels.ts index a700f69931..a844a45574 100644 --- a/package/src/store/apis/queries/selectActiveLocationsForChannels.ts +++ b/package/src/store/apis/queries/selectActiveLocationsForChannels.ts @@ -10,8 +10,7 @@ export const selectActiveLocationsForChannels = async ( SqliteClient.logger?.('info', 'selectActiveLocationsForChannels', { cids, }); - // Active means `endAt` is set and still in the future. `endAt` is unix nanoseconds, so the - // cutoff is `nowNs()` and the comparison is plain numeric. + // Active means `endAt` is set and in the future. Both `endAt` and `nowNs()` are unix nanoseconds. const locations = await SqliteClient.executeSql( `SELECT * FROM locations WHERE channelCid IN (${questionMarks}) AND endAt IS NOT NULL AND endAt > ?`, [...cids, nowNs()], diff --git a/package/src/store/apis/queries/selectChannelQueryForFilterSort.ts b/package/src/store/apis/queries/selectChannelQueryForFilterSort.ts index 38beca582d..5131179278 100644 --- a/package/src/store/apis/queries/selectChannelQueryForFilterSort.ts +++ b/package/src/store/apis/queries/selectChannelQueryForFilterSort.ts @@ -11,17 +11,16 @@ import { SqliteClient } from '../../SqliteClient'; import { convertFilterSortToQuery } from '../utils/convertFilterSortToQuery'; export type CachedChannelQuery = { - /** Channel ids in the order the cached query produced them. */ + /** Channel IDs in the order the cached query produced them. */ cids: string[]; - /** The backend-resolved predefined filter that order was produced by, when there was one. */ + /** The server-resolved predefined filter that produced the order, if any. */ predefinedFilter?: ParsedPredefinedFilterResponse; }; /** - * A stored `predefined_filter` response is only useful if it still describes a filter and, - * optionally, a sort. Anything else in the column — a truncated write, a row left by an older - * shape — is discarded rather than handed to the paginator, which would otherwise match and order - * the whole list by a malformed rule. + * Whether a stored `predefined_filter` value still describes a filter and, optionally, a sort. + * Anything else (such as a truncated write) is discarded, so the paginator never matches and + * orders the list by a malformed rule. */ const isPredefinedFilterResponse = (value: unknown): value is ParsedPredefinedFilterResponse => { if (typeof value !== 'object' || value === null) return false; @@ -47,15 +46,14 @@ const parsePredefinedFilter = ( }; /** - * Gets the cached result of a channel query from the database — the channel ids it produced and the - * backend-resolved predefined filter that produced them. + * Gets the cached result of a channel query: the channel IDs it produced and the server-resolved + * predefined filter behind them, or null when the query is not in the `channelQueries` table. The + * full `options` are part of the query key, as they tell two predefined-filter queries apart. * - * @param {Object} param - * @param {Object} param.filters Filters for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters - * @param {Object} param.options Full query options, which is what tells two predefined-filter queries apart - * @param {Object} param.sort Sort for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters - * - * @returns The cached query, or null if it doesn't exist in the "channelQueries" table. + * @param params.filters Channel filters: https://getstream.io/chat/docs/javascript/query_channels/ + * @param params.options Query options, including `predefined_filter`. + * @param params.sort Channel sort: https://getstream.io/chat/docs/javascript/query_channels/ + * @returns The cached query, or null when it is not cached. */ export const selectChannelQueryForFilterSort = async ({ diff --git a/package/src/store/apis/queries/selectReactionsForMessages.ts b/package/src/store/apis/queries/selectReactionsForMessages.ts index 2323d90df9..6e0087f58b 100644 --- a/package/src/store/apis/queries/selectReactionsForMessages.ts +++ b/package/src/store/apis/queries/selectReactionsForMessages.ts @@ -5,11 +5,14 @@ import { SqliteClient } from '../../SqliteClient'; import type { Scalar, TableRowJoinedUser } from '../../types'; /** - * Fetches reactions for a message from the database for messageIds. - * @param messageIds The message IDs for which reactions are to be fetched. - * @param limit The limit of how many reactions should be returned. - * @param filters A ReactionFilter for the reactions we want to fetch. Only type is currently supported. - * @param sort A sort for reactions to be used when querying. Custom data is currently not supported for sorting. + * Fetches the reactions of the given messages. Only the `type` filter and sorting by `created_at` + * are supported. + * + * @param messageIds The IDs of the messages to fetch reactions for. + * @param limit Maximum number of reactions to return, or `null` for no limit. Defaults to 25. + * @param filters Reaction filters. Only `type` is supported. + * @param sort Reaction sort. Only `created_at` is supported. + * @returns The reactions joined with their users. */ export const selectReactionsForMessages = async ( messageIds: string[], diff --git a/package/src/store/apis/upsertChannels.ts b/package/src/store/apis/upsertChannels.ts index 9f59c4809c..1ec638c738 100644 --- a/package/src/store/apis/upsertChannels.ts +++ b/package/src/store/apis/upsertChannels.ts @@ -21,7 +21,6 @@ export const upsertChannels = async ({ execute?: boolean; isLatestMessagesSet?: boolean; }) => { - // Update the database only if the query is provided. let queries: PreparedQueries[] = []; const channelIds = channels.map((channel) => channel.channel?.cid); diff --git a/package/src/store/apis/upsertCidsForQuery.ts b/package/src/store/apis/upsertCidsForQuery.ts index 796248c1ec..d50e82691a 100644 --- a/package/src/store/apis/upsertCidsForQuery.ts +++ b/package/src/store/apis/upsertCidsForQuery.ts @@ -25,7 +25,6 @@ export const upsertCidsForQuery = async ({ predefinedFilter?: ParsedPredefinedFilterResponse; sort?: SortParamRequest[]; }) => { - // Update the database only if the query is provided. const cidsString = JSON.stringify(cids); const id = convertFilterSortToQuery({ filters, options, sort }); const predefinedFilterString = predefinedFilter ? JSON.stringify(predefinedFilter) : null; diff --git a/package/src/store/apis/upsertDraft.ts b/package/src/store/apis/upsertDraft.ts index 4f3ae7d1ef..1141e90f29 100644 --- a/package/src/store/apis/upsertDraft.ts +++ b/package/src/store/apis/upsertDraft.ts @@ -19,7 +19,7 @@ export const upsertDraft = async ({ const queries: PreparedQueries[] = []; const { channel_cid, parent_id, message } = draft; - // Delete existing draft message if it exists. + // Deletes any existing draft of the same channel and parent first. const deleteQuery = createDeleteQuery('draft', { cid: channel_cid, parentId: parent_id, @@ -27,7 +27,7 @@ export const upsertDraft = async ({ queries.push(deleteQuery); - // Important: Make sure you create a draft only after a draft message is created. + // The draft row references the draft message, so the message is queued first. const storableDraftMessage = mapDraftMessageToStorable({ draftMessage: message }); queries.push(createUpsertQuery('draftMessage', storableDraftMessage)); diff --git a/package/src/store/constants.ts b/package/src/store/constants.ts index 79ed28573d..2dfeba660b 100644 --- a/package/src/store/constants.ts +++ b/package/src/store/constants.ts @@ -2,10 +2,5 @@ export const DB_NAME = 'stream-chat-react-native'; export const DB_LOCATION = 'databases'; export const DB_STATUS_ERROR = 1; -/** - * Default value for the `maxSyncEventsLimit` prop on `Chat`. Chosen conservatively - * and below the backend hard cap; tune with performance data. The underlying LLC - * (`stream-chat`) has no default of its own, this default is implied purely by the - * RN SDK. - */ +/** Default for the `maxSyncEventsLimit` prop on `Chat`, below the server's hard cap. */ export const DEFAULT_MAX_SYNC_EVENTS_LIMIT = 250; diff --git a/package/src/store/mappers/mapPollToStorable.ts b/package/src/store/mappers/mapPollToStorable.ts index ec5bb33dbb..a560b7df57 100644 --- a/package/src/store/mappers/mapPollToStorable.ts +++ b/package/src/store/mappers/mapPollToStorable.ts @@ -33,7 +33,7 @@ export const mapPollToStorable = (poll: PollResponseData): TableRow<'poll'> => { allow_user_suggested_options, answers_count, created_at: mapTimestampToStorable(created_at), - created_by: JSON.stringify(created_by), // decouple the users from the actual poll + created_by: JSON.stringify(created_by), // stored as JSON to decouple the user from the poll created_by_id, description, enforce_unique_vote, diff --git a/package/src/store/mappers/mapReadToStorable.ts b/package/src/store/mappers/mapReadToStorable.ts index 5dec1bdab0..a8461a7f39 100644 --- a/package/src/store/mappers/mapReadToStorable.ts +++ b/package/src/store/mappers/mapReadToStorable.ts @@ -24,7 +24,7 @@ export const mapReadToStorable = ({ cid, lastDeliveredAt: mapTimestampToStorable(last_delivered_at), lastDeliveredMessageId: last_delivered_message_id, - // `0` is the LLC's epoch sentinel; the column is `INTEGER NOT NULL`. + // `0` is the epoch sentinel of `stream-chat`; the column is `INTEGER NOT NULL`. lastRead: last_read ?? 0, lastReadMessageId: last_read_message_id, unreadMessages: unread_messages, diff --git a/package/src/store/mappers/mapStorableToPoll.ts b/package/src/store/mappers/mapStorableToPoll.ts index eaf9387e53..ccd7e13df1 100644 --- a/package/src/store/mappers/mapStorableToPoll.ts +++ b/package/src/store/mappers/mapStorableToPoll.ts @@ -49,9 +49,8 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponseData = updated_at: mapStorableToRequiredTimestamp(updated_at), vote_count, vote_counts_by_option: JSON.parse(vote_counts_by_option), - // `voting_visibility` is `'anonymous' | 'public'` on the response, and the column is TEXT. - // The `?? ''` fallback was never a valid value — it only compiled because the date fields on - // this same object literal were already failing and masking it. Default to the API's default. + // The column is TEXT, while the response type is `'anonymous' | 'public'`. A missing value + // falls back to the API default. voting_visibility: (voting_visibility as PollResponseData['voting_visibility']) ?? 'public', }; }; diff --git a/package/src/store/mappers/mapStorableToTimestamp.ts b/package/src/store/mappers/mapStorableToTimestamp.ts index 6a037559ce..eb954cd5a0 100644 --- a/package/src/store/mappers/mapStorableToTimestamp.ts +++ b/package/src/store/mappers/mapStorableToTimestamp.ts @@ -2,23 +2,19 @@ import { asTimestampNS } from 'stream-chat'; import type { TimestampNS } from 'stream-chat'; /** - * A timestamp on its way *out of* SQLite. The inverse of `mapTimestampToStorable`, and equally - * conversion-free: the column already holds the unix-nanosecond number every response and event - * field carries, so it is branded as a `TimestampNS` without being converted. + * Brands a stored unix-nanosecond column as a `TimestampNS` without converting it (the inverse of + * `mapTimestampToStorable`), and maps SQLite's `null` for an absent timestamp to `undefined`. * - * It exists for the one thing SQLite and the response types disagree about — SQLite says `null` - * for an absent timestamp, the generated types say `undefined`. - * - * Every date column is nullable, so use {@link mapStorableToRequiredTimestamp} for a field the model - * declares required (`created_at` / `updated_at`). The compiler cannot catch a miss in a mapper whose - * literal ends with `...JSON.parse(extraData)` — spreading `any` disables the check. + * Every date column is nullable, so use {@link mapStorableToRequiredTimestamp} for fields the model + * declares required (`created_at`, `updated_at`). The compiler cannot catch a miss in a mapper + * whose literal ends with `...JSON.parse(extraData)`, because spreading `any` disables the check. */ export const mapStorableToTimestamp = (timestamp?: number | null): TimestampNS | undefined => timestamp == null ? undefined : asTimestampNS(timestamp); /** * {@link mapStorableToTimestamp} for a field the model declares required: an absent column reads - * back as the epoch, which every "no timestamp" consumer already treats as nothing having happened. + * back as the epoch, which consumers treat as no timestamp. */ export const mapStorableToRequiredTimestamp = (timestamp?: number | null): TimestampNS => mapStorableToTimestamp(timestamp) ?? asTimestampNS(0); diff --git a/package/src/store/mappers/mapTimestampToStorable.ts b/package/src/store/mappers/mapTimestampToStorable.ts index 18838416d5..014b5f0d8e 100644 --- a/package/src/store/mappers/mapTimestampToStorable.ts +++ b/package/src/store/mappers/mapTimestampToStorable.ts @@ -1,14 +1,9 @@ /** - * A timestamp on its way *into* SQLite. Unix nanoseconds in, unix nanoseconds out — every date - * column is `INTEGER` holding exactly what the API put on the wire, so there is no conversion here. - * Lossless relative to what JavaScript holds: `JSON.parse` already quantised the value to the - * nearest double (~256 ns) before SQLite saw it. + * Stores a unix-nanosecond timestamp in its `INTEGER` column unchanged, mapping `undefined` to + * `null`. Nothing is lost: `JSON.parse` already rounded the value to the nearest double (~256 ns). * - * The only thing this function does is pick `null` over `undefined`, and that choice is - * load-bearing rather than stylistic: `upsertStatementParts` omits `undefined` values from the - * column list, so an absent date on an upsert-**update** would silently keep whatever was - * already stored. Writing an explicit `null` clears it, which is what the previous ISO mapper - * did by returning `''`. + * The `null` matters: `upsertStatementParts` omits `undefined` values from the column list, so an + * absent date on an upsert-update would keep the stored value rather than clear it. */ export const mapTimestampToStorable = (timestamp?: number | null): number | null => timestamp ?? null; diff --git a/package/src/store/schema.ts b/package/src/store/schema.ts index 692591c2cf..d5cf27c5f4 100644 --- a/package/src/store/schema.ts +++ b/package/src/store/schema.ts @@ -348,10 +348,7 @@ export type Schema = { channelQueries: { cids: string; id: string; - /** - * The backend-resolved `predefined_filter` metadata this cid order was produced by, as JSON. - * Null for a plain `filter_conditions` query. - */ + /** JSON of the server-resolved `predefined_filter` this CID order came from; null otherwise. */ predefinedFilter?: string | null; }; channels: { diff --git a/package/src/store/sqlite-utils/createDeleteQuery.ts b/package/src/store/sqlite-utils/createDeleteQuery.ts index 283b062e94..376b68dea9 100644 --- a/package/src/store/sqlite-utils/createDeleteQuery.ts +++ b/package/src/store/sqlite-utils/createDeleteQuery.ts @@ -4,13 +4,12 @@ import type { Schema } from '../schema'; import type { PreparedQueries, TableColumnNames, TableColumnValue } from '../types'; /** - * Creates a simple delete query for sqlite. + * Creates a prepared `DELETE` query. In `whereCondition` an array value matches with `IN`, a key + * with an `undefined` value is skipped, and the conditions are joined with AND. * - * @param {string} table Table name - * @param {Object} whereCondition Where condition for select query. - * e.g., { id: 'vishal', cid: ['messaging:id1', 'messaging:id2'] }. - * All the conditions will be joined with AND in final query. - * @returns {string} Final select query + * @param table The table name. + * @param whereCondition Column values to match, for example `{ id: 'a', cid: ['b', 'c'] }`. + * @returns The prepared query as `[sql, params]`. */ export const createDeleteQuery = ( diff --git a/package/src/store/sqlite-utils/createSelectQuery.ts b/package/src/store/sqlite-utils/createSelectQuery.ts index 43262786e6..d0defa6939 100644 --- a/package/src/store/sqlite-utils/createSelectQuery.ts +++ b/package/src/store/sqlite-utils/createSelectQuery.ts @@ -5,14 +5,15 @@ import type { Schema } from '../schema'; import type { PreparedQueries, TableColumnNames, TableColumnValue } from '../types'; /** - * Creates a simple select query for sqlite. + * Creates a prepared `SELECT` query for `fields` (defaults to `['*']`). In `whereCondition` an + * array value matches with `IN`, a key with an `undefined` value is skipped, and the conditions are + * joined with AND. * - * @param {string} table Table name - * @param {Array} fields Array of columns which need to be selected e.g., ['*'] or ['id', 'name'] - * @param {Object} whereCondition Where condition for select query. - * e.g., { id: 'vishal', cid: ['messaging:id1', 'messaging:id2'] }. - * All the conditions will be joined with AND in final query. - * @returns {string} Final select query + * @param table The table name. + * @param fields The columns to select. Defaults to `['*']`. + * @param whereCondition Column values to match, for example `{ id: 'a', cid: ['b', 'c'] }`. + * @param orderBy Column sort directions, `1` for ascending and `-1` for descending. + * @returns The prepared query as `[sql, params]`. */ export const createSelectQuery = ( table: T, diff --git a/package/src/store/sqlite-utils/createUpdateQuery.ts b/package/src/store/sqlite-utils/createUpdateQuery.ts index 3a1a65ac16..a048a4abbe 100644 --- a/package/src/store/sqlite-utils/createUpdateQuery.ts +++ b/package/src/store/sqlite-utils/createUpdateQuery.ts @@ -4,14 +4,14 @@ import type { Schema } from '../schema'; import type { PreparedQueries, TableColumnNames, TableColumnValue, TableRow } from '../types'; /** - * Creates a simple update query for sqlite. + * Creates a prepared `UPDATE OR IGNORE` query that sets the columns in `set`. In `whereCondition` + * an array value matches with `IN`, a key with an `undefined` value is skipped, and the conditions + * are joined with AND. * - * @param {string} table Table name - * @param {Object} set Set conditions for update query. - * @param {Object} whereCondition Where condition for select query. - * e.g., { id: 'vishal', cid: ['messaging:id1', 'messaging:id2'] }. - * All the conditions will be joined with AND in final query. - * @returns {string} Final update query for sqlite + * @param table The table name. + * @param set The column values to write. + * @param whereCondition Column values to match, for example `{ id: 'a', cid: ['b', 'c'] }`. + * @returns The prepared query as `[sql, params]`. */ export const createUpdateQuery = ( table: T, diff --git a/package/src/store/sqlite-utils/createUpsertQuery.ts b/package/src/store/sqlite-utils/createUpsertQuery.ts index b5d90418b1..54c9998e7d 100644 --- a/package/src/store/sqlite-utils/createUpsertQuery.ts +++ b/package/src/store/sqlite-utils/createUpsertQuery.ts @@ -2,9 +2,8 @@ import { Schema, tables } from '../schema'; import type { PreparedQueries, TableColumnNames, TableRow } from '../types'; /** - * The pieces every upsert statement is built from, shared with - * {@link import('./createUpsertQueryIfParentExists').createUpsertQueryIfParentExists} so the two - * forms cannot drift in how they filter columns or resolve conflict keys. + * The pieces every upsert statement is built from, shared with `createUpsertQueryIfParentExists` so + * both filter columns and resolve conflict keys the same way. * * @internal */ @@ -15,12 +14,9 @@ export const upsertStatementParts = ( ) => { const filteredRow: typeof row = {}; - // In case of "DO UPDATE SET", we only want to update the properties which - // are provided, and not set undefined properties in database. - // E.g., channel date such as `own_capabilities` is only available in response of client.queryChannels or channel.query. - // But its not available in `event.channel`. And our mapper functions such as mapChannelToStorable will set fields which are not available as undefined. - // So when you execute upsert query for storable value of `event.channel` in `channels` table, it will - // unset ownCapabilities field for that channel in `channels` table. + // `DO UPDATE SET` only updates the provided properties. Mappers set unavailable fields to + // `undefined` (`own_capabilities` is missing from `event.channel`, for example), and writing + // those would clear the stored values. for (const key in row) { if (row[key] !== undefined) { filteredRow[key] = row[key]; @@ -49,12 +45,13 @@ export const upsertStatementParts = ( }; /** - * Creates a simple upsert query for sqlite. + * Creates a prepared upsert query. Conflicts are checked on `conflictCheckKeys`, defaulting to the + * primary key (https://www.sqlite.org/lang_UPSERT.html). `undefined` row values are left out. * - * @param {string} table Table name - * @param {Object} row Table row to insert or update. - * @param {Array} conflictCheckKeys Custom list of columns to check conflicts for - https://www.sqlite.org/lang_UPSERT.html. By default conflicts are checked on primary keys. - * @returns {string} Final upsert query for sqlite + * @param table The table name. + * @param row The table row to insert or update. + * @param conflictCheckKeys The columns to check conflicts on. Defaults to the primary key. + * @returns The prepared query as `[sql, params]`. */ export const createUpsertQuery = ( table: T, diff --git a/package/src/store/sqlite-utils/createUpsertQueryIfParentExists.ts b/package/src/store/sqlite-utils/createUpsertQueryIfParentExists.ts index f31440bc38..486dbc0596 100644 --- a/package/src/store/sqlite-utils/createUpsertQueryIfParentExists.ts +++ b/package/src/store/sqlite-utils/createUpsertQueryIfParentExists.ts @@ -3,9 +3,7 @@ import { upsertStatementParts } from './createUpsertQuery'; import { Schema } from '../schema'; import type { PreparedQueries, TableColumnNames, TableRow } from '../types'; -/** - * The row this write depends on - the parent side of a foreign key declared in {@link Schema}. - */ +/** The row this write depends on: the parent side of a foreign key declared in {@link Schema}. */ type ParentRow = { column: string; table: keyof Schema; @@ -13,31 +11,23 @@ type ParentRow = { }; /** - * An upsert that writes nothing at all when the row it references is absent. - * - * Deliberately NOT an option on `createUpsertQuery`: "insert or update" is a contract worth keeping - * exact, and this is a third thing - "insert or update, or do nothing" - so it says so in its name - * rather than hiding behind a flag. - * - * For the tables whose schema declares a foreign key. Only part of a channel's messages are ever - * cached, so a child row can genuinely arrive for a parent this database has never held - a reaction - * on an old message, say. SQLite rejects that child, and since these queries are executed as one - * batch, the one rejected statement aborts every unrelated write alongside it. + * An upsert that writes nothing when the parent row it references is absent. Conflicts are checked + * on `conflictCheckKeys`, defaulting to the primary key. * - * Skipping is the honest outcome rather than the lesser evil: these tables mirror what is held - * locally, so a child with no parent has nothing to hang off, and it comes back on its own once the - * parent is cached (a message arrives carrying its own reactions). + * It is separate from `createUpsertQuery` so that function stays a plain "insert or update". Only + * part of a channel's messages is cached, so a child row (a reaction, say) can arrive for a parent + * the database never held. SQLite rejects it, and as queries run in one batch, that aborts every + * unrelated write alongside it. Skipping it is correct: the child returns with its parent once that + * is cached. * - * Expressed as `WHERE EXISTS` inside the statement rather than as a separate probe so it costs no - * extra round trip and cannot race with the write it guards - and the lookup it does is the same - * index lookup the foreign key already forces on every insert. SQLite requires that `WHERE` when an - * `INSERT ... SELECT` is followed by `ON CONFLICT`, so the clause doing the guarding is also what - * keeps the upsert unambiguous. + * The check is a `WHERE EXISTS` inside the statement, so it costs no round trip and cannot race the + * write. SQLite requires that `WHERE` when `INSERT ... SELECT` is followed by `ON CONFLICT`. * - * @param table Table name. - * @param row Table row to insert or update. + * @param table The table name. + * @param row The table row to insert or update. * @param parent The row that must exist for anything to be written. - * @param conflictCheckKeys Custom list of columns to check conflicts for. Defaults to primary keys. + * @param conflictCheckKeys The columns to check conflicts on. Defaults to the primary key. + * @returns The prepared query as `[sql, params]`. */ export const createUpsertQueryIfParentExists = ( table: T, diff --git a/package/src/theme/topologicalResolution.ts b/package/src/theme/topologicalResolution.ts index 384d7061f0..35f361793c 100644 --- a/package/src/theme/topologicalResolution.ts +++ b/package/src/theme/topologicalResolution.ts @@ -5,14 +5,16 @@ const isPlainObject = (value: unknown): value is Record => { }; /** - * Resolves "$token" references in `dictionary` by performing a DFS. - * deps first, then node - i.e. a topological evaluation. + * Resolves `$token` references in `dictionary` depth first, dependencies before the token that uses + * them, so declaration order does not matter. Throws on a cycle or an unresolved reference. + * + * @param dictionary The semantic tokens, whose values may reference other tokens as `$token`. + * @returns The dictionary with every reference resolved. */ export const resolveTokensTopologically = (dictionary: T): T => { const resolvedMemo = new Map(); - // Used purely for cycle detection (even though we do not expect - // cycles we want to assert early to make sure we raise an alarm). + // Cycle detection: none are expected, but a cycle must fail loudly. const visiting = new Set(); const resolveValueDeep = (value: unknown): unknown => { @@ -31,11 +33,7 @@ export const resolveTokensTopologically = (diction return value; }; - /** - * This is the topological sort part: - * - DFS into dependencies first - * - then memoize the current token - */ + /** Resolves a token's dependencies first, then memoizes the token. */ const dfs = (tokenKeyOrPath: string, originalRef: string): unknown => { const cacheKey = tokenKeyOrPath; @@ -50,13 +48,10 @@ export const resolveTokensTopologically = (diction const raw = dictionary[tokenKeyOrPath as keyof Theme['semantics']]; if (raw === undefined) { - // is throwing maybe too strict here ? throw new Error(`Unresolved reference: "${originalRef}" (missing "${tokenKeyOrPath}")`); } - // resolve dependencies inside `raw` BEFORE finalizing this token - // so that we can throw if there's a cycle or the dep graph is not - // connected) + // Dependencies resolve before the token is memoized, so a cycle or a missing reference throws. const fullyResolved = resolveValueDeep(raw); resolvedMemo.set(cacheKey, fullyResolved); diff --git a/package/src/types/react-native-compat.ts b/package/src/types/react-native-compat.ts index 9db55f8fc1..5cc99b7a8f 100644 --- a/package/src/types/react-native-compat.ts +++ b/package/src/types/react-native-compat.ts @@ -10,20 +10,10 @@ import type { } from 'react-native'; /** - * Portable React Native ref (host instance) types. - * - * React Native 0.87 made the Strict TypeScript API the default. Under it the core components are - * function components rather than classes, so a component type is no longer usable as its own ref - * type - `useRef(null)` no longer type checks against ``. React Native now - * publishes dedicated instance types (`ViewInstance`, `TextInputInstance`, ...) instead. - * - * Those `*Instance` types do not exist on React Native < 0.87 and this SDK supports `>=0.79`, so we - * cannot import them directly. `React.ComponentRef` resolves to the correct instance type - * on *both* type generations - it is exactly how React Native itself defines `ViewInstance` and - * friends internally - so it is the portable spelling and the one to use throughout the SDK. - * - * When the SDK's minimum supported React Native reaches 0.87, these aliases can be swapped for the - * upstream `*Instance` types in a single edit. + * Portable React Native ref (host instance) types. From React Native 0.87 the core components are + * function components, so a component type cannot double as its ref type, and the `ViewInstance` + * family of instance types is absent before 0.87 (the SDK supports `>=0.79`). + * `React.ComponentRef` resolves to the right instance type on both. */ export type ViewRef = React.ComponentRef; @@ -33,23 +23,17 @@ export type ScrollViewRef = React.ComponentRef; export type FlatListRef = React.ComponentRef>; export type SectionListRef = React.ComponentRef>; -/** - * `KeyboardEventListener` was dropped from React Native's root type exports in 0.87. It was only ever - * an alias for a handler taking a `KeyboardEvent`, so we re-declare it here rather than deep importing. - */ +/** Handler for a `KeyboardEvent`. Declared here because React Native 0.87+ does not export it. */ export type KeyboardEventListener = (event: KeyboardEvent) => void; /** - * `ViewToken` and `ViewabilityConfig` are no longer exported from the `react-native` root under the - * Strict TypeScript API - `ViewToken` moved to `@react-native/virtualized-lists` (re-exported from - * the root as `ListViewToken`) and `ViewabilityConfig` is no longer re-exported at all. Both appear - * in this SDK's public message list surface, and neither spelling exists across the whole `>=0.79` - * range, so we declare them structurally. The shapes are stable and shared by `FlatList` and - * `@shopify/flash-list`. + * Structural `ViewToken` and `ViewabilityConfig`, which appear in the public message list surface. + * The `react-native` root exports them differently across `>=0.79` (under the Strict TypeScript API + * `ViewToken` is `ListViewToken` and `ViewabilityConfig` is not exported), and `FlatList` and + * `@shopify/flash-list` share the shapes. * - * `index` is deliberately widened to `number | null | undefined`: React Native typed it `number | null` - * up to 0.86 and `number | undefined` from 0.87, and as consumers of the callback we must accept - * whichever the running version hands us. + * `index` is `number | null | undefined` because React Native types it `number | null` up to 0.86 + * and `number | undefined` from 0.87. */ export type ViewToken = { index: number | null | undefined; @@ -60,24 +44,15 @@ export type ViewToken = { }; export type ViewabilityConfig = { - /** - * Minimum amount of time (in milliseconds) that an item must be physically viewable before the - * viewability callback will be fired. - */ + /** Minimum time in milliseconds an item must be viewable before the viewability callback runs. */ minimumViewTime?: number | undefined; /** - * Percent of viewport that must be covered for a partially occluded item to count as "viewable", - * 0-100. Fully visible items are always considered viewable. + * Percent (0-100) of the viewport that must be covered for a partially occluded item to count as + * viewable. Fully visible items always count. */ viewAreaCoveragePercentThreshold?: number | undefined; - /** - * Similar to `viewAreaCoveragePercentThreshold`, but considers the percent of the item that is - * visible rather than the fraction of the viewable area it covers. - */ + /** Like `viewAreaCoveragePercentThreshold`, but measured as the percent of the item visible. */ itemVisiblePercentThreshold?: number | undefined; - /** - * Nothing is considered viewable until the user scrolls or `recordInteraction` is called after - * render. - */ + /** When true, nothing is viewable until the user scrolls or `recordInteraction` is called. */ waitForInteraction?: boolean | undefined; }; diff --git a/package/src/types/types.ts b/package/src/types/types.ts index e0fbc21819..b27fd34595 100644 --- a/package/src/types/types.ts +++ b/package/src/types/types.ts @@ -42,13 +42,11 @@ export type UploadAttachmentPreviewProps = { }; /** - * This SDK adds nothing of its own to an attachment's custom data. + * The SDK adds nothing of its own to an attachment's custom data. * * An attachment whose upload has not resolved keeps `localMetadata` (`id`, `file`, `previewUri`) - * from `stream-chat`'s own composition, which is what the message list joins to the live - * `uploadManager` record and what the send path needs to upload or retry. The SDK used to smuggle - * that through `custom.localId` / `custom.originalFile` instead, squatting in data that belongs to - * the integrator. `mime_type` and `file_size` come from `stream-chat` itself. + * from `stream-chat`. The message list joins it to the live `uploadManager` record, and the send + * path uses it to upload or retry. */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface DefaultAttachmentData {} @@ -99,7 +97,6 @@ export type UnknownType = Record; export type ValueOf = T[keyof T]; -// ASYNC AUDIO EXPO: export enum AndroidOutputFormat { DEFAULT = 0, THREE_GPP = 1, @@ -169,53 +166,39 @@ export enum IOSAudioQuality { } export type RecordingOptionsAndroid = { - /** - * The desired audio encoder. See the [`AndroidAudioEncoder`](#androidaudioencoder) enum for all valid values. - */ + /** The desired audio encoder. See the `AndroidAudioEncoder` enum for valid values. */ audioEncoder: AndroidAudioEncoder | number; /** - * The desired file extension. Example valid values are `.3gp` and `.m4a`. - * For more information, see the [Android docs](https://developer.android.com/guide/topics/media/media-formats) - * for supported output formats. + * The desired file extension, such as `.3gp` or `.m4a`. See the + * [Android docs](https://developer.android.com/guide/topics/media/media-formats) for supported + * output formats. */ extension: string; - /** - * The desired file format. See the [`AndroidOutputFormat`](#androidoutputformat) enum for all valid values. - */ + /** The desired file format. See the `AndroidOutputFormat` enum for valid values. */ outputFormat: AndroidOutputFormat | number; /** - * The desired bit rate. - * - * Note that `prepareToRecordAsync()` may perform additional checks on the parameter to make sure whether the specified - * bit rate is applicable, and sometimes the passed bitRate will be clipped internally to ensure the audio recording - * can proceed smoothly based on the capabilities of the platform. + * The desired bit rate. `prepareToRecordAsync()` may clip it to what the platform supports. * * @example `128000` */ bitRate?: number; /** - * The desired maximum file size in bytes, after which the recording will stop (but `stopAndUnloadAsync()` must still - * be called after this point). + * The maximum file size in bytes, after which the recording stops. `stopAndUnloadAsync()` must + * still be called. * * @example `65536` */ maxFileSize?: number; /** - * The desired number of channels. - * - * Note that `prepareToRecordAsync()` may perform additional checks on the parameter to make sure whether the specified - * number of audio channels are applicable. + * The desired number of channels. `prepareToRecordAsync()` may check that the platform supports + * it. * * @example `1`, `2` */ numberOfChannels?: number; /** - * The desired sample rate. - * - * Note that the sampling rate depends on the format for the audio recording, as well as the capabilities of the platform. - * For instance, the sampling rate supported by AAC audio coding standard ranges from 8 to 96 kHz, - * the sampling rate supported by AMRNB is 8kHz, and the sampling rate supported by AMRWB is 16kHz. - * Please consult with the related audio coding standard for the supported audio sampling rate. + * The desired sample rate. The supported rates depend on the output format and the platform: AAC + * supports 8 to 96 kHz, AMR-NB 8 kHz and AMR-WB 16 kHz. * * @example 44100 */ @@ -223,9 +206,7 @@ export type RecordingOptionsAndroid = { }; export type RecordingOptionsIOS = { - /** - * The desired audio quality. See the [`IOSAudioQuality`](#iosaudioquality) enum for all valid values. - */ + /** The desired audio quality. See the `IOSAudioQuality` enum for valid values. */ audioQuality: IOSAudioQuality | number; /** * The desired bit rate. @@ -257,9 +238,7 @@ export type RecordingOptionsIOS = { * @example `16` */ bitDepthHint?: number; - /** - * The desired bit rate strategy. See the next section for an enumeration of all valid values of `bitRateStrategy`. - */ + /** The desired bit rate strategy. */ bitRateStrategy?: number; /** * The desired PCM bit depth. @@ -267,17 +246,11 @@ export type RecordingOptionsIOS = { * @example `16` */ linearPCMBitDepth?: number; - /** - * A boolean describing if the PCM data should be formatted in big endian. - */ + /** Whether the PCM data is formatted in big endian. */ linearPCMIsBigEndian?: boolean; - /** - * A boolean describing if the PCM data should be encoded in floating point or integral values. - */ + /** Whether the PCM data is encoded as floating point (true) or integral (false) values. */ linearPCMIsFloat?: boolean; - /** - * The desired file format. See the [`IOSOutputFormat`](#iosoutputformat) enum for all valid values. - */ + /** The desired file format. See the `IOSOutputFormat` enum for valid values. */ outputFormat?: string | IOSOutputFormat | number; }; @@ -288,66 +261,53 @@ export type RecordingOptionsWeb = { }; export type ExpoRecordingOptions = { - /** - * Recording options for the Android platform. - */ + /** Recording options for Android. */ android: RecordingOptionsAndroid; - /** - * Recording options for the iOS platform. - */ + /** Recording options for iOS. */ ios: RecordingOptionsIOS; - /** - * Recording options for the Web platform. - */ + /** Recording options for the web. */ web: RecordingOptionsWeb; - /** - * A boolean that determines whether audio level information will be part of the status object under the "metering" key. - */ + /** Whether audio level information is included in the status object under the `metering` key. */ isMeteringEnabled?: boolean; /** - * A boolean that hints to keep the audio active after `prepareToRecordAsync` completes. - * Setting this value can improve the speed at which the recording starts. Only set this value to `true` when you call `startAsync` - * immediately after `prepareToRecordAsync`. This value is automatically set when using `Audio.recording.createAsync()`. + * Keeps the audio active after `prepareToRecordAsync` completes, which can speed up the start of + * the recording. Set it to `true` only when `startAsync` is called immediately afterwards. Set + * automatically by `Audio.recording.createAsync()`. */ keepAudioActiveHint?: boolean; }; export type AudioMode = { /** - * A boolean selecting if recording is enabled on iOS. - * > When this flag is set to `true`, playback may be routed to the phone earpiece instead of to the speaker. Set it back to `false` after stopping recording to reenable playback through the speaker. + * Whether recording is enabled on iOS. While `true`, playback may be routed to the earpiece + * instead of the speaker; set it back to `false` after recording to restore speaker playback. * @default false */ allowsRecordingIOS: boolean; - /** - * An enum selecting how your experience's audio should interact with the audio from other apps on Android. - */ + /** How the app's audio interacts with audio from other apps on Android. */ interruptionModeAndroid: InterruptionModeAndroid; - /** - * An enum selecting how your experience's audio should interact with the audio from other apps on iOS. - */ + /** How the app's audio interacts with audio from other apps on iOS. */ interruptionModeIOS: InterruptionModeIOS; /** - * A boolean selecting if your experience's audio should play in silent mode on iOS. + * Whether the app's audio plays in silent mode on iOS. * @default false */ playsInSilentModeIOS: boolean; /** - * A boolean selecting if the audio is routed to earpiece on Android. + * Whether the audio is routed to the earpiece on Android. * @default false */ playThroughEarpieceAndroid: boolean; /** - * A boolean selecting if your experience's audio should automatically be lowered in volume ("duck") if audio from another - * app interrupts your experience. If `false`, audio from other apps will pause your audio. + * Whether the app's audio is lowered in volume ("ducked") when another app plays audio. When + * `false`, audio from other apps pauses the app's audio. * @default true */ shouldDuckAndroid: boolean; /** - * A boolean selecting if the audio session (playback or recording) should stay active even when the app goes into background. - * > This is not available in Expo Go for iOS, it will only work in standalone apps. - * > To enable it for standalone apps, [follow the instructions below](#playing-or-recording-audio-in-background) - * > to add `UIBackgroundModes` to your app configuration. + * Whether the audio session (playback or recording) stays active while the app is in the + * background. Not available in Expo Go for iOS; standalone apps also need `UIBackgroundModes` in + * the app configuration. * @default false */ staysActiveInBackground: boolean; @@ -355,28 +315,18 @@ export type AudioMode = { // @needsAudit export enum InterruptionModeIOS { - /** - * **This is the default option.** If this option is set, your experience's audio is mixed with audio playing in background apps. - */ + /** The app's audio mixes with audio playing in background apps. This is the default. */ MixWithOthers = 0, - /** - * If this option is set, your experience's audio interrupts audio from other apps. - */ + /** The app's audio interrupts audio from other apps. */ DoNotMix = 1, - /** - * If this option is set, your experience's audio lowers the volume ("ducks") of audio from other apps while your audio plays. - */ + /** The app's audio lowers the volume ("ducks") of other apps' audio while it plays. */ DuckOthers = 2, } export enum InterruptionModeAndroid { - /** - * If this option is set, your experience's audio interrupts audio from other apps. - */ + /** The app's audio interrupts audio from other apps. */ DoNotMix = 1, - /** - * **This is the default option.** If this option is set, your experience's audio lowers the volume ("ducks") of audio from other apps while your audio plays. - */ + /** The app's audio ducks other apps' audio while it plays. This is the default. */ DuckOthers = 2, } @@ -385,8 +335,6 @@ export type ExpoAudioRecordingConfiguration = { options?: Partial; }; -// ASYNC AUDIO RN CLI - export enum AudioSourceAndroidType { DEFAULT = 0, MIC, @@ -522,9 +470,7 @@ export interface AudioSet { export type RNCLIRecordingOptions = { audioSet?: AudioSet; - /** - * A boolean that determines whether audio level information will be part of the status object under the "metering" key. - */ + /** Whether audio level information is included in the status object under the `metering` key. */ isMeteringEnabled?: boolean; }; @@ -546,8 +492,8 @@ export type EmojiSearchIndex = { }; /** - * Override the file upload request used to upload the channel image. - * By default the SDK uploads to Stream's CDN via `client.uploadImage`. + * Overrides the request that uploads the channel image. Defaults to `client.uploadImage`, which + * uploads to Stream's CDN. * @param file File object to upload */ export type GlobalFileUploadRequest = (file: File) => Promise; diff --git a/package/src/utils/attachmentUrls.ts b/package/src/utils/attachmentUrls.ts index d1f51a8075..563e650723 100644 --- a/package/src/utils/attachmentUrls.ts +++ b/package/src/utils/attachmentUrls.ts @@ -6,14 +6,12 @@ const localFileUri = (attachment: Attachment | LocalAttachment) => : undefined; /** - * The URL to play a video from — `asset_url` / `image_url`, or the local file it has yet to be + * The URL to play a video from: `asset_url`, `image_url`, or the local file it is yet to be * uploaded from. * - * Only videos need this. For them `setupVideoAttachmentPreviewMiddleware` replaces - * `localMetadata.previewUri` with the thumbnail the picker extracted, so `stream-chat`'s - * `getAttachmentPreviewUrl` would play the thumbnail. For every other type the LLC's - * `toLocalUploadAttachment` sets `previewUri` to the picked file's own `uri`, so use - * `getAttachmentPreviewUrl` there. + * Videos only; use `getAttachmentPreviewUrl` for other types. The + * `setupVideoAttachmentPreviewMiddleware` sets `localMetadata.previewUri` to the video's + * `thumb_url`, so `getAttachmentPreviewUrl` would return the thumbnail, not the video. */ export const getPlayableVideoUrl = (attachment: Attachment | LocalAttachment) => attachment.asset_url ?? attachment.image_url ?? localFileUri(attachment); diff --git a/package/src/utils/compressImage.ts b/package/src/utils/compressImage.ts index ff6b7a24f4..f7d928ea2a 100644 --- a/package/src/utils/compressImage.ts +++ b/package/src/utils/compressImage.ts @@ -2,19 +2,16 @@ import { NativeHandlers } from '../native'; import type { File } from '../types/types'; /** - * Function to compress and Image and return the compressed Image URI - * @param image - * @param compressImageQuality - * @returns string + * Compresses an image and returns the URI of the compressed image. + * + * @param image The image to compress. + * @param compressImageQuality Compression quality. Compression is skipped when it is not a number + * or is 1. + * @returns The compressed image URI, or the original URI when compression is skipped. */ export const compressedImageURI = async (image: File, compressImageQuality?: number) => { const uri = image.uri || ''; - /** - * We skip compression if: - * - the file is from the camera as that should already be compressed - * - the file has no height/width value to maintain for compression - * - the compressImageQuality number is not present or is 1 (meaning no compression) - */ + // Compression is skipped without a height or width, and when the quality is not a number or is 1. const compressedUri = await (!image.height || !image.width || typeof compressImageQuality !== 'number' || diff --git a/package/src/utils/getResizedImageUrl.ts b/package/src/utils/getResizedImageUrl.ts index ace70d6172..e885eaf46c 100644 --- a/package/src/utils/getResizedImageUrl.ts +++ b/package/src/utils/getResizedImageUrl.ts @@ -13,16 +13,16 @@ export type GetResizedImageUrlParams = Pick url.includes(rCDNh)) && originalHeight && originalWidth; @@ -60,8 +58,7 @@ export function getResizedImageUrl({ return parsedUrl.toString(); } catch (error) { - // There is some issue with the url. - // Simply return the original url, there is no need to break the app for this. + // An unparsable URL is returned as is rather than breaking the render. console.warn(error); return url; diff --git a/package/src/utils/getTrimmedAttachmentTitle.ts b/package/src/utils/getTrimmedAttachmentTitle.ts index c5a94ed84e..ffabc0274b 100644 --- a/package/src/utils/getTrimmedAttachmentTitle.ts +++ b/package/src/utils/getTrimmedAttachmentTitle.ts @@ -1,5 +1,5 @@ -// Add dots in between the title if it is too long and then append the remaining last characters. -// Eg: "This is a very long title" => "This is a very long ti...le" +// Shortens a title longer than `maxLength` (default 18) to its first and last `maxLength / 2` +// characters joined by an ellipsis: "This is a very long title" => "This is a...ong title". export const getTrimmedAttachmentTitle = (title?: string, maxLength?: number) => { const maxLengthValue = maxLength || 18; if (!title) return ''; diff --git a/package/src/utils/getUrlOfImageAttachment.ts b/package/src/utils/getUrlOfImageAttachment.ts index 8f5de10c36..d8f1a457f7 100644 --- a/package/src/utils/getUrlOfImageAttachment.ts +++ b/package/src/utils/getUrlOfImageAttachment.ts @@ -5,9 +5,11 @@ import { getPlayableVideoUrl } from './attachmentUrls'; import { FileTypes } from '../types/types'; /** - * Extract url of image from image attachment. - * @param image Image attachment - * @returns {string} + * Returns the URL to display for an image attachment. + * + * @param image The image attachment. + * @param giphyVersion The Giphy rendition used for Giphy attachments. Defaults to 'fixed_height'. + * @returns The image URL. */ export function getUrlOfImageAttachment( image: Attachment, @@ -17,7 +19,7 @@ export function getUrlOfImageAttachment( return image.giphy?.[giphyVersion]?.url || image.thumb_url; } - // Videos open in the player, so resolve the playable file rather than the thumbnail + // Videos open in the player, so resolve the playable file rather than the thumbnail. if (image.type === FileTypes.Video) { return getPlayableVideoUrl(image); } diff --git a/package/src/utils/i18n/Streami18n.ts b/package/src/utils/i18n/Streami18n.ts index 4d70b6e8f6..ad21f6d0f4 100644 --- a/package/src/utils/i18n/Streami18n.ts +++ b/package/src/utils/i18n/Streami18n.ts @@ -4,8 +4,8 @@ import { isDayjsLike, Streami18n as CoreStreami18n, } from '@stream-io/i18n'; -// The names of the languages the API can auto-translate a message into. Chat API metadata, so it -// stays in `stream-chat` rather than moving to the generic i18n package. +// `languageNameDefaults` (the names of the languages the API can auto-translate into) is Chat API +// metadata, so it comes from `stream-chat` rather than the generic i18n package. import type { CalendarFormats, Streami18nOptions as CoreStreami18nOptions, @@ -18,17 +18,14 @@ import { runtimeDefaults } from '../../i18n/runtimeDefaults'; import type { TranslationCatalog } from '../../i18n/types'; /** - * The English calendar wording the SDK ships, for the dayjs calendar plugin. + * The English calendar wording the SDK ships for the dayjs calendar plugin, layered under any + * calendar an integrator supplies. * - * Bundled data, the same way `runtimeDefaults` is, and layered the same way: underneath anything an - * integrator supplies. `timestamp.*` keys formatted with `timestampFormatter(calendar: true)` and no - * `calendarFormats` argument of their own read this off the active dayjs locale, and nothing else - * supplies it — no locale file defines `calendar`, and dayjs already has `en` registered, so core's - * locale fallback never gets a chance to fill it in. Without it the plugin's own defaults apply and a - * date separator reads "Today at 3:04 PM" where every previous version read "Today". + * `timestamp.*` keys formatted with `timestampFormatter(calendar: true)` and no `calendarFormats` + * argument read it off the active dayjs locale. No locale file defines `calendar` and dayjs already + * has `en` registered, so without it the plugin defaults apply ("Today at 3:04 PM", not "Today"). * - * Exported so a new language can be built from it (`{ ...englishCalendarFormats, sameDay: '[heute]' }`) - * rather than transcribed. + * Exported so a new language can extend it: `{ ...englishCalendarFormats, sameDay: '[heute]' }`. */ export const englishCalendarFormats: CalendarFormats = { lastDay: '[Yesterday]', @@ -43,16 +40,11 @@ const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; /** - * The calendar slots already registered on dayjs's `en` locale. + * The calendar slots already registered on dayjs's `en` locale, so that the + * {@link englishCalendarFormats} defaults do not overwrite them. Core's constructor and the app + * (`Dayjs.updateLocale('en', { calendar })`) can both register them first. * - * Read back rather than assumed empty, because {@link englishCalendarFormats} is a default and must not - * overwrite a choice already made. Two things get there before this runs: core applies - * `dayjsLocaleConfigForLanguage` inside its own constructor, and an app is free to call - * `Dayjs.updateLocale('en', { calendar })` itself — which the v10 migration guide suggests for anyone - * bringing their own `DateTimeParser`. - * - * Filtered to the six known slots with string values: whatever is in the registry came from outside and - * is typed `unknown`, and a stray key would be spread straight into a config dayjs then formats with. + * Only known slots with string values are returned, because the registry is untyped external data. */ const registeredEnglishCalendar = (parser: DateTimeParserModule): Partial => { const registry = 'Ls' in parser ? parser.Ls : undefined; @@ -68,23 +60,18 @@ const registeredEnglishCalendar = (parser: DateTimeParserModule): Partial; /** - * Wrapper around [i18next](https://www.i18next.com/) for this SDK's translations. Pass an instance to - * `` to control language and copy. + * Wrapper around [i18next](https://www.i18next.com/) for this SDK's translations. Pass an instance + * to `` to control language and copy. * - * The implementation lives in `@stream-io/i18n`, shared with the React SDK, so both behave identically - * and a fix reaches both at once. What is added here is this SDK's own bundled translation data — core - * cannot import it, because the key catalog is generated from *this* package's `t()` call sites. + * The implementation lives in `@stream-io/i18n`, shared with the React SDK. This class adds the + * SDK's bundled translation data, which core cannot import because the key catalog is generated + * from this package's `t()` call sites. * - * ## Overriding some of the English copy + * Overriding some of the English copy: * * ```ts * const i18n = new Streami18n({ @@ -92,7 +79,7 @@ export type Streami18nOptions = CoreStreami18nOptions; * }); * ``` * - * ## Registering a language + * Registering a language: * * ```ts * import 'dayjs/locale/de'; @@ -103,20 +90,18 @@ export type Streami18nOptions = CoreStreami18nOptions; * }); * ``` * - * A partial dictionary is safe: keys you do not supply render their English copy, never a raw dotted - * path. Note no dayjs locale file defines `calendar` — that field belongs to the calendar plugin — so a - * new language needs both the locale import and a `calendar` config, or relative dates render English - * scaffolding around translated day names. + * Keys missing from a partial dictionary render their English copy, never a raw dotted path. No + * dayjs locale file defines `calendar`, so a new language needs both the locale import and a + * `calendar` config, or relative dates mix English with translated day names. * - * Reactivity goes through `i18n.state`, a `StateStore`. `setLanguage()` returns nothing — the new `t` is - * published to that store, which `` subscribes to. + * `setLanguage()` resolves with no value: the new `t` is published to `i18n.state`, a `StateStore` + * that `` subscribes to. */ export class Streami18n extends CoreStreami18n { constructor(options: Streami18nOptions = {}) { super({ ...options, - // Core owns the `language.*` names, since it owns the `TranslationLanguage` union they describe. - // Merged under this SDK's data, and the caller's over both, so an individual name stays overridable. + // The `language.*` names come from `stream-chat`. Precedence: those, SDK data, the caller. runtimeDefaults: { ...languageNameDefaults, ...runtimeDefaults, @@ -124,13 +109,12 @@ export class Streami18n extends CoreStreami18n (config) => { - // Built from the config the adapter receives, so it reflects everything that ran after routing: - // request interceptors registered before this one (axios runs them after it) and - // `transformRequest`. + // Built from the config the adapter receives, so it includes the changes made by request + // interceptors registered before this one (axios runs them after it) and `transformRequest`. const nativeMultipartRequest = createNativeMultipartRequest( client, config as NativeMultipartAxiosRequestConfig, @@ -280,13 +279,14 @@ const createNativeMultipartAdapter = }; /** - * Routes file uploads through the native uploader by setting `adapter` on those requests only. + * Routes multipart file uploads through the native uploader by setting `adapter` on those requests + * only. Does nothing when native multipart upload is unavailable or the client is already set up. * - * The client's default adapter is deliberately left alone. Replacing it would mean resolving the - * fallback (`['xhr', 'http', 'fetch']`) with `axios.getAdapter`, i.e. importing axios here — and - * wherever that import resolves to a different copy than `stream-chat`'s, every request would fail - * with that copy's `AxiosError`, which `stream-chat` does not recognise. Untouched, every other - * request is resolved by `stream-chat`'s own axios. + * The client's default adapter is left alone: replacing it would require importing axios here to + * resolve the fallback, and a copy different from `stream-chat`'s would fail every request with an + * `AxiosError` that `stream-chat` does not recognize. + * + * @param client The client to install the adapter on. */ export const installNativeMultipartAdapter = (client: StreamChat) => { if (!isNativeMultipartUploadAvailable()) { diff --git a/package/src/utils/isRNVersionGreaterThanOrEqualTo.ts b/package/src/utils/isRNVersionGreaterThanOrEqualTo.ts index eda9c5c0a6..a9e2f1d5b6 100644 --- a/package/src/utils/isRNVersionGreaterThanOrEqualTo.ts +++ b/package/src/utils/isRNVersionGreaterThanOrEqualTo.ts @@ -1,11 +1,13 @@ import { Platform } from 'react-native'; /** - * Returns `true` when the running React Native version is greater than or equal to `version` - * (a `'major.minor.patch'` string, e.g. `'0.86.0'`), comparing major, then minor, then patch. + * Whether the running React Native version is greater than or equal to `version`. * - * Falls back to `false` when the version is unavailable (e.g. react-native-web) — callers should - * treat that as "assume an older React Native". + * Returns `false` when the version is unavailable (such as react-native-web), so callers treat it + * as an older React Native. + * + * @param version A `'major.minor.patch'` string, such as `'0.86.0'`. + * @returns Whether the running version is at least `version`. */ export const isRNVersionGreaterThanOrEqualTo = (version: string): boolean => { const runtime = Platform.constants?.reactNativeVersion; diff --git a/package/src/utils/react-native-constants.ts b/package/src/utils/react-native-constants.ts index 718b488285..3c5ed43dc8 100644 --- a/package/src/utils/react-native-constants.ts +++ b/package/src/utils/react-native-constants.ts @@ -1,10 +1,8 @@ import { isRNVersionGreaterThanOrEqualTo } from './isRNVersionGreaterThanOrEqualTo'; /** - * React Native capability flags, resolved once at module load - the RN version is fixed for the - * lifetime of the app. Keeping version gates as named flags here makes them easy to mock in tests - * and easy to retire later: when the SDK's minimum supported React Native reaches a baseline, grep - * for the flag to find every gated code path and delete both the flag and the branches it guarded. + * React Native capability flags, resolved once at module load. Named flags are easy to mock in + * tests and to find and delete once the minimum supported React Native reaches their baseline. */ export const isRN86OrGreater = isRNVersionGreaterThanOrEqualTo('0.86.0'); diff --git a/package/src/utils/rtlMirrorSwitchStyle.ts b/package/src/utils/rtlMirrorSwitchStyle.ts index ee5d95f6f7..d1f76c7895 100644 --- a/package/src/utils/rtlMirrorSwitchStyle.ts +++ b/package/src/utils/rtlMirrorSwitchStyle.ts @@ -4,8 +4,10 @@ import { I18nManager, Platform, ViewStyle } from 'react-native'; type RtlMirrorSwitchStyle = Pick; /** - * Style that mirrors `Switch` horizontally in RTL on iOS so thumb/track match layout direction. - * The returned object is stable across renders while `I18nManager.isRTL` is unchanged (`useMemo`). + * Style that mirrors a `Switch` horizontally in RTL on iOS so the thumb and track match the layout + * direction. + * + * @returns A style that is stable across renders while `I18nManager.isRTL` is unchanged. */ export function useRtlMirrorSwitchStyle(): RtlMirrorSwitchStyle { const isRTL = I18nManager.isRTL; diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index e3efb00219..f4ee6bb726 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -11,9 +11,8 @@ export type ReactionData = { Icon: React.ComponentType; type: string; /** - * Whether the reaction should appear inside of the reaction picker in the - * context menu or not. If `true`, it will consequently not appear in the - * extra reactions bottom sheet. + * Whether the reaction appears in the context menu's reaction picker. When `true`, it does not + * appear in the extra reactions bottom sheet. */ isMain?: boolean; }; @@ -70,26 +69,29 @@ export const getIndicatorTypeForFileState = ( }; /** - * Utility to check if the message is a Blocked message. - * @param message - * @returns boolean + * Whether the message was blocked by moderation. + * + * @param message The message to check. + * @returns Whether the message is blocked. */ export const isBlockedMessage = (message: LocalMessage) => { return message.type === 'error' && message.moderation?.action === 'remove'; }; /** - * Utility to check if the message is a Bounced message. - * @param message - * @returns boolean + * Whether the message was bounced by moderation. + * + * @param message The message to check. + * @returns Whether the message is bounced. */ export const isBouncedMessage = (message: LocalMessage) => message.type === 'error' && message?.moderation?.action === 'bounce'; /** - * Utility to check if the message is a edited message. - * @param message - * @returns boolean + * Whether the message text has been edited. + * + * @param message The message to check. + * @returns Whether the message is edited. */ export const isEditedMessage = (message: LocalMessage) => message.message_text_updated_at != null; @@ -110,11 +112,11 @@ export const getUrlWithoutParams = (url?: string) => { }; /** - * Whether this URL points at a file on the device rather than something fetchable. + * Whether the URL points at a file on the device rather than something fetchable over HTTP(S). + * An undefined URL is not local. * - * Anchored on the scheme rather than searching for `http` anywhere in the string: a - * `content://`/`ph://` URI is free to contain that substring, and an attachment mid-upload is - * rendered from exactly such a URI. Undefined is "not local" — there is no local file to read. + * Matches on the scheme, not on `http` anywhere in the string, because a `content://` or `ph://` + * URI may contain that substring. */ export const isLocalUrl = (url?: string) => !!url && !/^https?:\/\//i.test(url.trim()); @@ -124,16 +126,11 @@ export const generateRandomId = (a = ''): string => ((Number(a) ^ (Math.random() * 16)) >> (Number(a) / 4)).toString(16) : `${1e7}-${1e3}-${4e3}-${8e3}-${1e11}`.replace(/[018]/g, generateRandomId); -/* - * Returns true if the message text only contains emojis - */ +/** Whether the text contains only emojis, ignoring whitespace. */ export const hasOnlyEmojis = (text: string) => { try { - // get all emojis in the string const emojiOnlyString = [...text.matchAll(EmojiRegex())].join(''); - // remove all spaces from original text const originalTextWithNoSpaces = text.replaceAll(/\s/g, ''); - // check if both are the same return ( emojiOnlyString.length !== 0 && emojiOnlyString.length === originalTextWithNoSpaces.length ); @@ -143,9 +140,11 @@ export const hasOnlyEmojis = (text: string) => { }; /** - * Stringifies a message object - * @param {LocalMessage} message - the message object to be stringified - * @returns {string} The stringified message + * Stringifies the fields of a message that affect how it renders, to compare messages cheaply. + * + * @param params.message The message to stringify. + * @param params.includeReactions Whether to include the reactions. Defaults to true. + * @returns The stringified message. */ export const stringifyMessage = ({ message, @@ -186,12 +185,10 @@ export const stringifyMessage = ({ }; /** - * Utility to get the file name from the path using regex. - * `[^/]+` matches one or more characters that are not a slash (/), ensuring we capture the filename part. - * `\.` matches the period before the file extension. - * `[^/]+$` matches one or more characters that are not a slash (/) until the end of the string, capturing the file extension. - * @param path string - * @returns string + * Returns the file name, including its extension, from the last segment of a path. + * + * @param path The file path or URI. + * @returns The file name, or an empty string when the last segment has no extension. */ export const getFileNameFromPath = (path: string) => { const pattern = /[^/]+\.[^/]+$/; @@ -200,9 +197,10 @@ export const getFileNameFromPath = (path: string) => { }; /** - * Utility to get the duration label from the duration in seconds. - * @param duration number - * @returns string + * Formats a duration as `m:ss`, or `HH:mm:ss` from one hour. + * + * @param duration The duration in milliseconds. + * @returns The duration label, `00:00` for a zero or missing duration. */ export const getDurationLabelFromDuration = (duration: number) => { if (!duration) { @@ -239,20 +237,23 @@ export const formatMsToMinSec = (ms: number) => { }; /** - * Utility to escape special characters in a string. - * @param text - * @returns string + * Escapes the characters that have a special meaning in a regular expression. + * + * @param text The text to escape. + * @returns The escaped text. */ export function escapeRegExp(text: string) { return text.replace(/[-[\]{}()*+?.,/\\^$|#]/g, '\\$&'); } /** - * The purpose of this function is to compare two messages and determine if they are equal. - * It checks various properties of the messages, such as status, type, text, pinned state, updated_at timestamp, i18n data, and reply count. - * If all these properties match, it returns true, indicating that the messages are considered equal. - * If any of the properties differ, it returns false, indicating that the messages are not equal. - * Useful for the `areEqual` logic in the React.memo of the Message component/sub-components. + * Whether two messages are equal for rendering purposes, for use in `React.memo` comparators. + * Compares `status`, `type`, `text`, `pinned`, `i18n` (by reference), `reply_count`, `updated_at` + * and `deleted_at`. Two missing messages are equal. + * + * @param prevMessage The previous message. + * @param nextMessage The next message. + * @returns Whether the messages are equal. */ export const checkMessageEquality = ( prevMessage?: LocalMessage | MessageResponse | null, @@ -266,8 +267,7 @@ export const checkMessageEquality = ( if (prevMessageExists !== nextMessageExists) { return false; } - // `status` only exists on optimistic/local messages (`LocalMessage`); a quoted - // `MessageResponse` won't carry it, so read it through a guard instead of a cast. + // `status` exists only on `LocalMessage`, so it is read through an `in` guard. const prevStatus = prevMessage && 'status' in prevMessage ? prevMessage.status : undefined; const nextStatus = nextMessage && 'status' in nextMessage ? nextMessage.status : undefined; const messageEqual = @@ -284,11 +284,13 @@ export const checkMessageEquality = ( }; /** - * The purpose of this function is to compare two quoted messages and determine if they are equal. - * It checks various properties of the messages, such as status, type, text, updated_at timestamp, and deleted_at. - * If all these properties match, it returns true, indicating that the messages are considered equal. - * If any of the properties differ, it returns false, indicating that the messages are not equal. - * Useful for the `areEqual` logic in the React.memo of the Message component/sub-components. + * Whether two quoted messages are equal for rendering purposes, for use in `React.memo` + * comparators. Compares `type`, `text`, `updated_at` and `deleted_at`. Two missing messages are + * equal. + * + * @param prevQuotedMessage The previous quoted message. + * @param nextQuotedMessage The next quoted message. + * @returns Whether the quoted messages are equal. */ export const checkQuotedMessageEquality = ( prevQuotedMessage?: LocalMessage | MessageResponse | null, @@ -312,10 +314,11 @@ export const checkQuotedMessageEquality = ( }; /** - * Utility to get initials from name. - * @param name string - * @param numberOfInitials number - optional, default is 2 - * @returns string + * Returns the uppercase first letters of the first words of a name. + * + * @param name The full name. + * @param numberOfInitials How many initials to return. Defaults to 2. + * @returns The initials, or an empty string for a blank name. */ export const getInitialsFromName = (name: string, numberOfInitials: number = 2) => { if (!name) return ''; @@ -329,7 +332,6 @@ export const getInitialsFromName = (name: string, numberOfInitials: number = 2) .join(''); }; -// Utility to hash a string to a number export const hashStringToNumber = (str: string) => { let hash = 0; From 5f0394c9bdf3698986e71cd599351cf6e96100a6 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Wed, 21 Oct 2026 08:35:06 +0200 Subject: [PATCH 2/2] fix(contexts): point context hook errors at live docs pages The errors thrown by the `useXContext()` hooks linked to the removed `basics/hello_stream_chat` page, which now returns 404. Link to the current core-components / ui-components pages instead, and name the component that actually provides each context: - `useAttachmentPickerContext`: Channel (provided in ChannelShell), not OverlayProvider - `useMessageListItemContext`: MessageList (no `MessageListItem` component exists) - `useCreatePollContentContext`: CreatePoll, which wraps CreatePollContent in the provider Also assert the messages of the hooks the suite did not cover. --- package/src/contexts/__tests__/index.test.tsx | 38 ++++++++++++++----- .../AttachmentPickerContext.tsx | 2 +- .../channelContext/ChannelContext.tsx | 2 +- .../channelsContext/ChannelsContext.tsx | 2 +- .../src/contexts/chatContext/ChatContext.tsx | 2 +- .../ImageGalleryContextBase.tsx | 2 +- .../MessageInputContext.tsx | 2 +- .../MessageListItemContext.tsx | 2 +- .../messagesContext/MessagesContext.tsx | 2 +- .../overlayContext/OverlayContext.tsx | 2 +- .../OwnCapabilitiesContext.tsx | 2 +- .../pollContext/createPollContentContext.tsx | 2 +- .../src/contexts/pollContext/pollContext.tsx | 2 +- .../contexts/themeContext/ThemeContext.tsx | 2 +- 14 files changed, 42 insertions(+), 22 deletions(-) diff --git a/package/src/contexts/__tests__/index.test.tsx b/package/src/contexts/__tests__/index.test.tsx index 4e95346385..25ee4b31f1 100644 --- a/package/src/contexts/__tests__/index.test.tsx +++ b/package/src/contexts/__tests__/index.test.tsx @@ -9,13 +9,17 @@ import { useChannelsContext, useChatContext, useBottomSheetContext, + useCreatePollContentContext, useImageGalleryContext, + useMessageInputContext, useMessagesContext, useOverlayContext, useOwnCapabilitiesContext, + usePollContext, useTheme, useThreadContext, } from '../'; +import { useMessageListItemContext } from '../messageListItemContext/MessageListItemContext'; jest.mock('../utils/isTestEnvironment', () => ({ isTestEnvironment: jest.fn(() => false) })); jest.spyOn(console, 'error').mockImplementation(); @@ -28,39 +32,55 @@ describe('contexts hooks in a component throws an error with message when not wr it.each([ [ useOverlayContext, - 'The useOverlayContext hook was called outside the OverlayContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', + 'The useOverlayContext hook was called outside the OverlayContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/overlay-provider/', ], [ useOwnCapabilitiesContext, - 'The useOwnCapabilitiesContext hook was called outside the Channel Component. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', + 'The useOwnCapabilitiesContext hook was called outside the Channel Component. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel/', ], [ useTheme, - 'The useThemeContext hook was called outside the ThemeContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', + 'The useThemeContext hook was called outside the ThemeContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/overlay-provider/', ], [ useChannelContext, - 'The useChannelContext hook was called outside of the ChannelContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', + 'The useChannelContext hook was called outside of the ChannelContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel/', ], [ useChannelsContext, - 'The useChannelsContext hook was called outside of the ChannelsContext provider. Make sure you have configured ChannelList component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel-list', + 'The useChannelsContext hook was called outside of the ChannelsContext provider. Make sure you have configured ChannelList component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel-list/', ], [ useChatContext, - 'The useChatContext hook was called outside the ChatContext Provider. Make sure you have configured Chat component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#chat', + 'The useChatContext hook was called outside the ChatContext Provider. Make sure you have configured Chat component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/chat/', ], [ useImageGalleryContext, - 'The useImageGalleryContext hook was called outside the ImageGalleryContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', + 'The useImageGalleryContext hook was called outside the ImageGalleryContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/overlay-provider/', ], [ useMessagesContext, - 'The useMessagesContext hook was called outside of the MessagesContext provider. Make sure you have configured MessageList component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#message-list', + 'The useMessagesContext hook was called outside of the MessagesContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel/', ], [ useAttachmentPickerContext, - 'The useAttachmentPickerContext hook was called outside the AttachmentPickerContext provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', + 'The useAttachmentPickerContext hook was called outside the AttachmentPickerContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel/', + ], + [ + useMessageInputContext, + 'The useMessageInputContext hook was called outside of the MessageInputContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel/', + ], + [ + useMessageListItemContext, + 'The useMessageListItemContext hook was called outside of the MessageListItemContext provider. Make sure you have configured MessageList component correctly - https://getstream.io/chat/docs/sdk/react-native/ui-components/message-list/', + ], + [ + usePollContext, + 'The usePollContext hook was called outside of the PollContext provider. Make sure you have configured the Poll component correctly - https://getstream.io/chat/docs/sdk/react-native/ui-components/poll/', + ], + [ + useCreatePollContentContext, + 'The useCreatePollContentContext hook was called outside of the CreatePollContentContext provider. Make sure you have configured the CreatePoll component correctly - https://getstream.io/chat/docs/sdk/react-native/ui-components/create-poll/', ], [ useBottomSheetContext, diff --git a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx index 3524ff13e5..45082d028b 100644 --- a/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx +++ b/package/src/contexts/attachmentPickerContext/AttachmentPickerContext.tsx @@ -87,7 +87,7 @@ export const useAttachmentPickerContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useAttachmentPickerContext hook was called outside the AttachmentPickerContext provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', + 'The useAttachmentPickerContext hook was called outside the AttachmentPickerContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel/', ); } diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index 9c0d12875d..17998efec3 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -55,7 +55,7 @@ export const useChannelContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useChannelContext hook was called outside of the ChannelContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', + 'The useChannelContext hook was called outside of the ChannelContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel/', ); } diff --git a/package/src/contexts/channelsContext/ChannelsContext.tsx b/package/src/contexts/channelsContext/ChannelsContext.tsx index fa7c457758..f0a525f570 100644 --- a/package/src/contexts/channelsContext/ChannelsContext.tsx +++ b/package/src/contexts/channelsContext/ChannelsContext.tsx @@ -82,7 +82,7 @@ export const useChannelsContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useChannelsContext hook was called outside of the ChannelsContext provider. Make sure you have configured ChannelList component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel-list', + 'The useChannelsContext hook was called outside of the ChannelsContext provider. Make sure you have configured ChannelList component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel-list/', ); } diff --git a/package/src/contexts/chatContext/ChatContext.tsx b/package/src/contexts/chatContext/ChatContext.tsx index faf82e9f6b..74b4dd4c19 100644 --- a/package/src/contexts/chatContext/ChatContext.tsx +++ b/package/src/contexts/chatContext/ChatContext.tsx @@ -52,7 +52,7 @@ export const useChatContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useChatContext hook was called outside the ChatContext Provider. Make sure you have configured Chat component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#chat', + 'The useChatContext hook was called outside the ChatContext Provider. Make sure you have configured Chat component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/chat/', ); } diff --git a/package/src/contexts/imageGalleryContext/ImageGalleryContextBase.tsx b/package/src/contexts/imageGalleryContext/ImageGalleryContextBase.tsx index eaadec4e79..8dfff8f14e 100644 --- a/package/src/contexts/imageGalleryContext/ImageGalleryContextBase.tsx +++ b/package/src/contexts/imageGalleryContext/ImageGalleryContextBase.tsx @@ -26,7 +26,7 @@ export const useImageGalleryContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useImageGalleryContext hook was called outside the ImageGalleryContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', + 'The useImageGalleryContext hook was called outside the ImageGalleryContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/overlay-provider/', ); } diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 46357668c9..75bd10a6cc 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -421,7 +421,7 @@ export const useMessageInputContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useMessageInputContext hook was called outside of the MessageInputContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', + 'The useMessageInputContext hook was called outside of the MessageInputContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel/', ); } diff --git a/package/src/contexts/messageListItemContext/MessageListItemContext.tsx b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx index 0313f8e6bd..f7a68c44a9 100644 --- a/package/src/contexts/messageListItemContext/MessageListItemContext.tsx +++ b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx @@ -46,7 +46,7 @@ export const useMessageListItemContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useMessageListItemContext hook was called outside of the MessageListItemContext provider. Make sure you have configured MessageListItem component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#message-list', + 'The useMessageListItemContext hook was called outside of the MessageListItemContext provider. Make sure you have configured MessageList component correctly - https://getstream.io/chat/docs/sdk/react-native/ui-components/message-list/', ); } diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 7b79b9b86b..e23ae16953 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -215,7 +215,7 @@ export const useMessagesContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useMessagesContext hook was called outside of the MessagesContext provider. Make sure you have configured MessageList component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#message-list', + 'The useMessagesContext hook was called outside of the MessagesContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel/', ); } diff --git a/package/src/contexts/overlayContext/OverlayContext.tsx b/package/src/contexts/overlayContext/OverlayContext.tsx index 67548104f5..ea1d5bfc22 100644 --- a/package/src/contexts/overlayContext/OverlayContext.tsx +++ b/package/src/contexts/overlayContext/OverlayContext.tsx @@ -40,7 +40,7 @@ export const useOverlayContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useOverlayContext hook was called outside the OverlayContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', + 'The useOverlayContext hook was called outside the OverlayContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/overlay-provider/', ); } diff --git a/package/src/contexts/ownCapabilitiesContext/OwnCapabilitiesContext.tsx b/package/src/contexts/ownCapabilitiesContext/OwnCapabilitiesContext.tsx index 1d3ab06816..9bc1640104 100644 --- a/package/src/contexts/ownCapabilitiesContext/OwnCapabilitiesContext.tsx +++ b/package/src/contexts/ownCapabilitiesContext/OwnCapabilitiesContext.tsx @@ -49,7 +49,7 @@ export const useOwnCapabilitiesContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useOwnCapabilitiesContext hook was called outside the Channel Component. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', + 'The useOwnCapabilitiesContext hook was called outside the Channel Component. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/channel/', ); } diff --git a/package/src/contexts/pollContext/createPollContentContext.tsx b/package/src/contexts/pollContext/createPollContentContext.tsx index ffd1f2fa34..4e5ac6253c 100644 --- a/package/src/contexts/pollContext/createPollContentContext.tsx +++ b/package/src/contexts/pollContext/createPollContentContext.tsx @@ -42,7 +42,7 @@ export const useCreatePollContentContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useCreatePollContentContext hook was called outside of the CreatePollContentContext provider. Make sure you have configured the CreatePollContent component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', + 'The useCreatePollContentContext hook was called outside of the CreatePollContentContext provider. Make sure you have configured the CreatePoll component correctly - https://getstream.io/chat/docs/sdk/react-native/ui-components/create-poll/', ); } diff --git a/package/src/contexts/pollContext/pollContext.tsx b/package/src/contexts/pollContext/pollContext.tsx index bfb1f07797..3be332d8d2 100644 --- a/package/src/contexts/pollContext/pollContext.tsx +++ b/package/src/contexts/pollContext/pollContext.tsx @@ -29,7 +29,7 @@ export const usePollContext = () => { if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The usePollContext hook was called outside of the PollContext provider. Make sure you have configured the Poll component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', + 'The usePollContext hook was called outside of the PollContext provider. Make sure you have configured the Poll component correctly - https://getstream.io/chat/docs/sdk/react-native/ui-components/poll/', ); } diff --git a/package/src/contexts/themeContext/ThemeContext.tsx b/package/src/contexts/themeContext/ThemeContext.tsx index 5e698b772f..e75152a5e1 100644 --- a/package/src/contexts/themeContext/ThemeContext.tsx +++ b/package/src/contexts/themeContext/ThemeContext.tsx @@ -89,7 +89,7 @@ export const useTheme = () => { if (theme === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { throw new Error( - 'The useThemeContext hook was called outside the ThemeContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', + 'The useThemeContext hook was called outside the ThemeContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/react-native/core-components/overlay-provider/', ); } return { theme };