diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index b055d764ea..40ad8cd0fc 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -5,6 +5,7 @@ import { ChannelSort, LocalMessage, TextComposerMiddleware, + LiveLocationManagerConstructorParameters, } from 'stream-chat'; import { AIStateIndicator, @@ -18,9 +19,10 @@ import { Thread, ThreadList, useCreateChatClient, - useMessageComposer, VirtualizedMessageList as MessageList, Window, + useChatContext, + useLiveLocationSharingManager, } from 'stream-chat-react'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; import { init, SearchIndex } from 'emoji-mart'; @@ -55,6 +57,61 @@ const sort: ChannelSort = { pinned_at: 1, last_message_at: -1, updated_at: -1 }; // @ts-ignore const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated; +const ShareLiveLocation = () => { + const { channel } = useChatContext(); + + return ( + + ); +}; + +const watchLocationNormal: LiveLocationManagerConstructorParameters['watchLocation'] = ( + watcher, +) => { + const watch = navigator.geolocation.watchPosition((position) => { + watcher({ latitude: position.coords.latitude, longitude: position.coords.longitude }); + }); + + return () => navigator.geolocation.clearWatch(watch); +}; + +const watchLocationTimed: LiveLocationManagerConstructorParameters['watchLocation'] = ( + watcher, +) => { + const timer = setInterval(() => { + navigator.geolocation.getCurrentPosition((position) => { + watcher({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }); + }); + }, 5000); + + return () => { + clearInterval(timer); + console.log('cleanup'); + }; +}; + const App = () => { const chatClient = useCreateChatClient({ apiKey, @@ -62,6 +119,11 @@ const App = () => { userData: { id: userId }, }); + useLiveLocationSharingManager({ + client: chatClient, + watchLocation: watchLocationNormal, + }); + useEffect(() => { if (!chatClient) return; @@ -97,6 +159,7 @@ const App = () => { + diff --git a/i18next-parser.config.js b/i18next-parser.config.js index 0e41cb91f6..0b0e8d878d 100644 --- a/i18next-parser.config.js +++ b/i18next-parser.config.js @@ -9,8 +9,7 @@ module.exports = { namespaceSeparator: false, output: 'src/i18n/$LOCALE.json', sort(a, b) { - return a < b ? -1 : 1; // alfabetical order + return a < b ? -1 : 1; // alphabetical order }, - useKeysAsDefaultValue: true, verbose: true, }; diff --git a/package.json b/package.json index a2c3125834..b5942a3909 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,7 @@ "emoji-mart": "^5.4.0", "react": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0 || ^16.14.0", - "stream-chat": "^9.10.1" + "stream-chat": "^9.12.0" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -183,7 +183,7 @@ "@playwright/test": "^1.42.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", - "@stream-io/stream-chat-css": "^5.11.1", + "@stream-io/stream-chat-css": "^5.11.2", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", @@ -236,7 +236,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "semantic-release": "^24.2.3", - "stream-chat": "^9.10.1", + "stream-chat": "^9.12.0", "ts-jest": "^29.2.5", "typescript": "^5.4.5", "typescript-eslint": "^8.17.0" diff --git a/src/components/Attachment/Attachment.tsx b/src/components/Attachment/Attachment.tsx index 2bc5600b34..0457228d26 100644 --- a/src/components/Attachment/Attachment.tsx +++ b/src/components/Attachment/Attachment.tsx @@ -4,6 +4,7 @@ import { isFileAttachment, isImageAttachment, isScrapedContent, + isSharedLocationResponse, isVideoAttachment, isVoiceRecordingAttachment, } from 'stream-chat'; @@ -13,6 +14,7 @@ import { CardContainer, FileContainer, GalleryContainer, + GeolocationContainer, ImageContainer, MediaContainer, UnsupportedAttachmentContainer, @@ -21,7 +23,7 @@ import { import { SUPPORTED_VIDEO_FORMATS } from './utils'; import type { ReactPlayerProps } from 'react-player'; -import type { Attachment as StreamAttachment } from 'stream-chat'; +import type { SharedLocationResponse, Attachment as StreamAttachment } from 'stream-chat'; import type { AttachmentActionsProps } from './AttachmentActions'; import type { AudioProps } from './Audio'; import type { VoiceRecordingProps } from './VoiceRecording'; @@ -31,6 +33,7 @@ import type { GalleryProps, ImageProps } from '../Gallery'; import type { UnsupportedAttachmentProps } from './UnsupportedAttachment'; import type { ActionHandlerReturnType } from '../Message/hooks/useActionHandler'; import type { GroupedRenderedAttachment } from './utils'; +import type { GeolocationProps } from './Geolocation'; const CONTAINER_MAP = { audio: AudioContainer, @@ -49,12 +52,13 @@ export const ATTACHMENT_GROUPS_ORDER = [ 'audio', 'voiceRecording', 'file', + 'geolocation', 'unsupported', ] as const; export type AttachmentProps = { /** The message attachments to render, see [attachment structure](https://getstream.io/chat/docs/javascript/message_format/?language=javascript) **/ - attachments: StreamAttachment[]; + attachments: (StreamAttachment | SharedLocationResponse)[]; /** The handler function to call when an action is performed on an attachment, examples include canceling a \/giphy command or shuffling the results. */ actionHandler?: ActionHandlerReturnType; /** Custom UI component for displaying attachment actions, defaults to and accepts same props as: [AttachmentActions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/AttachmentActions.tsx) */ @@ -67,6 +71,7 @@ export type AttachmentProps = { File?: React.ComponentType; /** Custom UI component for displaying a gallery of image type attachments, defaults to and accepts same props as: [Gallery](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/Gallery.tsx) */ Gallery?: React.ComponentType; + Geolocation?: React.ComponentType; /** Custom UI component for displaying an image type attachment, defaults to and accepts same props as: [Image](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/Image.tsx) */ Image?: React.ComponentType; /** Optional flag to signal that an attachment is a displayed as a part of a quoted message */ @@ -113,16 +118,26 @@ const renderGroupedAttachments = ({ .filter((attachment) => !isImageAttachment(attachment)) .reduce( (typeMap, attachment) => { - const attachmentType = getAttachmentType(attachment); - - const Container = CONTAINER_MAP[attachmentType]; - typeMap[attachmentType].push( - , - ); + if (isSharedLocationResponse(attachment)) { + typeMap.geolocation.push( + , + ); + } else { + const attachmentType = getAttachmentType(attachment); + + const Container = CONTAINER_MAP[attachmentType]; + typeMap[attachmentType].push( + , + ); + } return typeMap; }, @@ -137,6 +152,7 @@ const renderGroupedAttachments = ({ image: [], // eslint-disable-next-line sort-keys gallery: [], + geolocation: [], voiceRecording: [], }, ); diff --git a/src/components/Attachment/AttachmentContainer.tsx b/src/components/Attachment/AttachmentContainer.tsx index 13dad35ca9..c7884e09ec 100644 --- a/src/components/Attachment/AttachmentContainer.tsx +++ b/src/components/Attachment/AttachmentContainer.tsx @@ -3,7 +3,8 @@ import React, { useLayoutEffect, useRef, useState } from 'react'; import ReactPlayer from 'react-player'; import clsx from 'clsx'; import * as linkify from 'linkifyjs'; -import type { Attachment, LocalAttachment } from 'stream-chat'; +import type { Attachment, LocalAttachment, SharedLocationResponse } from 'stream-chat'; +import { isSharedLocationResponse } from 'stream-chat'; import { AttachmentActions as DefaultAttachmentActions } from './AttachmentActions'; import { Audio as DefaultAudio } from './Audio'; @@ -11,10 +12,12 @@ import { VoiceRecording as DefaultVoiceRecording } from './VoiceRecording'; import { Gallery as DefaultGallery, ImageComponent as DefaultImage } from '../Gallery'; import { Card as DefaultCard } from './Card'; import { FileAttachment as DefaultFile } from './FileAttachment'; +import { Geolocation as DefaultGeolocation } from './Geolocation'; import { UnsupportedAttachment as DefaultUnsupportedAttachment } from './UnsupportedAttachment'; import type { AttachmentComponentType, GalleryAttachment, + GeolocationContainerProps, RenderAttachmentProps, RenderGalleryProps, } from './utils'; @@ -26,7 +29,7 @@ import type { } from '../../types/types'; export type AttachmentContainerProps = { - attachment: Attachment | GalleryAttachment; + attachment: Attachment | GalleryAttachment | SharedLocationResponse; componentType: AttachmentComponentType; }; export const AttachmentWithinContainer = ({ @@ -37,7 +40,7 @@ export const AttachmentWithinContainer = ({ const isGAT = isGalleryAttachmentType(attachment); let extra = ''; - if (!isGAT) { + if (!isGAT && !isSharedLocationResponse(attachment)) { extra = componentType === 'card' && !attachment?.image_url && !attachment?.thumb_url ? 'no-image' @@ -50,7 +53,9 @@ export const AttachmentWithinContainer = ({ 'str-chat__message-attachment str-chat__message-attachment-dynamic-size', { [`str-chat__message-attachment--${componentType}`]: componentType, - [`str-chat__message-attachment--${attachment?.type}`]: attachment?.type, + [`str-chat__message-attachment--${(attachment as Attachment)?.type}`]: ( + attachment as Attachment + )?.type, [`str-chat__message-attachment--${componentType}--${extra}`]: componentType && extra, 'str-chat__message-attachment--svg-image': isSvgAttachment(attachment), @@ -288,6 +293,15 @@ export const MediaContainer = (props: RenderAttachmentProps) => { ); }; +export const GeolocationContainer = ({ + Geolocation = DefaultGeolocation, + location, +}: GeolocationContainerProps) => ( + + + +); + export const UnsupportedAttachmentContainer = ({ attachment, UnsupportedAttachment = DefaultUnsupportedAttachment, diff --git a/src/components/Attachment/Geolocation.tsx b/src/components/Attachment/Geolocation.tsx new file mode 100644 index 0000000000..9b67a3a519 --- /dev/null +++ b/src/components/Attachment/Geolocation.tsx @@ -0,0 +1,113 @@ +import type { ComponentType } from 'react'; +import { useEffect } from 'react'; +import { useRef, useState } from 'react'; +import React from 'react'; +import type { Coords, SharedLocationResponse } from 'stream-chat'; +import { useChatContext, useTranslationContext } from '../../context'; +import { ExternalLinkIcon, GeolocationIcon } from './icons'; + +export type GeolocationMapProps = Coords; + +export type GeolocationProps = { + location: SharedLocationResponse; + GeolocationAttachmentMapPlaceholder?: ComponentType; + GeolocationMap?: ComponentType; +}; + +export const Geolocation = ({ + GeolocationAttachmentMapPlaceholder = DefaultGeolocationAttachmentMapPlaceholder, + GeolocationMap, + location, +}: GeolocationProps) => { + const { channel, client } = useChatContext(); + const { t } = useTranslationContext(); + + const [stoppedSharing, setStoppedSharing] = useState( + !!location.end_at && new Date(location.end_at).getTime() < new Date().getTime(), + ); + const timeoutRef = useRef | undefined>(undefined); + + const isMyLocation = location.user_id === client.userID; + const isLiveLocation = !!location.end_at; + + useEffect(() => { + if (!location.end_at) return; + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout( + () => setStoppedSharing(true), + new Date(location.end_at).getTime() - Date.now(), + ); + }, [location.end_at]); + + return ( +
+
+ {GeolocationMap ? ( + + ) : ( + + )} +
+
+ {isLiveLocation ? ( + stoppedSharing ? ( + t('Location sharing ended') + ) : isMyLocation ? ( +
+ +
+ {t('Live until {{ timestamp }}', { + timestamp: t('timestamp/LiveLocation', { timestamp: location.end_at }), + })} +
+
+ ) : ( +
+
+ {t('Live location')} +
+
+ {t('Live until {{ timestamp }}', { + timestamp: t('timestamp/LiveLocation', { timestamp: location.end_at }), + })} +
+
+ ) + ) : ( + t('Current location') + )} +
+
+ ); +}; + +export type GeolocationAttachmentMapPlaceholderProps = { + location: SharedLocationResponse; +}; + +const DefaultGeolocationAttachmentMapPlaceholder = ({ + location, +}: GeolocationAttachmentMapPlaceholderProps) => ( +
+ + + + +
+); diff --git a/src/components/Attachment/__tests__/Attachment.test.js b/src/components/Attachment/__tests__/Attachment.test.js index 29a289bc9b..7bd7416fc9 100644 --- a/src/components/Attachment/__tests__/Attachment.test.js +++ b/src/components/Attachment/__tests__/Attachment.test.js @@ -9,9 +9,11 @@ import { generateFileAttachment, generateGiphyAttachment, generateImageAttachment, + generateLiveLocationResponse, generateScrapedAudioAttachment, generateScrapedDataAttachment, generateScrapedImageAttachment, + generateStaticLocationResponse, generateVideoAttachment, } from 'mock-builders'; @@ -31,6 +33,9 @@ const File = (props) =>
{props.customTestId}< const Gallery = (props) => (
{props.customTestId}
); +const Geolocation = (props) => ( +
{props.customTestId}
+); const ATTACHMENTS = { scraped: { @@ -58,6 +63,7 @@ const renderComponent = (props) => Card={Card} File={File} Gallery={Gallery} + Geolocation={Geolocation} Image={Image} Media={Media} {...props} @@ -248,6 +254,17 @@ describe('attachment', () => { }); }); + it('renders shared location with Geolocation attachment', () => { + renderComponent({ attachments: [generateLiveLocationResponse()] }); + waitFor(() => { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + renderComponent({ attachments: [generateStaticLocationResponse()] }); + waitFor(() => { + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); + }); + it('should render AttachmentActions component if attachment has actions', async () => { const action = generateAttachmentAction(); const attachment = generateImageAttachment({ diff --git a/src/components/Attachment/__tests__/Geolocation.test.js b/src/components/Attachment/__tests__/Geolocation.test.js new file mode 100644 index 0000000000..2327e91f25 --- /dev/null +++ b/src/components/Attachment/__tests__/Geolocation.test.js @@ -0,0 +1,207 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Channel } from '../../Channel'; +import { Chat } from '../../Chat'; +import { Geolocation } from '../Geolocation'; +import { + generateLiveLocationResponse, + generateStaticLocationResponse, + initClientWithChannels, +} from '../../../mock-builders'; + +const GeolocationMapComponent = (props) => ( +
+); +const GeolocationAttachmentMapPlaceholderComponent = (props) => ( +
+); + +const getStopSharingButton = () => + screen.queryByRole('button', { name: /stop sharing/i }); +const getLocationSharingEndedLabel = () => screen.queryByText('Location sharing ended'); +const getStaticLocationLabel = () => screen.queryByText('Current location'); +const getOtherUsersLiveLocationLabel = () => screen.queryByText('Live location'); +const getGeolocationAttachmentMapPlaceholder = () => + screen.queryByTestId('geolocation-attachment-map-placeholder'); +const getCustomGeolocationAttachmentMapPlaceholder = () => + screen.queryByTestId('geolocation-attachment-map-placeholder-custom'); +const getGeolocationMap = () => screen.queryByTestId('geolocation-map'); + +const ownUser = { id: 'user-id' }; +const otherUser = { id: 'other-user-id' }; + +const renderComponent = async ({ channel, client, props } = {}) => { + const { + channels: [defaultChannel], + client: defaultClient, + } = await initClientWithChannels({ customUser: ownUser }); + let result; + await act(() => { + result = render( + + + + + , + ); + }); + return { channel: defaultChannel, client: defaultClient, result }; +}; + +describe.each([ + ['with', 'with', GeolocationMapComponent, GeolocationAttachmentMapPlaceholderComponent], + ['with', 'without', GeolocationMapComponent, undefined], + ['without', 'with', undefined, GeolocationAttachmentMapPlaceholderComponent], + ['without', 'without', undefined, undefined], +])( + 'Geolocation attachment %s GeolocationMap and %s GeolocationAttachmentMapPlaceholder', + (_, __, GeolocationMap, GeolocationAttachmentMapPlaceholder) => { + it('renders own static location', async () => { + const location = generateStaticLocationResponse({ user_id: ownUser.id }); + await renderComponent({ + props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location }, + }); + expect(getStopSharingButton()).not.toBeInTheDocument(); + expect(getLocationSharingEndedLabel()).not.toBeInTheDocument(); + expect(getOtherUsersLiveLocationLabel()).not.toBeInTheDocument(); + expect(getStaticLocationLabel()).toBeInTheDocument(); + if (GeolocationMap) { + expect(getGeolocationMap()).toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } else if (GeolocationAttachmentMapPlaceholder) { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + } else { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } + }); + + it("renders other user's static location", async () => { + const location = generateStaticLocationResponse({ user_id: otherUser.id }); + await renderComponent({ + props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location }, + }); + expect(getStopSharingButton()).not.toBeInTheDocument(); + expect(getLocationSharingEndedLabel()).not.toBeInTheDocument(); + expect(getOtherUsersLiveLocationLabel()).not.toBeInTheDocument(); + expect(getStaticLocationLabel()).toBeInTheDocument(); + if (GeolocationMap) { + expect(getGeolocationMap()).toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } else if (GeolocationAttachmentMapPlaceholder) { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + } else { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } + }); + + it('renders own live location', async () => { + const location = generateLiveLocationResponse({ user_id: ownUser.id }); + await renderComponent({ + props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location }, + }); + expect(getStopSharingButton()).toBeInTheDocument(); + expect(getLocationSharingEndedLabel()).not.toBeInTheDocument(); + expect(getOtherUsersLiveLocationLabel()).not.toBeInTheDocument(); + expect(getStaticLocationLabel()).not.toBeInTheDocument(); + if (GeolocationMap) { + expect(getGeolocationMap()).toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } else if (GeolocationAttachmentMapPlaceholder) { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + } else { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } + }); + it("other user's live location", async () => { + const location = generateLiveLocationResponse({ user_id: otherUser.id }); + await renderComponent({ + props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location }, + }); + expect(getStopSharingButton()).not.toBeInTheDocument(); + expect(getLocationSharingEndedLabel()).not.toBeInTheDocument(); + expect(getOtherUsersLiveLocationLabel()).toBeInTheDocument(); + expect(getStaticLocationLabel()).not.toBeInTheDocument(); + if (GeolocationMap) { + expect(getGeolocationMap()).toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } else if (GeolocationAttachmentMapPlaceholder) { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + } else { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } + }); + it("own user's stopped live location", async () => { + const location = generateLiveLocationResponse({ + end_at: '1980-01-01T00:00:00.000Z', + user_id: ownUser.id, + }); + await renderComponent({ + props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location }, + }); + expect(getStopSharingButton()).not.toBeInTheDocument(); + expect(getLocationSharingEndedLabel()).toBeInTheDocument(); + expect(getOtherUsersLiveLocationLabel()).not.toBeInTheDocument(); + expect(getStaticLocationLabel()).not.toBeInTheDocument(); + if (GeolocationMap) { + expect(getGeolocationMap()).toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } else if (GeolocationAttachmentMapPlaceholder) { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + } else { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } + }); + it("other user's stopped live location", async () => { + const location = generateLiveLocationResponse({ + end_at: '1980-01-01T00:00:00.000Z', + user_id: otherUser.id, + }); + await renderComponent({ + props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location }, + }); + expect(getStopSharingButton()).not.toBeInTheDocument(); + expect(getLocationSharingEndedLabel()).toBeInTheDocument(); + expect(getOtherUsersLiveLocationLabel()).not.toBeInTheDocument(); + expect(getStaticLocationLabel()).not.toBeInTheDocument(); + if (GeolocationMap) { + expect(getGeolocationMap()).toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } else if (GeolocationAttachmentMapPlaceholder) { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + } else { + expect(getGeolocationMap()).not.toBeInTheDocument(); + expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument(); + expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument(); + } + }); + }, +); diff --git a/src/components/Attachment/icons.tsx b/src/components/Attachment/icons.tsx index 6e26b2c396..75cc86bbba 100644 --- a/src/components/Attachment/icons.tsx +++ b/src/components/Attachment/icons.tsx @@ -29,3 +29,24 @@ export const PauseIcon = () => ( ); + +export const GeolocationIcon = () => ( + + + + +); + +export const ExternalLinkIcon = () => ( + + + +); diff --git a/src/components/Attachment/index.ts b/src/components/Attachment/index.ts index 3086b6dd4a..377cbe2468 100644 --- a/src/components/Attachment/index.ts +++ b/src/components/Attachment/index.ts @@ -5,7 +5,9 @@ export * from './Audio'; export * from './audioSampling'; export * from './Card'; export * from './components'; -export * from './UnsupportedAttachment'; export * from './FileAttachment'; +export * from './Geolocation'; +export * from './UnsupportedAttachment'; export * from './utils'; export { useAudioController } from './hooks/useAudioController'; +export * from '../Location/hooks/useLiveLocationSharingManager'; diff --git a/src/components/Attachment/utils.tsx b/src/components/Attachment/utils.tsx index 3dd9f83a75..a6ec70b1ef 100644 --- a/src/components/Attachment/utils.tsx +++ b/src/components/Attachment/utils.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import type { Attachment } from 'stream-chat'; +import type { Attachment, SharedLocationResponse } from 'stream-chat'; import type { ATTACHMENT_GROUPS_ORDER, AttachmentProps } from './Attachment'; export const SUPPORTED_VIDEO_FORMATS = [ @@ -26,6 +26,10 @@ export type RenderGalleryProps = Omit & { attachment: GalleryAttachment; }; +export type GeolocationContainerProps = Omit & { + location: SharedLocationResponse; +}; + // This identity function determines attachment type specific to React. // Once made sure other SDKs support the same logic, move to stream-chat-js export const isGalleryAttachmentType = ( diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 844274cb52..8746dda216 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -113,6 +113,7 @@ type ChannelPropsForwardedToComponentContext = Pick< | 'Input' | 'LinkPreviewList' | 'LoadingIndicator' + | 'ShareLocationDialog' | 'Message' | 'MessageActions' | 'MessageBouncePrompt' @@ -1237,6 +1238,7 @@ const ChannelInner = ( ReminderNotification: props.ReminderNotification, SendButton: props.SendButton, SendToChannelCheckbox: props.SendToChannelCheckbox, + ShareLocationDialog: props.ShareLocationDialog, StartRecordingAudioButton: props.StartRecordingAudioButton, StopAIGenerationButton: props.StopAIGenerationButton, StreamedMessageText: props.StreamedMessageText, @@ -1303,6 +1305,7 @@ const ChannelInner = ( props.ReminderNotification, props.SendButton, props.SendToChannelCheckbox, + props.ShareLocationDialog, props.StartRecordingAudioButton, props.StopAIGenerationButton, props.StreamedMessageText, diff --git a/src/components/ChannelPreview/__tests__/utils.test.js b/src/components/ChannelPreview/__tests__/utils.test.js index fb2722d33e..d46c8ddf2f 100644 --- a/src/components/ChannelPreview/__tests__/utils.test.js +++ b/src/components/ChannelPreview/__tests__/utils.test.js @@ -13,6 +13,7 @@ import { } from 'mock-builders'; import { getDisplayImage, getDisplayTitle, getLatestMessagePreview } from '../utils'; +import { generateStaticLocationResponse } from '../../../mock-builders'; describe('ChannelPreview utils', () => { const clientUser = generateUser(); @@ -37,6 +38,15 @@ describe('ChannelPreview utils', () => { const channelWithDeletedMessage = generateChannel({ messages: [generateMessage({ deleted_at: new Date() })], }); + const channelWithLocationMessage = generateChannel({ + messages: [ + generateMessage({ + attachments: [], + shared_location: generateStaticLocationResponse(), + text: '', + }), + ], + }); const channelWithAttachmentMessage = generateChannel({ messages: [ generateMessage({ @@ -50,6 +60,7 @@ describe('ChannelPreview utils', () => { ['Nothing yet...', 'channelWithEmptyMessage', channelWithEmptyMessage], ['Message deleted', 'channelWithDeletedMessage', channelWithDeletedMessage], ['🏙 Attachment...', 'channelWithAttachmentMessage', channelWithAttachmentMessage], + ['📍Shared location', 'channelWithLocationMessage', channelWithLocationMessage], ])('should return %s for %s', async (expectedValue, testCaseName, c) => { const t = (text) => text; const channel = await getQueriedChannelInstance(c); diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx index 5ae0353d5c..bf39b94528 100644 --- a/src/components/ChannelPreview/utils.tsx +++ b/src/components/ChannelPreview/utils.tsx @@ -88,6 +88,10 @@ export const getLatestMessagePreview = ( return t('🏙 Attachment...'); } + if (latestMessage.shared_location) { + return t('📍Shared location'); + } + return t('Empty message...'); }; diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx index de33b6ff71..5442256e61 100644 --- a/src/components/Dialog/DialogAnchor.tsx +++ b/src/components/Dialog/DialogAnchor.tsx @@ -11,9 +11,11 @@ export interface DialogAnchorOptions { open: boolean; placement: Placement; referenceElement: HTMLElement | null; + allowFlip?: boolean; } export function useDialogAnchor({ + allowFlip, open, placement, referenceElement, @@ -21,6 +23,10 @@ export function useDialogAnchor({ const [popperElement, setPopperElement] = useState(null); const { attributes, styles, update } = usePopper(referenceElement, popperElement, { modifiers: [ + { + enabled: !!allowFlip, // Prevent flipping + name: 'flip', + }, { name: 'eventListeners', options: { @@ -61,6 +67,7 @@ export type DialogAnchorProps = PropsWithChildren> } & ComponentProps<'div'>; export const DialogAnchor = ({ + allowFlip = true, children, className, focus = true, @@ -74,6 +81,7 @@ export const DialogAnchor = ({ const dialog = useDialog({ id }); const open = useDialogIsOpen(id); const { attributes, setPopperElement, styles } = useDialogAnchor({ + allowFlip, open, placement, referenceElement, diff --git a/src/components/Form/Dropdown.tsx b/src/components/Form/Dropdown.tsx new file mode 100644 index 0000000000..749549103c --- /dev/null +++ b/src/components/Form/Dropdown.tsx @@ -0,0 +1,110 @@ +import type { PropsWithChildren } from 'react'; +import { useRef } from 'react'; +import { useEffect } from 'react'; +import React, { useState } from 'react'; +import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog'; +import { DialogManagerProvider, useTranslationContext } from '../../context'; +import type { Placement } from '@popperjs/core'; + +type DropdownContextValue = { + close(): void; +}; + +const DropdownContext = React.createContext({ + close: () => null, +}); + +type DropdownContextProviderProps = DropdownContextValue; + +const DropdownContextProvider = ({ + children, + ...props +}: PropsWithChildren) => ( + {children} +); + +export const useDropdownContext = () => React.useContext(DropdownContext); + +export type DropdownProps = PropsWithChildren<{ + className?: string; + openButtonProps?: React.HTMLAttributes; + placement?: Placement; +}>; + +export const Dropdown = (props: DropdownProps) => { + const dropdownDialogId = `dropdown`; + + return ( +
+ + + +
+ ); +}; + +const DropdownInner = ({ + children, + dialogId, + openButtonProps, + placement = 'bottom', +}: DropdownProps & { dialogId: string }) => { + const { t } = useTranslationContext(); + const [openButton, setOpenButton] = useState(null); + const [dropdownWidth, setDropdownWidth] = useState(''); + const dropdownRef = useRef(null); + const dialog = useDialog({ id: dialogId }); + const dropdownDialogIsOpen = useDialogIsOpen(dialogId); + + useEffect(() => { + if (!openButton || typeof ResizeObserver === 'undefined') return; + let timeout: ReturnType; + const observer = new ResizeObserver(([button]) => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + const width = button.target.getBoundingClientRect().width + 'px'; + if (!dropdownRef.current) { + setDropdownWidth(width); + return; + } + dropdownRef.current.style.width = width; + }, 100); + }); + observer.observe(openButton); + + return () => { + observer.disconnect(); + }; + }, [openButton]); + + return ( + + + + +
+
+ ); +}; + +export type DurationDropdownItemsProps = { + durations: number[]; + selectDuration: (duration: number) => void; +}; +const DurationDropdownItems = ({ + durations, + selectDuration, +}: DurationDropdownItemsProps) => { + const { t } = useTranslationContext(); + const { close } = useDropdownContext(); + return durations.map((duration) => ( + + )); +}; diff --git a/src/components/Location/__tests__/ShareLocationDialog.test.js b/src/components/Location/__tests__/ShareLocationDialog.test.js new file mode 100644 index 0000000000..b59d62cdfd --- /dev/null +++ b/src/components/Location/__tests__/ShareLocationDialog.test.js @@ -0,0 +1,221 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { Channel } from '../../Channel'; +import { Chat } from '../../Chat'; +import { initClientWithChannels } from '../../../mock-builders'; +import { ShareLocationDialog } from '../ShareLocationDialog'; +import { useMessageComposer } from '../../MessageInput'; + +jest.mock('../../MessageInput/hooks/useMessageComposer', () => ({ + useMessageComposer: jest.fn().mockReturnValue({ + locationComposer: { + initState: jest.fn(), + setData: jest.fn(), + }, + sendLocation: jest.fn(), + }), +})); + +const DROPDOWN_OPEN_BTN_TEST_ID = 'dropdown-open-button'; +const SHARE_LIVE_LOCATION_SWITCH_TEST_ID = 'share-location-dialog-live-location-switch'; +const GEOLOCATION_MAP_TEST_ID = 'geolocation-map'; + +const close = jest.fn().mockImplementation(); +const user = { id: 'user-id' }; +const GeolocationMapComponent = (props) => ( +
+); + +const renderComponent = async ({ channel, client, props } = {}) => { + const { + channels: [defaultChannel], + client: defaultClient, + } = await initClientWithChannels({ customUser: user }); + let result; + await act(() => { + result = render( + + + + + , + ); + }); + const justRerender = () => + result.rerender( + + + + + , + ); + return { channel: defaultChannel, client: defaultClient, justRerender, ...result }; +}; + +const getCurrentPosition = jest.fn().mockImplementation(() => ({})); +const watchPosition = jest.fn().mockImplementation(() => ({})); + +window.navigator.geolocation = { + clearWatch: jest.fn().mockImplementation(() => ({})), + getCurrentPosition, + watchPosition, +}; + +describe('ShareLocationDialog', () => { + afterEach(jest.clearAllMocks); + it('renders dropdown with default durations', async () => { + // check send button is enabled after selection + await renderComponent(); + expect(screen.queryByTestId(DROPDOWN_OPEN_BTN_TEST_ID)).not.toBeInTheDocument(); + await act(async () => { + await fireEvent.click(screen.getByTestId(SHARE_LIVE_LOCATION_SWITCH_TEST_ID)); + }); + + await act(async () => { + await fireEvent.click(screen.getByTestId(DROPDOWN_OPEN_BTN_TEST_ID)); + }); + expect(screen.getAllByText('15 minutes')).toHaveLength(2); + expect(screen.queryByText('an hour')).toBeInTheDocument(); + expect(screen.queryByText('8 hours')).toBeInTheDocument(); + }); + + it('renders dropdown with custom durations', async () => { + // check send button is enabled after selection + await renderComponent({ + props: { shareDurations: [2 * 60 * 1000, 3 * 60 * 60 * 1000, 10 * 60 * 60 * 1000] }, + }); + expect(screen.queryByTestId(DROPDOWN_OPEN_BTN_TEST_ID)).not.toBeInTheDocument(); + await act(async () => { + await fireEvent.click(screen.getByTestId(SHARE_LIVE_LOCATION_SWITCH_TEST_ID)); + }); + + await act(async () => { + await fireEvent.click(screen.getByTestId(DROPDOWN_OPEN_BTN_TEST_ID)); + }); + expect(screen.getAllByText('2 minutes')).toHaveLength(2); + expect(screen.queryByText('3 hours')).toBeInTheDocument(); + expect(screen.queryByText('10 hours')).toBeInTheDocument(); + }); + + it('renders GeolocationMap component', async () => { + const callbacks = {}; + window.navigator.geolocation.watchPosition.mockImplementation( + (onSuccess, onError) => { + callbacks.onSuccess = onSuccess; + callbacks.onError = onError; + }, + ); + const { justRerender } = await renderComponent({ + props: { GeolocationMap: GeolocationMapComponent }, + }); + const geolocationMap = screen.getByTestId(GEOLOCATION_MAP_TEST_ID); + expect(geolocationMap.dataset.latitude).toBeUndefined(); + expect(geolocationMap.dataset.longitude).toBeUndefined(); + expect(geolocationMap.dataset.loading).toBe('true'); + expect(geolocationMap.dataset.error).toBeUndefined(); + expect(geolocationMap.dataset.rw).toBe('true'); + + const error = new Error('Geolocation error'); + await act(() => { + callbacks.onError(error); + justRerender(); + }); + + waitFor(() => { + expect(geolocationMap.dataset.latitude).toBeUndefined(); + expect(geolocationMap.dataset.longitude).toBeUndefined(); + expect(geolocationMap.dataset.loading).toBe('false'); + expect(geolocationMap.dataset.error).toEqual(error); + }); + + const coords = { latitude: 1, longitude: 10 }; + await act(() => { + callbacks.onSuccess({ coords }); + justRerender(); + }); + + waitFor(() => { + expect(geolocationMap.dataset.latitude).toBe(coords.latitude); + expect(geolocationMap.dataset.longitude).toBe(coords.longitude); + expect(geolocationMap.dataset.loading).toBe('false'); + expect(geolocationMap.dataset.error).toBeUndefined(); + }); + }); + + it('closes the dialog', async () => { + await renderComponent({ props: { close } }); + const messageComposer = useMessageComposer(); + await act(async () => { + await fireEvent.click(screen.getByText('Cancel')); + }); + expect(close).toHaveBeenCalledTimes(1); + expect(messageComposer.locationComposer.initState).toHaveBeenCalledTimes(1); + }); + + it('attaches the position to message composition', async () => { + const callbacks = {}; + window.navigator.geolocation.watchPosition.mockImplementation( + (onSuccess, onError) => { + callbacks.onSuccess = onSuccess; + callbacks.onError = onError; + }, + ); + + const { justRerender } = await renderComponent({ props: { close } }); + const messageComposer = useMessageComposer(); + const coords = { latitude: 1, longitude: 10 }; + await act(() => { + callbacks.onSuccess({ coords }); + justRerender(); + }); + waitFor(() => { + expect(screen.getByText('Attach')).toBeEnabled(); + }); + await act(async () => { + await fireEvent.click(screen.getByText('Attach')); + }); + expect(close).toHaveBeenCalledTimes(1); + expect(messageComposer.locationComposer.initState).not.toHaveBeenCalledTimes(1); + expect(messageComposer.locationComposer.setData).toHaveBeenCalledWith( + expect.objectContaining({ ...coords, durationMs: undefined }), + ); + }); + + it('sends message with the position directly', async () => { + const callbacks = {}; + window.navigator.geolocation.watchPosition.mockImplementation( + (onSuccess, onError) => { + callbacks.onSuccess = onSuccess; + callbacks.onError = onError; + }, + ); + + const { justRerender } = await renderComponent({ props: { close } }); + const messageComposer = useMessageComposer(); + const coords = { latitude: 1, longitude: 10 }; + await act(() => { + callbacks.onSuccess({ coords }); + justRerender(); + }); + waitFor(() => { + expect(screen.getByText('Share')).toBeEnabled(); + }); + await act(async () => { + await fireEvent.click(screen.getByText('Share')); + }); + expect(close).toHaveBeenCalledTimes(1); + expect(messageComposer.locationComposer.initState).not.toHaveBeenCalledTimes(1); + expect(messageComposer.locationComposer.setData).toHaveBeenCalledWith( + expect.objectContaining({ ...coords, durationMs: undefined }), + ); + expect(messageComposer.sendLocation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Location/hooks/useLiveLocationSharingManager.ts b/src/components/Location/hooks/useLiveLocationSharingManager.ts new file mode 100644 index 0000000000..51f83ed0e5 --- /dev/null +++ b/src/components/Location/hooks/useLiveLocationSharingManager.ts @@ -0,0 +1,68 @@ +import { LiveLocationManager } from 'stream-chat'; +import { useEffect, useMemo } from 'react'; +import type { LiveLocationManagerConstructorParameters, StreamChat } from 'stream-chat'; + +const isMobile = () => /Mobi/i.test(navigator.userAgent); +/** + * Checks whether the current browser is Safari. + */ +export const isSafari = () => { + if (typeof navigator === 'undefined') return false; + return /^((?!chrome|android).)*safari/i.test(navigator.userAgent || ''); +}; + +/** + * Checks whether the current browser is Firefox. + */ +export const isFirefox = () => { + if (typeof navigator === 'undefined') return false; + return navigator.userAgent?.includes('Firefox'); +}; + +/** + * Checks whether the current browser is Google Chrome. + */ +export const isChrome = () => { + if (typeof navigator === 'undefined') return false; + return navigator.userAgent?.includes('Chrome'); +}; + +const browser = () => { + if (isChrome()) return 'chrome'; + if (isFirefox()) return 'firefox'; + if (isSafari()) return 'safari'; + return 'other'; +}; + +export const useLiveLocationSharingManager = ({ + client, + getDeviceId, + watchLocation, +}: Omit & { + client?: StreamChat | null; + getDeviceId?: () => string; +}) => { + const manager = useMemo(() => { + if (!client) return null; + + return new LiveLocationManager({ + client, + getDeviceId: + getDeviceId ?? + (() => `web-${isMobile() ? 'mobile' : 'desktop'}-${browser()}-${client.userID}`), + watchLocation, + }); + }, [client, getDeviceId, watchLocation]); + + useEffect(() => { + if (!manager) return; + + manager.init(); + + return () => { + manager.unregisterSubscriptions(); + }; + }, [manager]); + + return manager; +}; diff --git a/src/components/Location/index.ts b/src/components/Location/index.ts new file mode 100644 index 0000000000..1a88f0c32e --- /dev/null +++ b/src/components/Location/index.ts @@ -0,0 +1 @@ +export * from './ShareLocationDialog'; diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index bae5dac0ae..e2c1e95e38 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -91,6 +91,15 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { () => isMessageAIGenerated?.(message), [isMessageAIGenerated, message], ); + const finalAttachments = useMemo( + () => + !message.shared_location && !message.attachments + ? [] + : !message.shared_location + ? message.attachments + : [message.shared_location, ...(message.attachments ?? [])], + [message], + ); if (isDateSeparatorMessage(message)) { return null; @@ -185,11 +194,8 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => {
{poll && } - {message.attachments?.length && !message.quoted_message ? ( - + {finalAttachments?.length && !message.quoted_message ? ( + ) : null} {isAIGenerated ? ( diff --git a/src/components/Message/__tests__/MessageSimple.test.js b/src/components/Message/__tests__/MessageSimple.test.js index a8ebf2d497..858f8c7d55 100644 --- a/src/components/Message/__tests__/MessageSimple.test.js +++ b/src/components/Message/__tests__/MessageSimple.test.js @@ -26,8 +26,10 @@ import { countReactions, generateChannel, generateFileAttachment, + generateImageAttachment, generateMessage, generateReaction, + generateStaticLocationResponse, generateUser, getOrCreateChannelApi, getTestClientWithUser, @@ -614,6 +616,21 @@ describe('', () => { expect(results).toHaveNoViolations(); }); + it('adds shared location at the beginning of the attachment list', async () => { + const message = generateAliceMessage({ + attachments: [ + generateFileAttachment(), + generateImageAttachment(), + generateImageAttachment(), + ], + shared_location: generateStaticLocationResponse(), + }); + await renderMessageSimple({ message }); + expect(screen.getAllByTestId('gallery-image')).toHaveLength(2); + expect(screen.getAllByTestId('attachment-file')).toHaveLength(1); + expect(screen.getAllByTestId('attachment-geolocation')).toHaveLength(1); + }); + it('should display reply count and handle replies count button click when not in thread list and reply count is not 0', async () => { const message = generateAliceMessage({ reply_count: 1, diff --git a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx index 4c0d13f2cf..0a90fde844 100644 --- a/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx +++ b/src/components/MessageInput/AttachmentPreviewList/AttachmentPreviewList.tsx @@ -11,17 +11,22 @@ import { } from 'stream-chat'; import type { UnsupportedAttachmentPreviewProps } from './UnsupportedAttachmentPreview'; import { UnsupportedAttachmentPreview as DefaultUnknownAttachmentPreview } from './UnsupportedAttachmentPreview'; -import { VoiceRecordingPreview as DefaultVoiceRecordingPreview } from './VoiceRecordingPreview'; -import { FileAttachmentPreview as DefaultFilePreview } from './FileAttachmentPreview'; -import { ImageAttachmentPreview as DefaultImagePreview } from './ImageAttachmentPreview'; -import { useAttachmentManagerState, useMessageComposer } from '../hooks'; import type { VoiceRecordingPreviewProps } from './VoiceRecordingPreview'; +import { VoiceRecordingPreview as DefaultVoiceRecordingPreview } from './VoiceRecordingPreview'; import type { FileAttachmentPreviewProps } from './FileAttachmentPreview'; +import { FileAttachmentPreview as DefaultFilePreview } from './FileAttachmentPreview'; import type { ImageAttachmentPreviewProps } from './ImageAttachmentPreview'; +import { ImageAttachmentPreview as DefaultImagePreview } from './ImageAttachmentPreview'; +import { useAttachmentsForPreview, useMessageComposer } from '../hooks'; +import { + GeolocationPreview as DefaultGeolocationPreview, + type GeolocationPreviewProps, +} from './GeolocationPreview'; export type AttachmentPreviewListProps = { AudioAttachmentPreview?: ComponentType; FileAttachmentPreview?: ComponentType; + GeolocationPreview?: ComponentType; ImageAttachmentPreview?: ComponentType; UnsupportedAttachmentPreview?: ComponentType; VideoAttachmentPreview?: ComponentType; @@ -31,6 +36,7 @@ export type AttachmentPreviewListProps = { export const AttachmentPreviewList = ({ AudioAttachmentPreview = DefaultFilePreview, FileAttachmentPreview = DefaultFilePreview, + GeolocationPreview = DefaultGeolocationPreview, ImageAttachmentPreview = DefaultImagePreview, UnsupportedAttachmentPreview = DefaultUnknownAttachmentPreview, VideoAttachmentPreview = DefaultFilePreview, @@ -38,9 +44,10 @@ export const AttachmentPreviewList = ({ }: AttachmentPreviewListProps) => { const messageComposer = useMessageComposer(); - const { attachments } = useAttachmentManagerState(); + // todo: we could also allow to attach poll to a message composition + const { attachments, location } = useAttachmentsForPreview(); - if (!attachments.length) return null; + if (!attachments.length && !location) return null; return (
@@ -48,6 +55,18 @@ export const AttachmentPreviewList = ({ className='str-chat__attachment-list-scroll-container' data-testid='attachment-list-scroll-container' > + {location && ( + + )} {attachments.map((attachment) => { if (isScrapedContent(attachment)) return null; if (isLocalVoiceRecordingAttachment(attachment)) { diff --git a/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx b/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx new file mode 100644 index 0000000000..32c61c989d --- /dev/null +++ b/src/components/MessageInput/AttachmentPreviewList/GeolocationPreview.tsx @@ -0,0 +1,77 @@ +import type { LiveLocationPreview, StaticLocationPreview } from 'stream-chat'; +import { CloseIcon } from '../icons'; +import type { ComponentType } from 'react'; +import React from 'react'; +import { useTranslationContext } from '../../../context'; +import { GeolocationIcon } from '../../Attachment/icons'; + +type GeolocationPreviewImageProps = { + location: StaticLocationPreview | LiveLocationPreview; +}; + +const GeolocationPreviewImage = () => ( +
+ +
+); + +export type GeolocationPreviewProps = { + location: StaticLocationPreview | LiveLocationPreview; + PreviewImage?: ComponentType; + remove?: () => void; +}; + +export const GeolocationPreview = ({ + location, + PreviewImage = GeolocationPreviewImage, + remove, +}: GeolocationPreviewProps) => { + const { t } = useTranslationContext(); + return ( +
+ + {remove && ( + + )} + +
+ {(location as LiveLocationPreview).durationMs ? ( + <> +
+ {t('Live location')} +
+
+ {t('Live for {{duration}}', { + duration: t('duration/Share Location', { + milliseconds: (location as LiveLocationPreview).durationMs, + }), + })} +
+ + ) : ( + <> +
+ {t('Current location')} +
+
+ {location.latitude}, {location.longitude} +
+ + )} +
+
+ ); +}; diff --git a/src/components/MessageInput/AttachmentSelector.tsx b/src/components/MessageInput/AttachmentSelector.tsx index 32ac3ffb86..83d1539b7b 100644 --- a/src/components/MessageInput/AttachmentSelector.tsx +++ b/src/components/MessageInput/AttachmentSelector.tsx @@ -5,13 +5,13 @@ import { CHANNEL_CONTAINER_ID } from '../Channel/constants'; import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog'; import { DialogMenuButton } from '../Dialog/DialogMenu'; import { Modal } from '../Modal'; +import { ShareLocationDialog as DefaultLocationDialog } from '../Location'; import { PollCreationDialog as DefaultPollCreationDialog } from '../Poll'; import { Portal } from '../Portal/Portal'; import { UploadFileInput } from '../ReactFileUtilities'; import { useChannelStateContext, useComponentContext, - useMessageInputContext, useTranslationContext, } from '../../context'; import { @@ -19,6 +19,8 @@ import { useAttachmentSelectorContext, } from '../../context/AttachmentSelectorContext'; import { useStableId } from '../UtilityComponents/useStableId'; +import clsx from 'clsx'; +import { useMessageComposer } from './hooks'; export const SimpleAttachmentSelector = () => { const { @@ -84,7 +86,7 @@ export type AttachmentSelectorActionProps = { export type AttachmentSelectorAction = { ActionButton: React.ComponentType; - type: 'uploadFile' | 'createPoll' | (string & {}); + type: 'uploadFile' | 'createPoll' | 'addLocation' | (string & {}); ModalContent?: React.ComponentType; }; @@ -107,6 +109,20 @@ export const DefaultAttachmentSelectorComponents = { ); }, + Location({ closeMenu, openModalForAction }: AttachmentSelectorActionProps) { + const { t } = useTranslationContext(); + return ( + { + openModalForAction('addLocation'); + closeMenu(); + }} + > + {t('Location')} + + ); + }, Poll({ closeMenu, openModalForAction }: AttachmentSelectorActionProps) { const { t } = useTranslationContext(); return ( @@ -129,6 +145,10 @@ export const defaultAttachmentSelectorActionSet: AttachmentSelectorAction[] = [ ActionButton: DefaultAttachmentSelectorComponents.Poll, type: 'createPoll', }, + { + ActionButton: DefaultAttachmentSelectorComponents.Location, + type: 'addLocation', + }, ]; export type AttachmentSelectorProps = { @@ -137,25 +157,32 @@ export type AttachmentSelectorProps = { }; const useAttachmentSelectorActionsFiltered = (original: AttachmentSelectorAction[]) => { - const { PollCreationDialog = DefaultPollCreationDialog } = useComponentContext(); - const { channelCapabilities, channelConfig } = useChannelStateContext(); - const { isThreadInput } = useMessageInputContext(); + const { + PollCreationDialog = DefaultPollCreationDialog, + ShareLocationDialog = DefaultLocationDialog, + } = useComponentContext(); + const { channelCapabilities } = useChannelStateContext(); + const messageComposer = useMessageComposer(); return original .filter((action) => { - if (action.type === 'uploadFile' && !channelCapabilities['upload-file']) - return false; - if ( - action.type === 'createPoll' && - (!channelConfig?.polls || isThreadInput || !channelCapabilities['send-poll']) - ) - return false; + if (action.type === 'uploadFile') return channelCapabilities['upload-file']; + + if (action.type === 'createPoll') + return channelCapabilities['send-poll'] && !messageComposer.threadId; + + if (action.type === 'addLocation') { + return messageComposer.config.location.enabled && !messageComposer.threadId; + } return true; }) .map((action) => { if (action.type === 'createPoll' && !action.ModalContent) { return { ...action, ModalContent: PollCreationDialog }; } + if (action.type === 'addLocation' && !action.ModalContent) { + return { ...action, ModalContent: ShareLocationDialog }; + } return action; }); }; @@ -166,11 +193,11 @@ export const AttachmentSelector = ({ }: AttachmentSelectorProps) => { const { t } = useTranslationContext(); const { channelCapabilities } = useChannelStateContext(); - const { isThreadInput } = useMessageInputContext(); + const messageComposer = useMessageComposer(); const actions = useAttachmentSelectorActionsFiltered(attachmentSelectorActionSet); - const menuDialogId = `attachment-actions-menu${isThreadInput ? '-thread' : ''}`; + const menuDialogId = `attachment-actions-menu${messageComposer.threadId ? '-thread' : ''}`; const menuDialog = useDialog({ id: menuDialogId }); const menuDialogIsOpen = useDialogIsOpen(menuDialogId); @@ -242,7 +269,11 @@ export const AttachmentSelector = ({ isOpen={modalIsOpen} > diff --git a/src/components/MessageInput/MessageInput.tsx b/src/components/MessageInput/MessageInput.tsx index 0a14300b66..aa3b89b71f 100644 --- a/src/components/MessageInput/MessageInput.tsx +++ b/src/components/MessageInput/MessageInput.tsx @@ -60,7 +60,9 @@ export type MessageInputProps = { hideSendButton?: boolean; /** Custom UI component handling how the message input is rendered, defaults to and accepts the same props as [MessageInputFlat](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/MessageInputFlat.tsx) */ Input?: React.ComponentType; - /** Signals that the MessageInput is rendered in a message thread (Thread component) */ + /** @deprecated use messageComposer.threadId to indicate, whether the message is composed within a thread context + * Signals that the MessageInput is rendered in a message thread (Thread component) + */ isThreadInput?: boolean; /** Max number of rows the underlying `textarea` component is allowed to grow */ maxRows?: number; @@ -119,11 +121,14 @@ const MessageInputProvider = (props: PropsWithChildren) => { ) return; // get draft data for legacy thead composer - messageComposer.channel.getDraft({ parent_id: threadId }).then(({ draft }) => { - if (draft) { - messageComposer.initState({ composition: draft }); - } - }); + messageComposer.channel + .getDraft({ parent_id: threadId }) + .then(({ draft }) => { + if (draft) { + messageComposer.initState({ composition: draft }); + } + }) + .catch(console.error); }, [messageComposer]); useRegisterDropHandlers(); @@ -139,11 +144,11 @@ const UnMemoizedMessageInput = (props: MessageInputProps) => { const { Input: PropInput } = props; const { Input: ContextInput } = useComponentContext('MessageInput'); - + const messageComposer = useMessageComposer(); const id = useStableId(); const Input = PropInput || ContextInput || MessageInputFlat; - const dialogManagerId = props.isThreadInput + const dialogManagerId = messageComposer.threadId ? `message-input-dialog-manager-thread-${id}` : `message-input-dialog-manager-${id}`; diff --git a/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js b/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js index b44a26c2b6..27ed1f76a4 100644 --- a/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js +++ b/src/components/MessageInput/__tests__/AttachmentPreviewList.test.js @@ -10,10 +10,13 @@ import { generateFileAttachment, generateImageAttachment, generateLocalImageUploadAttachmentData, + generateMessage, + generateStaticLocationResponse, generateVideoAttachment, generateVoiceRecordingAttachment, initClientWithChannels, } from '../../../mock-builders'; +import { MessageProvider } from '../../../context'; jest.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(); @@ -28,6 +31,8 @@ const renderComponent = async ({ channel: customChannel, client: customClient, componentCtx, + coords, + editedMessage, props, } = {}) => { let channel = customChannel; @@ -38,12 +43,29 @@ const renderComponent = async ({ channel = initiated.channels[0]; } channel.messageComposer.attachmentManager.upsertAttachments(attachments ?? []); + if (coords) channel.messageComposer.locationComposer.setData(coords); let result; await act(() => { result = render( - + {editedMessage ? ( + + + + ) : ( + + )} , ); @@ -279,4 +301,38 @@ describe('AttachmentPreviewList', () => { }); expect(container).toMatchSnapshot(); }); + + describe('shared location', () => { + it('should be rendered with location preview', async () => { + await renderComponent({ + attachments: [], + coords: { latitude: 2, longitude: 2 }, + }); + expect(screen.queryByTestId('location-preview')).toBeInTheDocument(); + }); + it('should be rendered with custom location preview', async () => { + const GeolocationPreview = () =>
; + await renderComponent({ + attachments: [], + coords: { latitude: 2, longitude: 2 }, + props: { GeolocationPreview }, + }); + expect(screen.queryByTestId('location-preview')).not.toBeInTheDocument(); + expect(screen.queryByTestId('geolocation-preview-custom')).toBeInTheDocument(); + }); + + it('should render location preview without possibility to remove it when editing a message', async () => { + await renderComponent({ + attachments: [], + coords: { latitude: 2, longitude: 2 }, + editedMessage: generateMessage({ + shared_location: generateStaticLocationResponse(), + }), + }); + expect(screen.queryByTestId('location-preview')).toBeInTheDocument(); + expect( + screen.queryByTestId('location-preview-item-delete-button'), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/MessageInput/__tests__/AttachmentSelector.test.js b/src/components/MessageInput/__tests__/AttachmentSelector.test.js index efb4747754..da20c86000 100644 --- a/src/components/MessageInput/__tests__/AttachmentSelector.test.js +++ b/src/components/MessageInput/__tests__/AttachmentSelector.test.js @@ -8,10 +8,12 @@ import { ChannelStateProvider, ComponentProvider, TranslationProvider, + TypingProvider, } from '../../../context'; -import { initClientWithChannels } from '../../../mock-builders'; +import { generateMessage, initClientWithChannels } from '../../../mock-builders'; import { CHANNEL_CONTAINER_ID } from '../../Channel/constants'; import { AttachmentSelector } from '../AttachmentSelector'; +import { LegacyThreadContext } from '../../Thread/LegacyThreadContext'; const ATTACHMENT_SELECTOR__ACTIONS_MENU_TEST_ID = 'attachment-selector-actions-menu'; const POLL_CREATION_DIALOG_TEST_ID = 'poll-creation-dialog'; @@ -21,18 +23,25 @@ const UPLOAD_FILE_BUTTON_CLASS = 'str-chat__attachment-selector-actions-menu__upload-file-button'; const CREATE_POLL_BUTTON_CLASS = 'str-chat__attachment-selector-actions-menu__create-poll-button'; +const SHARE_LOCATION_BUTTON_CLASS = + 'str-chat__attachment-selector-actions-menu__add-location-button'; const translationContext = { t: (v) => v, + tDateTimeParser: (v) => v.toString(), }; -const defaultChannelStateContext = { - channelCapabilities: { 'send-poll': true, 'upload-file': true }, - channelConfig: { polls: true }, +const defaultChannelData = { + own_capabilities: ['upload-file'], }; -const defaultMessageInputProps = { - isThreadInput: false, +const defaultConfig = { + config: { shared_locations: true }, +}; + +const defaultChannelStateContext = { + channelCapabilities: { 'send-poll': true, 'upload-file': true }, + notifications: [], }; const invokeMenu = async () => { @@ -42,33 +51,58 @@ const invokeMenu = async () => { }; const renderComponent = async ({ + channelData = {}, channelStateContext, componentContext, + customChannel, + customClient, messageInputProps, } = {}) => { - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [{ channel: { own_capabilities: ['upload-file'] } }], - }); + let channel, client; + if (customChannel && customClient) { + channel = customChannel; + client = customClient; + } else { + const res = await initClientWithChannels({ + channelsData: [ + { channel: { ...defaultChannelData, config: defaultConfig, ...channelData } }, + ], + }); + channel = res.channels[0]; + client = res.client; + } + jest.spyOn(channel, 'getDraft').mockImplementation(); let result; await act(() => { result = render( - - -
- -
-
-
+ + + +
+ {channelStateContext?.thread ? ( + + + + ) : ( + + )} +
+
+
+
, @@ -78,28 +112,26 @@ const renderComponent = async ({ }; describe('AttachmentSelector', () => { - it('renders with upload file button and poll button if send-poll and upload-file permissions are granted', async () => { + it('renders with all the buttons if all the permissions are granted', async () => { await renderComponent(); await invokeMenu(); const menu = screen.getByTestId(ATTACHMENT_SELECTOR__ACTIONS_MENU_TEST_ID); expect(menu).toBeInTheDocument(); expect(menu).toHaveTextContent('File'); expect(menu).toHaveTextContent('Poll'); + expect(menu).toHaveTextContent('Location'); }); - it('falls back to SimpleAttachmentSelector if channel.config.polls is false', async () => { - const { container } = await renderComponent({ - channelStateContext: { channelConfig: { polls: false } }, - }); - expect( - container.querySelector(`.${ATTACHMENT_SELECTOR_CLASS}`), - ).not.toBeInTheDocument(); - expect(screen.getByTestId('file-upload-button')).toBeInTheDocument(); - }); - - it('renders SimpleAttachmentSelector if send-poll permission is not granted', async () => { + it('falls back to SimpleAttachmentSelector if only file uploads are enabled', async () => { + const { + channels: [customChannel], + client: customClient, + } = await initClientWithChannels(); + customChannel.messageComposer.updateConfig({ location: { enabled: false } }); const { container } = await renderComponent({ channelStateContext: { channelCapabilities: { 'upload-file': true } }, + customChannel, + customClient, }); expect( container.querySelector(`.${ATTACHMENT_SELECTOR_CLASS}`), @@ -108,8 +140,26 @@ describe('AttachmentSelector', () => { }); it('renders SimpleAttachmentSelector if rendered in a thread', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [ + { + channel: { + ...defaultChannelData, + cid: 'type:id', + config: defaultConfig, + id: 'id', + type: 'type', + }, + }, + ], + }); const { container } = await renderComponent({ - messageInputProps: { isThreadInput: true }, + channel, + channelStateContext: { thread: generateMessage({ cid: channel.cid }) }, + client, }); expect( container.querySelector(`.${ATTACHMENT_SELECTOR_CLASS}`), @@ -117,7 +167,7 @@ describe('AttachmentSelector', () => { expect(screen.getByTestId('file-upload-button')).toBeInTheDocument(); }); - it('renders SimpleAttachmentSelector if upload-file permission is not granted', async () => { + it('renders AttachmentSelector if upload-file permission is not granted', async () => { await renderComponent({ channelStateContext: { channelCapabilities: { 'send-poll': true } }, }); @@ -126,9 +176,25 @@ describe('AttachmentSelector', () => { expect(menu).toBeInTheDocument(); expect(menu).not.toHaveTextContent('File'); expect(menu).toHaveTextContent('Poll'); + expect(menu).toHaveTextContent('Location'); + }); + + it('renders AttachmentSelector if only location sharing is enabled', async () => { + await renderComponent({ + channelData: { + config: { shared_locations: true }, + }, + channelStateContext: { channelCapabilities: {} }, + }); + await invokeMenu(); + const menu = screen.getByTestId(ATTACHMENT_SELECTOR__ACTIONS_MENU_TEST_ID); + expect(menu).toBeInTheDocument(); + expect(menu).not.toHaveTextContent('File'); + expect(menu).not.toHaveTextContent('Poll'); + expect(menu).toHaveTextContent('Location'); }); - it('does not render the invoke button if send-poll and upload-file permission is not granted', async () => { + it('does not render the invoke button if no permissions are not granted', async () => { await renderComponent({ channelStateContext: { channelCapabilities: {} }, }); @@ -182,6 +248,7 @@ describe('AttachmentSelector', () => { expect(menu).toHaveTextContent(customText); expect(menu).not.toHaveTextContent('File'); expect(menu).not.toHaveTextContent('Poll'); + expect(menu).not.toHaveTextContent('Location'); }); it('renders custom modal content if provided', async () => { @@ -260,4 +327,25 @@ describe('AttachmentSelector', () => { expect(screen.getByTestId(testId)).toBeInTheDocument(); }); }); + + it('allows to override ShareLocationDialog', async () => { + const SHARE_LOCATION_DIALOG_TEST_ID = 'custom-share-location-dialog'; + const CustomShareLocationDialog = () => ( +
+ ); + await renderComponent({ + componentContext: { + ShareLocationDialog: CustomShareLocationDialog, + }, + }); + await invokeMenu(); + const menu = screen.getByTestId(ATTACHMENT_SELECTOR__ACTIONS_MENU_TEST_ID); + const locationButton = menu.querySelector(`.${SHARE_LOCATION_BUTTON_CLASS}`); + act(() => { + fireEvent.click(locationButton); + }); + await waitFor(() => { + expect(screen.getByTestId(SHARE_LOCATION_DIALOG_TEST_ID)).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/MessageInput/hooks/index.ts b/src/components/MessageInput/hooks/index.ts index c38bb17f80..0c8fd01bd1 100644 --- a/src/components/MessageInput/hooks/index.ts +++ b/src/components/MessageInput/hooks/index.ts @@ -1,4 +1,5 @@ export * from './useAttachmentManagerState'; +export * from './useAttachmentsForPreview'; export * from './useCanCreatePoll'; export * from './useCooldownTimer'; export * from './useMessageInputControls'; diff --git a/src/components/MessageInput/hooks/useAttachmentsForPreview.ts b/src/components/MessageInput/hooks/useAttachmentsForPreview.ts new file mode 100644 index 0000000000..027e0468ac --- /dev/null +++ b/src/components/MessageInput/hooks/useAttachmentsForPreview.ts @@ -0,0 +1,36 @@ +import { useMessageComposer } from './useMessageComposer'; +import { useStateStore } from '../../../store'; +import type { + AttachmentManagerState, + LocationComposerState, + PollComposerState, +} from 'stream-chat'; + +const attachmentManagerStateSelector = (state: AttachmentManagerState) => ({ + attachments: state.attachments, +}); +const pollComposerStateSelector = (state: PollComposerState) => ({ + poll: state.data, +}); +const locationComposerStateSelector = (state: LocationComposerState) => ({ + location: state.location, +}); + +export const useAttachmentsForPreview = () => { + const { attachmentManager, locationComposer, pollComposer } = useMessageComposer(); + const { attachments } = useStateStore( + attachmentManager.state, + attachmentManagerStateSelector, + ); + const { poll } = useStateStore(pollComposer.state, pollComposerStateSelector); + const { location } = useStateStore( + locationComposer.state, + locationComposerStateSelector, + ); + + return { + attachments, + location, + poll, + }; +}; diff --git a/src/components/index.ts b/src/components/index.ts index dc60039f3b..bef74710a1 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -14,6 +14,7 @@ export * from './Gallery'; export * from './InfiniteScrollPaginator'; export * from './Loading'; export * from './LoadMore'; +export * from './Location'; export * from './MediaRecorder'; export * from './Message'; export * from './MessageActions'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 2a5c66047e..d5305cad55 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -64,6 +64,7 @@ import type { import type { PropsWithChildrenOnly, UnknownType } from '../types/types'; import type { StopAIGenerationButtonProps } from '../components/MessageInput/StopAIGenerationButton'; +import type { ShareLocationDialogProps } from '../components/Location'; export type ComponentContextValue = { /** Custom UI component to display a message attachment, defaults to and accepts same props as: [Attachment](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/Attachment.tsx) */ @@ -203,6 +204,8 @@ export type ComponentContextValue = { SendButton?: React.ComponentType; /** Custom UI component checkbox that indicates message to be sent to main channel, defaults to and accepts same props as: [SendToChannelCheckbox](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/SendToChannelCheckbox.tsx) */ SendToChannelCheckbox?: React.ComponentType; + /** Custom UI component to render the location sharing dialog, defaults to and accepts same props as: [ShareLocationDialog](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Location/ShareLocationDialog.tsx) */ + ShareLocationDialog?: React.ComponentType; /** Custom UI component button for initiating audio recording, defaults to and accepts same props as: [StartRecordingAudioButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MediaRecorder/AudioRecorder/AudioRecordingButtons.tsx) */ StartRecordingAudioButton?: React.ComponentType; StopAIGenerationButton?: React.ComponentType | null; diff --git a/src/i18n/__tests__/Streami18n.test.js b/src/i18n/__tests__/Streami18n.test.js index 72a922b701..1c4d805209 100644 --- a/src/i18n/__tests__/Streami18n.test.js +++ b/src/i18n/__tests__/Streami18n.test.js @@ -96,6 +96,7 @@ describe('Streami18n instance - with built-in langauge', () => { (key.includes('{{') && key.includes('}}')) || key.includes('duration/Message reminder') || key.includes('duration/Remind Me') || + key.includes('duration/Share Location') || typeof nlTranslations[key] !== 'string' ) { continue; @@ -125,6 +126,7 @@ describe('Streami18n instance - with built-in langauge', () => { (key.includes('{{') && key.includes('}}')) || key.includes('duration/Message reminder') || key.includes('duration/Remind Me') || + key.includes('duration/Share Location') || typeof nlTranslations[key] !== 'string' ) { continue; @@ -248,6 +250,7 @@ describe('setLanguage - switch to french', () => { (key.includes('{{') && key.includes('}}')) || key.includes('duration/Message reminder') || key.includes('duration/Remind Me') || + key.includes('duration/Share Location') || typeof nlTranslations[key] !== 'string' ) { continue; diff --git a/src/i18n/de.json b/src/i18n/de.json index fefa5103bf..c71e2d48e5 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -14,6 +14,7 @@ "Anonymous poll": "Anonyme Umfrage", "Archive": "Archivieren", "Ask a question": "Eine Frage stellen", + "Attach": "Anhängen", "Attach files": "Dateien anhängen", "Attachment upload blocked due to {{reason}}": "Anhang-Upload blockiert wegen {{reason}}", "Attachment upload failed due to {{reason}}": "Anhang-Upload fehlgeschlagen wegen {{reason}}", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "Verbindungsfehler, Wiederherstellung der Verbindung...", "Create": "Erstellen", "Create poll": "Umfrage erstellen", + "Current location": "Aktueller Standort", "Delete": "Löschen", "Delivered": "Zugestellt", "Download attachment {{ name }}": "Anhang {{ name }} herunterladen", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "Fehler beim Springen zur ersten ungelesenen Nachricht", "Failed to mark channel as read": "Fehler beim Markieren des Kanals als gelesen", "Failed to play the recording": "Wiedergabe der Aufnahme fehlgeschlagen", + "Failed to retrieve location": "Standort konnte nicht abgerufen werden", + "Failed to share location": "Standort konnte nicht geteilt werden", "File": "Datei", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Datei ist zu groß: {{ size }}, maximale Upload-Größe beträgt {{ limit }}", "Flag": "Melden", "Generating...": "Generieren...", "Latest Messages": "Neueste Nachrichten", + "Live for {{duration}}": "Live für {{duration}}", + "Live location": "Live-Standort", + "Live until {{ timestamp }}": "Live bis {{ timestamp }}", "Load more": "Mehr laden", + "Location": "Standort", + "Location sharing ended": "Standortfreigabe beendet", "Mark as unread": "Als ungelesen markieren", "Maximum number of votes (from 2 to 10)": "Maximale Anzahl der Stimmen (von 2 bis 10)", "Menu": "Menü", @@ -119,11 +128,16 @@ "Send Anyway": "Trotzdem senden", "Send message request failed": "Senden der Nachrichtenanfrage fehlgeschlagen", "Sending...": "Senden...", + "Share": "Teilen", + "Share Location": "Standort teilen", + "Share live location for": "Live-Standort teilen für", + "Shared live location": "Geteilter Live-Standort", "Show all": "Alle anzeigen", "Shuffle": "Mischen", "Slow Mode ON": "Langsamer Modus EIN", "Some of the files will not be accepted": "Einige der Dateien werden nicht akzeptiert", "Start typing to search": "Tippen Sie, um zu suchen", + "Stop sharing": "Teilen beenden", "Submit": "Absenden", "Suggest an option": "Eine Option vorschlagen", "Thinking...": "Denken...", @@ -168,12 +182,14 @@ "aria/Menu": "Menü", "aria/Message Options": "Nachrichtenoptionen", "aria/Open Attachment Selector": "Anhang-Auswahl öffnen", + "aria/Open Menu": "Menü öffnen", "aria/Open Message Actions Menu": "Nachrichtenaktionsmenü öffnen", "aria/Open Reaction Selector": "Reaktionsauswahl öffnen", "aria/Open Thread": "Thread öffnen", "aria/Reaction list": "Reaktionsliste", "aria/Remind Me Options": "Erinnerungsoptionen", "aria/Remove attachment": "Anhang entfernen", + "aria/Remove location attachment": "Standortanhang entfernen", "aria/Retry upload": "Upload erneut versuchen", "aria/Search results": "Suchergebnisse", "aria/Search results header filter button": "Suchergebnisse-Kopfzeilen-Filterbutton", @@ -183,6 +199,7 @@ "ban-command-description": "Einen Benutzer verbannen", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[Text]", "giphy-command-description": "Poste ein zufälliges Gif in den Kanal", "live": "live", @@ -199,6 +216,7 @@ "size limit": "Größenbeschränkung", "this content could not be displayed": "Dieser Inhalt konnte nicht angezeigt werden", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -230,5 +248,6 @@ "{{count}} votes_other": "{{count}} Stimmen", "🏙 Attachment...": "🏙 Anhang...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} hat erstellt: {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} hat abgestimmt: {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} hat abgestimmt: {{pollOptionText}}", + "📍Shared location": "📍Geteilter Standort" } diff --git a/src/i18n/en.json b/src/i18n/en.json index cf7f7bfe11..6b1f8c67fd 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -14,6 +14,7 @@ "Anonymous poll": "Anonymous poll", "Archive": "Archive", "Ask a question": "Ask a question", + "Attach": "Attach", "Attach files": "Attach files", "Attachment upload blocked due to {{reason}}": "Attachment upload blocked due to {{reason}}", "Attachment upload failed due to {{reason}}": "Attachment upload failed due to {{reason}}", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "Connection failure, reconnecting now...", "Create": "Create", "Create poll": "Create poll", + "Current location": "Current location", "Delete": "Delete", "Delivered": "Delivered", "Download attachment {{ name }}": "Download attachment {{ name }}", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "Failed to jump to the first unread message", "Failed to mark channel as read": "Failed to mark channel as read", "Failed to play the recording": "Failed to play the recording", + "Failed to retrieve location": "Failed to retrieve location", + "Failed to share location": "Failed to share location", "File": "File", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "File is too large: {{ size }}, maximum upload size is {{ limit }}", "Flag": "Flag", "Generating...": "Generating...", "Latest Messages": "Latest Messages", + "Live for {{duration}}": "Live for {{duration}}", + "Live location": "Live location", + "Live until {{ timestamp }}": "Live until {{ timestamp }}", "Load more": "Load more", + "Location": "Location", + "Location sharing ended": "Location sharing ended", "Mark as unread": "Mark as unread", "Maximum number of votes (from 2 to 10)": "Maximum number of votes (from 2 to 10)", "Menu": "Menu", @@ -119,11 +128,16 @@ "Send Anyway": "Send Anyway", "Send message request failed": "Send message request failed", "Sending...": "Sending...", + "Share": "Share", + "Share Location": "Share Location", + "Share live location for": "Share live location for", + "Shared live location": "Shared live location", "Show all": "Show all", "Shuffle": "Shuffle", "Slow Mode ON": "Slow Mode ON", "Some of the files will not be accepted": "Some of the files will not be accepted", "Start typing to search": "Start typing to search", + "Stop sharing": "Stop sharing", "Submit": "Submit", "Suggest an option": "Suggest an option", "Thinking...": "Thinking...", @@ -168,12 +182,14 @@ "aria/Menu": "Menu", "aria/Message Options": "Message Options", "aria/Open Attachment Selector": "aria/Open Attachment Selector", + "aria/Open Menu": "Open Menu", "aria/Open Message Actions Menu": "Open Message Actions Menu", "aria/Open Reaction Selector": "Open Reaction Selector", "aria/Open Thread": "Open Thread", "aria/Reaction list": "Reaction list", "aria/Remind Me Options": "aria/Remind Me Options", "aria/Remove attachment": "Remove attachment", + "aria/Remove location attachment": "Remove location attachment", "aria/Retry upload": "Retry upload", "aria/Search results": "Search results", "aria/Search results header filter button": "Search results header filter button", @@ -181,6 +197,7 @@ "aria/Stop AI Generation": "Stop AI Generation", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "live": "live", "network error": "network error", "replyCount_one": "1 reply", @@ -193,6 +210,7 @@ "size limit": "size limit", "this content could not be displayed": "this content could not be displayed", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -220,5 +238,6 @@ "{{count}} votes_other": "{{count}} votes", "🏙 Attachment...": "🏙 Attachment...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} created: {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} voted: {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} voted: {{pollOptionText}}", + "📍Shared location": "📍Shared location" } diff --git a/src/i18n/es.json b/src/i18n/es.json index 1a537145b1..cec59931a8 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -14,6 +14,7 @@ "Anonymous poll": "Encuesta anónima", "Archive": "Archivo", "Ask a question": "Hacer una pregunta", + "Attach": "Adjuntar", "Attach files": "Adjuntar archivos", "Attachment upload blocked due to {{reason}}": "Carga de adjunto bloqueada debido a {{reason}}", "Attachment upload failed due to {{reason}}": "Carga de adjunto fallida debido a {{reason}}", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "Fallo de conexión, reconectando ahora...", "Create": "Crear", "Create poll": "Crear encuesta", + "Current location": "Ubicación actual", "Delete": "Borrar", "Delivered": "Entregado", "Download attachment {{ name }}": "Descargar adjunto {{ name }}", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "Error al saltar al primer mensaje no leído", "Failed to mark channel as read": "Error al marcar el canal como leído", "Failed to play the recording": "No se pudo reproducir la grabación", + "Failed to retrieve location": "No se pudo obtener la ubicación", + "Failed to share location": "No se pudo compartir la ubicación", "File": "Archivo", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "El archivo es demasiado grande: {{ size }}, el tamaño máximo de carga es de {{ limit }}", "Flag": "Marcar", "Generating...": "Generando...", "Latest Messages": "Últimos mensajes", + "Live for {{duration}}": "En vivo durante {{duration}}", + "Live location": "Ubicación en vivo", + "Live until {{ timestamp }}": "En vivo hasta {{ timestamp }}", "Load more": "Cargar más", + "Location": "Ubicación", + "Location sharing ended": "Compartir ubicación terminado", "Mark as unread": "Marcar como no leído", "Maximum number of votes (from 2 to 10)": "Número máximo de votos (de 2 a 10)", "Menu": "Menú", @@ -121,11 +130,16 @@ "Send Anyway": "Enviar de todos modos", "Send message request failed": "Error al enviar la solicitud de mensaje", "Sending...": "Enviando...", + "Share": "Compartir", + "Share Location": "Compartir ubicación", + "Share live location for": "Compartir ubicación en vivo durante", + "Shared live location": "Ubicación en vivo compartida", "Show all": "Mostrar todo", "Shuffle": "Mezclar", "Slow Mode ON": "Modo lento activado", "Some of the files will not be accepted": "Algunos archivos no serán aceptados", "Start typing to search": "Empieza a escribir para buscar", + "Stop sharing": "Dejar de compartir", "Submit": "Enviar", "Suggest an option": "Sugerir una opción", "Thinking...": "Pensando...", @@ -171,12 +185,14 @@ "aria/Menu": "Menú", "aria/Message Options": "Opciones de mensaje", "aria/Open Attachment Selector": "Abrir selector de adjuntos", + "aria/Open Menu": "Abrir menú", "aria/Open Message Actions Menu": "Abrir menú de acciones de mensaje", "aria/Open Reaction Selector": "Abrir selector de reacciones", "aria/Open Thread": "Abrir hilo", "aria/Reaction list": "Lista de reacciones", "aria/Remind Me Options": "Opciones de recordatorio", "aria/Remove attachment": "Eliminar adjunto", + "aria/Remove location attachment": "Eliminar adjunto de ubicación", "aria/Retry upload": "Reintentar carga", "aria/Search results": "Resultados de búsqueda", "aria/Search results header filter button": "Botón de filtro del encabezado de resultados de búsqueda", @@ -186,6 +202,7 @@ "ban-command-description": "Prohibir a un usuario", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[texto]", "giphy-command-description": "Publicar un gif aleatorio en el canal", "live": "En vivo", @@ -204,6 +221,7 @@ "size limit": "límite de tamaño", "this content could not be displayed": "Este contenido no se pudo mostrar", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -238,5 +256,6 @@ "{{count}} votes_other": "{{count}} votos", "🏙 Attachment...": "🏙 Adjunto...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} creó: {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} votó: {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} votó: {{pollOptionText}}", + "📍Shared location": "📍Ubicación compartida" } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 17044a2c45..6a1f6e6a32 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -14,6 +14,7 @@ "Anonymous poll": "Sondage anonyme", "Archive": "Archive", "Ask a question": "Poser une question", + "Attach": "Joindre", "Attach files": "Joindre des fichiers", "Attachment upload blocked due to {{reason}}": "Téléchargement de pièce jointe bloqué en raison de {{reason}}", "Attachment upload failed due to {{reason}}": "Échec du téléchargement de la pièce jointe en raison de {{reason}}", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "Échec de la connexion, reconnexion en cours...", "Create": "Créer", "Create poll": "Créer un sondage", + "Current location": "Emplacement actuel", "Delete": "Supprimer", "Delivered": "Publié", "Download attachment {{ name }}": "Télécharger la pièce jointe {{ name }}", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "Échec du saut vers le premier message non lu", "Failed to mark channel as read": "Échec du marquage du canal comme lu", "Failed to play the recording": "Impossible de lire l'enregistrement", + "Failed to retrieve location": "Impossible de récupérer l'emplacement", + "Failed to share location": "Impossible de partager l'emplacement", "File": "Fichier", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Le fichier est trop volumineux : {{ size }}, la taille maximale de téléchargement est de {{ limit }}", "Flag": "Signaler", "Generating...": "Génération...", "Latest Messages": "Derniers messages", + "Live for {{duration}}": "En direct pendant {{duration}}", + "Live location": "Emplacement en direct", + "Live until {{ timestamp }}": "En direct jusqu'à {{ timestamp }}", "Load more": "Charger plus", + "Location": "Emplacement", + "Location sharing ended": "Partage d'emplacement terminé", "Mark as unread": "Marquer comme non lu", "Maximum number of votes (from 2 to 10)": "Nombre maximum de votes (de 2 à 10)", "Menu": "Menu", @@ -121,11 +130,16 @@ "Send Anyway": "Envoyer quand même", "Send message request failed": "Échec de la demande d'envoi de message", "Sending...": "Envoi en cours...", + "Share": "Partager", + "Share Location": "Partager l'emplacement", + "Share live location for": "Partager l'emplacement en direct pendant", + "Shared live location": "Emplacement en direct partagé", "Show all": "Tout afficher", "Shuffle": "Mélanger", "Slow Mode ON": "Mode lent activé", "Some of the files will not be accepted": "Certains fichiers ne seront pas acceptés", "Start typing to search": "Commencez à taper pour rechercher", + "Stop sharing": "Arrêter de partager", "Submit": "Envoyer", "Suggest an option": "Suggérer une option", "Thinking...": "Réflexion...", @@ -171,12 +185,14 @@ "aria/Menu": "Menu", "aria/Message Options": "Options du message", "aria/Open Attachment Selector": "Ouvrir le sélecteur de pièces jointes", + "aria/Open Menu": "Ouvrir le menu", "aria/Open Message Actions Menu": "Ouvrir le menu des actions du message", "aria/Open Reaction Selector": "Ouvrir le sélecteur de réactions", "aria/Open Thread": "Ouvrir le fil", "aria/Reaction list": "Liste des réactions", "aria/Remind Me Options": "Options de rappel", "aria/Remove attachment": "Supprimer la pièce jointe", + "aria/Remove location attachment": "Supprimer la pièce jointe d'emplacement", "aria/Retry upload": "Réessayer le téléchargement", "aria/Search results": "Résultats de recherche", "aria/Search results header filter button": "Bouton de filtre d'en-tête des résultats de recherche", @@ -186,6 +202,7 @@ "ban-command-description": "Bannir un utilisateur", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[texte]", "giphy-command-description": "Poster un GIF aléatoire dans le canal", "live": "en direct", @@ -204,6 +221,7 @@ "size limit": "limite de taille", "this content could not be displayed": "ce contenu n'a pas pu être affiché", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -238,5 +256,6 @@ "{{count}} votes_other": "{{count}} votes", "🏙 Attachment...": "🏙 Pièce jointe...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} a créé : {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} a voté : {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} a voté : {{pollOptionText}}", + "📍Shared location": "📍Emplacement partagé" } diff --git a/src/i18n/hi.json b/src/i18n/hi.json index a8fa99effb..5a9635eee4 100644 --- a/src/i18n/hi.json +++ b/src/i18n/hi.json @@ -14,6 +14,7 @@ "Anonymous poll": "गुमनाम मतदान", "Archive": "आर्काइव", "Ask a question": "एक प्रश्न पूछें", + "Attach": "संलग्न करें", "Attach files": "फाइल्स अटैच करे", "Attachment upload blocked due to {{reason}}": "{{reason}} के कारण अटैचमेंट अपलोड ब्लॉक किया गया", "Attachment upload failed due to {{reason}}": "{{reason}} के कारण अटैचमेंट अपलोड विफल रहा", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "कनेक्शन विफल रहा, अब पुनः कनेक्ट हो रहा है ...", "Create": "बनाएँ", "Create poll": "मतदान बनाएँ", + "Current location": "वर्तमान स्थान", "Delete": "डिलीट", "Delivered": "पहुंच गया", "Download attachment {{ name }}": "अनुलग्नक {{ name }} डाउनलोड करें", @@ -62,12 +64,19 @@ "Failed to jump to the first unread message": "पहले अपठित संदेश पर जाने में विफल", "Failed to mark channel as read": "चैनल को पढ़ा हुआ चिह्नित करने में विफल।", "Failed to play the recording": "रेकॉर्डिंग प्ले करने में विफल", + "Failed to retrieve location": "स्थान प्राप्त करने में विफल", + "Failed to share location": "स्थान साझा करने में विफल", "File": "फ़ाइल", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "फ़ाइल बहुत बड़ी है: {{ size }}, अधिकतम अपलोड साइज़ {{ limit }} है", "Flag": "फ्लैग करे", "Generating...": "बना रहा है...", "Latest Messages": "नवीनतम संदेश", + "Live for {{duration}}": "{{duration}} के लिए लाइव", + "Live location": "लाइव स्थान", + "Live until {{ timestamp }}": "{{ timestamp }} तक लाइव", "Load more": "और लोड करें", + "Location": "स्थान", + "Location sharing ended": "स्थान साझा करना समाप्त", "Mark as unread": "अपठित चिह्नित करें", "Maximum number of votes (from 2 to 10)": "अधिकतम वोटों की संख्या (2 से 10)", "Menu": "मेन्यू", @@ -120,11 +129,16 @@ "Send Anyway": "वैसे भी भेजें", "Send message request failed": "संदेश भेजने का अनुरोध विफल रहा", "Sending...": "भेजा जा रहा है", + "Share": "साझा करें", + "Share Location": "स्थान साझा करें", + "Share live location for": "लाइव स्थान साझा करें", + "Shared live location": "साझा किया गया लाइव स्थान", "Show all": "सभी दिखाएँ", "Shuffle": "मिश्रित करें", "Slow Mode ON": "स्लो मोड ऑन", "Some of the files will not be accepted": "कुछ फ़ाइलें स्वीकार नहीं की जाएंगी", "Start typing to search": "खोजने के लिए टाइप करना शुरू करें", + "Stop sharing": "साझा करना बंद करें", "Submit": "जमा करें", "Suggest an option": "एक विकल्प सुझाव दें", "Thinking...": "सोच रहा है...", @@ -169,12 +183,14 @@ "aria/Menu": "मेन्यू", "aria/Message Options": "संदेश विकल्प", "aria/Open Attachment Selector": "अटैचमेंट चयनकर्ता खोलें", + "aria/Open Menu": "मेन्यू खोलें", "aria/Open Message Actions Menu": "संदेश क्रिया मेन्यू खोलें", "aria/Open Reaction Selector": "प्रतिक्रिया चयनकर्ता खोलें", "aria/Open Thread": "थ्रेड खोलें", "aria/Reaction list": "प्रतिक्रिया सूची", "aria/Remind Me Options": "रिमाइंडर विकल्प", "aria/Remove attachment": "संलग्नक हटाएं", + "aria/Remove location attachment": "स्थान संलग्नक हटाएं", "aria/Retry upload": "अपलोड पुनः प्रयास करें", "aria/Search results": "खोज परिणाम", "aria/Search results header filter button": "खोज परिणाम हेडर फ़िल्टर बटन", @@ -184,6 +200,7 @@ "ban-command-description": "एक उपयोगकर्ता को प्रतिषेधित करें", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[पाठ]", "giphy-command-description": "चैनल पर एक क्रॉफिल जीआइएफ पोस्ट करें", "live": "लाइव", @@ -200,6 +217,7 @@ "size limit": "आकार सीमा", "this content could not be displayed": "यह कॉन्टेंट लोड नहीं हो पाया", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -231,5 +249,6 @@ "{{count}} votes_other": "{{count}} वोट", "🏙 Attachment...": "🏙 अटैचमेंट", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} ने बनाया: {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} ने वोट दिया: {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} ने वोट दिया: {{pollOptionText}}", + "📍Shared location": "📍साझा किया गया स्थान" } diff --git a/src/i18n/it.json b/src/i18n/it.json index bbceaa95fe..e26dfe6f97 100644 --- a/src/i18n/it.json +++ b/src/i18n/it.json @@ -14,6 +14,7 @@ "Anonymous poll": "Sondaggio anonimo", "Archive": "Archivia", "Ask a question": "Fai una domanda", + "Attach": "Allega", "Attach files": "Allega file", "Attachment upload blocked due to {{reason}}": "Caricamento allegato bloccato a causa di {{reason}}", "Attachment upload failed due to {{reason}}": "Caricamento allegato fallito a causa di {{reason}}", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "Errore di connessione, riconnessione in corso...", "Create": "Crea", "Create poll": "Crea sondaggio", + "Current location": "Posizione attuale", "Delete": "Elimina", "Delivered": "Consegnato", "Download attachment {{ name }}": "Scarica l'allegato {{ name }}", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "Impossibile passare al primo messaggio non letto", "Failed to mark channel as read": "Impossibile contrassegnare il canale come letto", "Failed to play the recording": "Impossibile riprodurre la registrazione", + "Failed to retrieve location": "Impossibile recuperare la posizione", + "Failed to share location": "Impossibile condividere la posizione", "File": "File", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Il file è troppo grande: {{ size }}, la dimensione massima di caricamento è {{ limit }}", "Flag": "Segnala", "Generating...": "Generando...", "Latest Messages": "Ultimi messaggi", + "Live for {{duration}}": "Live per {{duration}}", + "Live location": "Posizione live", + "Live until {{ timestamp }}": "Live fino a {{ timestamp }}", "Load more": "Carica di più", + "Location": "Posizione", + "Location sharing ended": "Condivisione posizione terminata", "Mark as unread": "Contrassegna come non letto", "Maximum number of votes (from 2 to 10)": "Numero massimo di voti (da 2 a 10)", "Menu": "Menù", @@ -121,11 +130,16 @@ "Send Anyway": "Invia comunque", "Send message request failed": "Richiesta di invio messaggio non riuscita", "Sending...": "Invio in corso...", + "Share": "Condividi", + "Share Location": "Condividi posizione", + "Share live location for": "Condividi posizione live per", + "Shared live location": "Posizione live condivisa", "Show all": "Mostra tutto", "Shuffle": "Mescolare", "Slow Mode ON": "Modalità lenta attivata", "Some of the files will not be accepted": "Alcuni dei file non saranno accettati", "Start typing to search": "Inizia a digitare per cercare", + "Stop sharing": "Ferma condivisione", "Submit": "Invia", "Suggest an option": "Suggerisci un'opzione", "Thinking...": "Pensando...", @@ -171,12 +185,14 @@ "aria/Menu": "Menu", "aria/Message Options": "Opzioni di messaggio", "aria/Open Attachment Selector": "Apri selettore allegati", + "aria/Open Menu": "Apri menu", "aria/Open Message Actions Menu": "Apri il menu delle azioni di messaggio", "aria/Open Reaction Selector": "Apri il selettore di reazione", "aria/Open Thread": "Apri discussione", "aria/Reaction list": "Elenco delle reazioni", "aria/Remind Me Options": "Opzioni promemoria", "aria/Remove attachment": "Rimuovi allegato", + "aria/Remove location attachment": "Rimuovi allegato posizione", "aria/Retry upload": "Riprova caricamento", "aria/Search results": "Risultati della ricerca", "aria/Search results header filter button": "Pulsante filtro intestazione risultati ricerca", @@ -186,6 +202,7 @@ "ban-command-description": "Vietare un utente", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[testo]", "giphy-command-description": "Pubblica un gif casuale sul canale", "live": "live", @@ -204,6 +221,7 @@ "size limit": "limite di dimensione", "this content could not be displayed": "questo contenuto non può essere mostrato", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -238,5 +256,6 @@ "{{count}} votes_other": "{{count}} voti", "🏙 Attachment...": "🏙 Allegato...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} ha creato: {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} ha votato: {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} ha votato: {{pollOptionText}}", + "📍Shared location": "📍Posizione condivisa" } diff --git a/src/i18n/ja.json b/src/i18n/ja.json index 88cace1c1c..d4a3523753 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -14,6 +14,7 @@ "Anonymous poll": "匿名投票", "Archive": "アーカイブ", "Ask a question": "質問する", + "Attach": "添付", "Attach files": "ファイルを添付する", "Attachment upload blocked due to {{reason}}": "{{reason}}のため添付ファイルのアップロードがブロックされました", "Attachment upload failed due to {{reason}}": "{{reason}}のため添付ファイルのアップロードに失敗しました", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "接続が失敗しました。再接続中...", "Create": "作成", "Create poll": "投票を作成", + "Current location": "現在の位置", "Delete": "消去", "Delivered": "配信しました", "Download attachment {{ name }}": "添付ファイル {{ name }} をダウンロード", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "最初の未読メッセージにジャンプできませんでした", "Failed to mark channel as read": "チャンネルを既読にすることができませんでした", "Failed to play the recording": "録音の再生に失敗しました", + "Failed to retrieve location": "位置情報の取得に失敗しました", + "Failed to share location": "位置情報の共有に失敗しました", "File": "ファイル", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "ファイルが大きすぎます:{{ size }}、最大アップロードサイズは{{ limit }}です", "Flag": "フラグ", "Generating...": "生成中...", "Latest Messages": "最新のメッセージ", + "Live for {{duration}}": "{{duration}}間ライブ", + "Live location": "ライブ位置情報", + "Live until {{ timestamp }}": "{{ timestamp }}までライブ", "Load more": "もっと読み込む", + "Location": "位置情報", + "Location sharing ended": "位置情報の共有が終了しました", "Mark as unread": "未読としてマーク", "Maximum number of votes (from 2 to 10)": "最大投票数(2から10まで)", "Menu": "メニュー", @@ -117,11 +126,16 @@ "Send Anyway": "とにかく送信する", "Send message request failed": "メッセージ送信リクエストが失敗しました", "Sending...": "送信中...", + "Share": "共有", + "Share Location": "位置情報を共有", + "Share live location for": "ライブ位置情報を共有", + "Shared live location": "共有されたライブ位置情報", "Show all": "すべて表示", "Shuffle": "シャッフル", "Slow Mode ON": "スローモードオン", "Some of the files will not be accepted": "一部のファイルは受け付けられません", "Start typing to search": "検索するには入力を開始してください", + "Stop sharing": "共有を停止", "Submit": "送信", "Suggest an option": "オプションを提案", "Thinking...": "考え中...", @@ -165,12 +179,14 @@ "aria/Menu": "メニュー", "aria/Message Options": "メッセージオプション", "aria/Open Attachment Selector": "添付ファイル選択を開く", + "aria/Open Menu": "メニューを開く", "aria/Open Message Actions Menu": "メッセージアクションメニューを開く", "aria/Open Reaction Selector": "リアクションセレクターを開く", "aria/Open Thread": "スレッドを開く", "aria/Reaction list": "リアクション一覧", "aria/Remind Me Options": "リマインダーオプション", "aria/Remove attachment": "添付ファイルを削除", + "aria/Remove location attachment": "位置情報の添付ファイルを削除", "aria/Retry upload": "アップロードを再試行", "aria/Search results": "検索結果", "aria/Search results header filter button": "検索結果ヘッダーフィルターボタン", @@ -180,6 +196,7 @@ "ban-command-description": "ユーザーを禁止する", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[テキスト]", "giphy-command-description": "チャンネルにランダムなGIFを投稿する", "live": "ライブ", @@ -196,6 +213,7 @@ "size limit": "サイズ制限", "this content could not be displayed": "このコンテンツは表示できませんでした", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -224,5 +242,6 @@ "{{count}} votes_other": "{{count}} 票", "🏙 Attachment...": "🏙 アタッチメント...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} が作成: {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} が投票: {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} が投票: {{pollOptionText}}", + "📍Shared location": "📍共有された位置情報" } diff --git a/src/i18n/ko.json b/src/i18n/ko.json index 93a6a81055..31a2df19ff 100644 --- a/src/i18n/ko.json +++ b/src/i18n/ko.json @@ -14,6 +14,7 @@ "Anonymous poll": "익명 투표", "Archive": "아카이브", "Ask a question": "질문하기", + "Attach": "첨부", "Attach files": "파일 첨부", "Attachment upload blocked due to {{reason}}": "{{reason}}로 인해 첨부 파일 업로드가 차단되었습니다", "Attachment upload failed due to {{reason}}": "{{reason}}로 인해 첨부 파일 업로드가 실패했습니다", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "연결 실패, 지금 다시 연결 중...", "Create": "생성", "Create poll": "투표 생성", + "Current location": "현재 위치", "Delete": "삭제", "Delivered": "배달됨", "Download attachment {{ name }}": "첨부 파일 {{ name }} 다운로드", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "첫 번째 읽지 않은 메시지로 이동하지 못했습니다", "Failed to mark channel as read": "채널을 읽음으로 표시하는 데 실패했습니다", "Failed to play the recording": "녹음을 재생하지 못했습니다", + "Failed to retrieve location": "위치를 가져오지 못했습니다", + "Failed to share location": "위치를 공유하지 못했습니다", "File": "파일", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "파일이 너무 큽니다: {{ size }}, 최대 업로드 크기는 {{ limit }}입니다", "Flag": "플래그", "Generating...": "생성 중...", "Latest Messages": "최신 메시지", + "Live for {{duration}}": "{{duration}} 동안 라이브", + "Live location": "라이브 위치", + "Live until {{ timestamp }}": "{{ timestamp }}까지 라이브", "Load more": "더 불러오기", + "Location": "위치", + "Location sharing ended": "위치 공유가 종료되었습니다", "Mark as unread": "읽지 않음으로 표시", "Maximum number of votes (from 2 to 10)": "최대 투표 수 (2에서 10까지)", "Menu": "메뉴", @@ -117,11 +126,16 @@ "Send Anyway": "어쨌든 보내기", "Send message request failed": "메시지 보내기 요청 실패", "Sending...": "배상중...", + "Share": "공유", + "Share Location": "위치 공유", + "Share live location for": "라이브 위치 공유", + "Shared live location": "공유된 라이브 위치", "Show all": "모두 보기", "Shuffle": "셔플", "Slow Mode ON": "슬로우 모드 켜짐", "Some of the files will not be accepted": "일부 파일은 허용되지 않을 수 있습니다", "Start typing to search": "검색하려면 입력을 시작하세요", + "Stop sharing": "공유 중지", "Submit": "제출", "Suggest an option": "옵션 제안", "Thinking...": "생각 중...", @@ -165,12 +179,14 @@ "aria/Menu": "메뉴", "aria/Message Options": "메시지 옵션", "aria/Open Attachment Selector": "첨부 파일 선택기 열기", + "aria/Open Menu": "메뉴 열기", "aria/Open Message Actions Menu": "메시지 액션 메뉴 열기", "aria/Open Reaction Selector": "반응 선택기 열기", "aria/Open Thread": "스레드 열기", "aria/Reaction list": "반응 목록", "aria/Remind Me Options": "알림 옵션", "aria/Remove attachment": "첨부 파일 제거", + "aria/Remove location attachment": "위치 첨부 파일 제거", "aria/Retry upload": "업로드 다시 시도", "aria/Search results": "검색 결과", "aria/Search results header filter button": "검색 결과 헤더 필터 버튼", @@ -180,6 +196,7 @@ "ban-command-description": "사용자를 차단", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[텍스트]", "giphy-command-description": "채널에 무작위 GIF 게시", "live": "라이브", @@ -196,6 +213,7 @@ "size limit": "크기 제한", "this content could not be displayed": "이 콘텐츠를 표시할 수 없습니다", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -224,5 +242,6 @@ "{{count}} votes_other": "{{count}} 투표", "🏙 Attachment...": "🏙 부착...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}}이(가) 생성함: {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}}이(가) 투표함: {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}}이(가) 투표함: {{pollOptionText}}", + "📍Shared location": "📍공유된 위치" } diff --git a/src/i18n/nl.json b/src/i18n/nl.json index c38445be61..b7dece4e6b 100644 --- a/src/i18n/nl.json +++ b/src/i18n/nl.json @@ -14,6 +14,7 @@ "Anonymous poll": "Anonieme peiling", "Archive": "Archief", "Ask a question": "Stel een vraag", + "Attach": "Bijvoegen", "Attach files": "Bijlage toevoegen", "Attachment upload blocked due to {{reason}}": "Bijlage upload geblokkeerd vanwege {{reason}}", "Attachment upload failed due to {{reason}}": "Bijlage upload mislukt vanwege {{reason}}", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "Verbindingsfout, opnieuw verbinden...", "Create": "Maak", "Create poll": "Maak peiling", + "Current location": "Huidige locatie", "Delete": "Verwijder", "Delivered": "Afgeleverd", "Download attachment {{ name }}": "Bijlage {{ name }} downloaden", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "Niet gelukt om naar het eerste ongelezen bericht te springen", "Failed to mark channel as read": "Kanaal kon niet als gelezen worden gemarkeerd", "Failed to play the recording": "Kan de opname niet afspelen", + "Failed to retrieve location": "Locatie kon niet worden opgehaald", + "Failed to share location": "Locatie kon niet worden gedeeld", "File": "Bestand", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Bestand is te groot: {{ size }}, maximale uploadgrootte is {{ limit }}", "Flag": "Markeer", "Generating...": "Genereren...", "Latest Messages": "Laatste berichten", + "Live for {{duration}}": "Live voor {{duration}}", + "Live location": "Live locatie", + "Live until {{ timestamp }}": "Live tot {{ timestamp }}", "Load more": "Meer laden", + "Location": "Locatie", + "Location sharing ended": "Locatie delen beëindigd", "Mark as unread": "Markeren als ongelezen", "Maximum number of votes (from 2 to 10)": "Maximaal aantal stemmen (van 2 tot 10)", "Menu": "Menu", @@ -119,11 +128,16 @@ "Send Anyway": "Toch versturen", "Send message request failed": "Verzoek om bericht te verzenden mislukt", "Sending...": "Aan het verzenden...", + "Share": "Delen", + "Share Location": "Locatie delen", + "Share live location for": "Live locatie delen voor", + "Shared live location": "Gedeelde live locatie", "Show all": "Toon alles", "Shuffle": "Schudden", "Slow Mode ON": "Langzame modus aan", "Some of the files will not be accepted": "Sommige bestanden zullen niet worden geaccepteerd", "Start typing to search": "Begin met typen om te zoeken", + "Stop sharing": "Delen stoppen", "Submit": "Versturen", "Suggest an option": "Stel een optie voor", "Thinking...": "Denken...", @@ -168,12 +182,14 @@ "aria/Menu": "Menu", "aria/Message Options": "Berichtopties", "aria/Open Attachment Selector": "Open bijlage selector", + "aria/Open Menu": "Menu openen", "aria/Open Message Actions Menu": "Menu voor berichtacties openen", "aria/Open Reaction Selector": "Reactiekiezer openen", "aria/Open Thread": "Draad openen", "aria/Reaction list": "Reactielijst", "aria/Remind Me Options": "Herinneringsopties", "aria/Remove attachment": "Bijlage verwijderen", + "aria/Remove location attachment": "Locatie bijlage verwijderen", "aria/Retry upload": "Upload opnieuw proberen", "aria/Search results": "Zoekresultaten", "aria/Search results header filter button": "Zoekresultaten header filter knop", @@ -183,6 +199,7 @@ "ban-command-description": "Een gebruiker verbannen", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[tekst]", "giphy-command-description": "Plaats een willekeurige gif in het kanaal", "live": "live", @@ -199,6 +216,7 @@ "size limit": "grootte limiet", "this content could not be displayed": "Deze inhoud kan niet weergegeven worden", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -230,5 +248,6 @@ "{{count}} votes_other": "{{count}} stemmen", "🏙 Attachment...": "🏙 Bijlage...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} heeft gemaakt: {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} heeft gestemd: {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} heeft gestemd: {{pollOptionText}}", + "📍Shared location": "📍Gedeelde locatie" } diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 0c7e002b52..eca0781c9a 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -14,6 +14,7 @@ "Anonymous poll": "Enquete anônima", "Archive": "Arquivar", "Ask a question": "Faça uma pergunta", + "Attach": "Anexar", "Attach files": "Anexar arquivos", "Attachment upload blocked due to {{reason}}": "Upload de anexo bloqueado devido a {{reason}}", "Attachment upload failed due to {{reason}}": "Upload de anexo falhou devido a {{reason}}", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "Falha de conexão, reconectando agora...", "Create": "Criar", "Create poll": "Criar enquete", + "Current location": "Localização atual", "Delete": "Excluir", "Delivered": "Entregue", "Download attachment {{ name }}": "Baixar anexo {{ name }}", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "Falha ao pular para a primeira mensagem não lida", "Failed to mark channel as read": "Falha ao marcar o canal como lido", "Failed to play the recording": "Falha ao reproduzir a gravação", + "Failed to retrieve location": "Falha ao obter localização", + "Failed to share location": "Falha ao compartilhar localização", "File": "Arquivo", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "O arquivo é muito grande: {{ size }}, o tamanho máximo de upload é {{ limit }}", "Flag": "Reportar", "Generating...": "Gerando...", "Latest Messages": "Mensagens mais recentes", + "Live for {{duration}}": "Ao vivo por {{duration}}", + "Live location": "Localização ao vivo", + "Live until {{ timestamp }}": "Ao vivo até {{ timestamp }}", "Load more": "Carregar mais", + "Location": "Localização", + "Location sharing ended": "Compartilhamento de localização encerrado", "Mark as unread": "Marcar como não lida", "Maximum number of votes (from 2 to 10)": "Número máximo de votos (de 2 a 10)", "Menu": "Menu", @@ -121,11 +130,16 @@ "Send Anyway": "Enviar de qualquer forma", "Send message request failed": "O pedido de envio da mensagem falhou", "Sending...": "Enviando...", + "Share": "Compartilhar", + "Share Location": "Compartilhar localização", + "Share live location for": "Compartilhar localização ao vivo por", + "Shared live location": "Localização ao vivo compartilhada", "Show all": "Mostrar tudo", "Shuffle": "Embaralhar", "Slow Mode ON": "Modo lento LIGADO", "Some of the files will not be accepted": "Alguns arquivos não serão aceitos", "Start typing to search": "Comece a digitar para pesquisar", + "Stop sharing": "Parar de compartilhar", "Submit": "Enviar", "Suggest an option": "Sugerir uma opção", "Thinking...": "Pensando...", @@ -171,12 +185,14 @@ "aria/Menu": "Menu", "aria/Message Options": "Opções de mensagem", "aria/Open Attachment Selector": "Abrir seletor de anexos", + "aria/Open Menu": "Abrir menu", "aria/Open Message Actions Menu": "Abrir menu de ações de mensagem", "aria/Open Reaction Selector": "Abrir seletor de reações", "aria/Open Thread": "Abrir tópico", "aria/Reaction list": "Lista de reações", "aria/Remind Me Options": "Opções de lembrete", "aria/Remove attachment": "Remover anexo", + "aria/Remove location attachment": "Remover anexo de localização", "aria/Retry upload": "Tentar upload novamente", "aria/Search results": "Resultados da pesquisa", "aria/Search results header filter button": "Botão de filtro do cabeçalho dos resultados da pesquisa", @@ -186,6 +202,7 @@ "ban-command-description": "Banir um usuário", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[texto]", "giphy-command-description": "Postar um gif aleatório no canal", "live": "ao vivo", @@ -204,6 +221,7 @@ "size limit": "limite de tamanho", "this content could not be displayed": "este conteúdo não pôde ser exibido", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -238,5 +256,6 @@ "{{count}} votes_other": "{{count}} votos", "🏙 Attachment...": "🏙 Anexo...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} criou: {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} votou: {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} votou: {{pollOptionText}}", + "📍Shared location": "📍Localização compartilhada" } diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 1281dcd43d..54094c3ecd 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -14,6 +14,7 @@ "Anonymous poll": "Анонимный опрос", "Archive": "Aрхивировать", "Ask a question": "Задать вопрос", + "Attach": "Прикрепить", "Attach files": "Прикрепить файлы", "Attachment upload blocked due to {{reason}}": "Загрузка вложения заблокирована из-за {{reason}}", "Attachment upload failed due to {{reason}}": "Загрузка вложения не удалась из-за {{reason}}", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "Ошибка соединения, переподключение...", "Create": "Создать", "Create poll": "Создать опрос", + "Current location": "Текущее местоположение", "Delete": "Удалить", "Delivered": "Отправлено", "Download attachment {{ name }}": "Скачать вложение {{ name }}", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "Не удалось перейти к первому непрочитанному сообщению", "Failed to mark channel as read": "Не удалось пометить канал как прочитанный", "Failed to play the recording": "Не удалось воспроизвести запись", + "Failed to retrieve location": "Не удалось получить местоположение", + "Failed to share location": "Не удалось поделиться местоположением", "File": "Файл", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Файл слишком большой: {{ size }}, максимальный размер загрузки составляет {{ limit }}", "Flag": "Пожаловаться", "Generating...": "Генерирую...", "Latest Messages": "Последние сообщения", + "Live for {{duration}}": "В прямом эфире {{duration}}", + "Live location": "Местоположение в прямом эфире", + "Live until {{ timestamp }}": "В прямом эфире до {{ timestamp }}", "Load more": "Загрузить больше", + "Location": "Местоположение", + "Location sharing ended": "Обмен местоположением завершен", "Mark as unread": "Отметить как непрочитанное", "Maximum number of votes (from 2 to 10)": "Максимальное количество голосов (от 2 до 10)", "Menu": "Меню", @@ -123,11 +132,16 @@ "Send Anyway": "Мне всё равно, отправить", "Send message request failed": "Не удалось отправить запрос на отправку сообщения", "Sending...": "Отправка...", + "Share": "Поделиться", + "Share Location": "Поделиться местоположением", + "Share live location for": "Поделиться местоположением в прямом эфире на", + "Shared live location": "Общее местоположение в прямом эфире", "Show all": "Показать все", "Shuffle": "Перемешать", "Slow Mode ON": "Медленный режим включен", "Some of the files will not be accepted": "Некоторые файлы не будут приняты", "Start typing to search": "Начните вводить для поиска", + "Stop sharing": "Прекратить делиться", "Submit": "Отправить", "Suggest an option": "Предложить вариант", "Thinking...": "Думаю...", @@ -174,12 +188,14 @@ "aria/Menu": "Меню", "aria/Message Options": "Параметры сообщения", "aria/Open Attachment Selector": "Открыть выбор вложений", + "aria/Open Menu": "Открыть меню", "aria/Open Message Actions Menu": "Открыть меню действий с сообщениями", "aria/Open Reaction Selector": "Открыть селектор реакций", "aria/Open Thread": "Открыть тему", "aria/Reaction list": "Список реакций", "aria/Remind Me Options": "Параметры напоминания", "aria/Remove attachment": "Удалить вложение", + "aria/Remove location attachment": "Удалить вложение местоположения", "aria/Retry upload": "Повторить загрузку", "aria/Search results": "Результаты поиска", "aria/Search results header filter button": "Кнопка фильтра заголовка результатов поиска", @@ -189,6 +205,7 @@ "ban-command-description": "Заблокировать пользователя", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[текст]", "giphy-command-description": "Опубликовать случайную GIF-анимацию в канале", "live": "В прямом эфире", @@ -209,6 +226,7 @@ "size limit": "ограничение размера", "this content could not be displayed": "Этот контент не может быть отображен в данный момент", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -246,5 +264,6 @@ "{{count}} votes_other": "{{count}} голосов", "🏙 Attachment...": "🏙 Вложение...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} создал(а): {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} проголосовал(а): {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} проголосовал(а): {{pollOptionText}}", + "📍Shared location": "📍Общее местоположение" } diff --git a/src/i18n/tr.json b/src/i18n/tr.json index b61f2274b2..446bf53349 100644 --- a/src/i18n/tr.json +++ b/src/i18n/tr.json @@ -14,6 +14,7 @@ "Anonymous poll": "Anonim anket", "Archive": "Arşivle", "Ask a question": "Bir soru sor", + "Attach": "Ekle", "Attach files": "Dosya ekle", "Attachment upload blocked due to {{reason}}": "{{reason}} nedeniyle ek yükleme engellendi", "Attachment upload failed due to {{reason}}": "{{reason}} nedeniyle ek yükleme başarısız oldu", @@ -26,6 +27,7 @@ "Connection failure, reconnecting now...": "Bağlantı hatası, tekrar bağlanılıyor...", "Create": "Oluştur", "Create poll": "Anket oluştur", + "Current location": "Mevcut konum", "Delete": "Sil", "Delivered": "İletildi", "Download attachment {{ name }}": "Ek {{ name }}'i indir", @@ -61,12 +63,19 @@ "Failed to jump to the first unread message": "İlk okunmamış mesaja atlamada hata oluştu", "Failed to mark channel as read": "Kanalı okundu olarak işaretleme başarısız oldu", "Failed to play the recording": "Kayıt oynatılamadı", + "Failed to retrieve location": "Konum alınamadı", + "Failed to share location": "Konum paylaşılamadı", "File": "Dosya", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Dosya çok büyük: {{ size }}, maksimum yükleme boyutu {{ limit }}", "Flag": "Bayrak", "Generating...": "Oluşturuluyor...", "Latest Messages": "Son Mesajlar", + "Live for {{duration}}": "{{duration}} boyunca canlı", + "Live location": "Canlı konum", + "Live until {{ timestamp }}": "{{ timestamp }}'e kadar canlı", "Load more": "Daha fazla yükle", + "Location": "Konum", + "Location sharing ended": "Konum paylaşımı sona erdi", "Mark as unread": "Okunmamış olarak işaretle", "Maximum number of votes (from 2 to 10)": "Maksimum oy sayısı (2 ile 10 arası)", "Menu": "Menü", @@ -119,11 +128,16 @@ "Send Anyway": "Yine de gönder", "Send message request failed": "Mesaj gönderme isteği başarısız oldu", "Sending...": "Gönderiliyor...", + "Share": "Paylaş", + "Share Location": "Konum Paylaş", + "Share live location for": "Canlı konum paylaş", + "Shared live location": "Paylaşılan canlı konum", "Show all": "Tümünü göster", "Shuffle": "Karıştır", "Slow Mode ON": "Yavaş Mod Açık", "Some of the files will not be accepted": "Bazı dosyalar kabul edilmeyecek", "Start typing to search": "Aramak için yazmaya başlayın", + "Stop sharing": "Paylaşımı durdur", "Submit": "Gönder", "Suggest an option": "Bir seçenek önerin", "Thinking...": "Düşünüyor...", @@ -168,12 +182,14 @@ "aria/Menu": "Menü", "aria/Message Options": "Mesaj Seçenekleri", "aria/Open Attachment Selector": "Ek Seçiciyi Aç", + "aria/Open Menu": "Menüyü Aç", "aria/Open Message Actions Menu": "Mesaj İşlemleri Menüsünü Aç", "aria/Open Reaction Selector": "Tepki Seçiciyi Aç", "aria/Open Thread": "Konuyu Aç", "aria/Reaction list": "Tepki listesi", "aria/Remind Me Options": "Hatırlatma seçenekleri", "aria/Remove attachment": "Eki kaldır", + "aria/Remove location attachment": "Konum ekini kaldır", "aria/Retry upload": "Yüklemeyi Tekrar Dene", "aria/Search results": "Arama sonuçları", "aria/Search results header filter button": "Arama sonuçları başlık filtre düğmesi", @@ -183,6 +199,7 @@ "ban-command-description": "Bir kullanıcıyı yasakla", "duration/Message reminder": "{{ milliseconds | durationFormatter(withSuffix: true) }}", "duration/Remind Me": "{{ milliseconds | durationFormatter(withSuffix: true) }}", + "duration/Share Location": "{{ milliseconds | durationFormatter }}", "giphy-command-args": "[metin]", "giphy-command-description": "Rastgele bir gif'i kanala gönder", "live": "canlı", @@ -199,6 +216,7 @@ "size limit": "boyut sınırı", "this content could not be displayed": "bu içerik gösterilemiyor", "timestamp/DateSeparator": "{{ timestamp | timestampFormatter(calendar: true) }}", + "timestamp/LiveLocation": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/MessageTimestamp": "{{ timestamp | timestampFormatter(calendar: true) }}", "timestamp/PollVote": "{{ timestamp | timestampFormatter(format: MMM D [at] HH:mm) }}", "timestamp/PollVoteTooltip": "{{ timestamp | timestampFormatter(calendar: true) }}", @@ -230,5 +248,6 @@ "{{count}} votes_other": "{{count}} oy", "🏙 Attachment...": "🏙 Ek...", "📊 {{createdBy}} created: {{ pollName}}": "📊 {{createdBy}} oluşturdu: {{ pollName}}", - "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} oy verdi: {{pollOptionText}}" + "📊 {{votedBy}} voted: {{pollOptionText}}": "📊 {{votedBy}} oy verdi: {{pollOptionText}}", + "📍Shared location": "📍Paylaşılan konum" } diff --git a/src/mock-builders/generator/channel.ts b/src/mock-builders/generator/channel.ts index 9deaa45994..49cd09d8d6 100644 --- a/src/mock-builders/generator/channel.ts +++ b/src/mock-builders/generator/channel.ts @@ -7,11 +7,11 @@ export const generateChannel = ( config?: Partial; } = { channel: {}, config: {} }, ) => { - const { channel: optionsChannel, config, ...optionsBesidesChannel } = options; + const { channel: optionsChannel, ...optionsBesidesChannel } = options; const id = optionsChannel?.id ?? nanoid(); const type = optionsChannel?.type ?? 'messaging'; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id: _, type: __, ...restOptionsChannel } = optionsChannel ?? {}; + const { config, id: _, type: __, ...restOptionsChannel } = optionsChannel ?? {}; return { members: [], diff --git a/src/mock-builders/generator/index.js b/src/mock-builders/generator/index.js index 956f847972..fad35360e9 100644 --- a/src/mock-builders/generator/index.js +++ b/src/mock-builders/generator/index.js @@ -2,6 +2,9 @@ export * from './attachment'; export * from './channel'; export * from './member'; export * from './message'; +export * from './messageDraft'; export * from './poll'; export * from './reaction'; +export * from './reminder'; +export * from './sharedLocation'; export * from './user'; diff --git a/src/mock-builders/generator/sharedLocation.ts b/src/mock-builders/generator/sharedLocation.ts new file mode 100644 index 0000000000..81b38f8567 --- /dev/null +++ b/src/mock-builders/generator/sharedLocation.ts @@ -0,0 +1,33 @@ +import type { + SharedLiveLocationResponse, + SharedStaticLocationResponse, +} from 'stream-chat'; + +export const generateStaticLocationResponse = ( + data: Partial, +): SharedStaticLocationResponse => ({ + channel_cid: 'channel_cid', + created_at: '1970-01-01T00:00:00.000Z', + created_by_device_id: 'created_by_device_id', + latitude: 1, + longitude: 1, + message_id: 'message_id', + updated_at: '1970-01-01T00:00:00.000Z', + user_id: 'user_id', + ...data, +}); + +export const generateLiveLocationResponse = ( + data: Partial, +): SharedLiveLocationResponse => ({ + channel_cid: 'channel_cid', + created_at: '1970-01-01T00:00:00.000Z', + created_by_device_id: 'created_by_device_id', + end_at: '9999-01-01T00:00:00.000Z', + latitude: 1, + longitude: 1, + message_id: 'message_id', + updated_at: '1970-01-01T00:00:00.000Z', + user_id: 'user_id', + ...data, +}); diff --git a/yarn.lock b/yarn.lock index 88308a04de..95c5c4c38b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2570,10 +2570,10 @@ resolved "https://registry.yarnpkg.com/@stream-io/escape-string-regexp/-/escape-string-regexp-5.0.1.tgz#362505c92799fea6afe4e369993fbbda8690cc37" integrity sha512-qIaSrzJXieZqo2fZSYTdzwSbZgHHsT3tkd646vvZhh4fr+9nO4NlvqGmPF43Y+OfZiWf+zYDFgNiPGG5+iZulQ== -"@stream-io/stream-chat-css@^5.11.1": - version "5.11.1" - resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.11.1.tgz#9e76b8fa059fd114b36b4b75aed3eae4a98111cb" - integrity sha512-i0/c0lHPAHuUwsiK3XDhgomSGUp/VgPEXFdcNM1Mks7nPlHvpVexOga3JtLqXuh2bf7wbza8JS3CHXVON6WRtw== +"@stream-io/stream-chat-css@^5.11.2": + version "5.11.2" + resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.11.2.tgz#cd7994c58cfb676ed83967e2f1afbcef49ad94c6" + integrity sha512-8uJppnqoplYryRxPI/0oQrFjNlF1RvTueC8o7pJPnjcPCrEKmwIv9UqwTX19ghK+5445dgBXEyX939vzn4VSiA== "@stream-io/transliterate@^1.5.5": version "1.5.5" @@ -12036,10 +12036,10 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -stream-chat@^9.10.1: - version "9.10.1" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.10.1.tgz#d460faeacfc55159bce0caf16067d4dec344b474" - integrity sha512-JwiM/iqaU16SlyG90eAezayKGnVmhNjrPfRKATlriwvGEaO8kgZfCUg5It4hIqXgdIAHFbrwHF6OHexWVXpY8w== +stream-chat@^9.12.0: + version "9.12.0" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.12.0.tgz#d27b1319844d100ca419c61463f5c65f53c87952" + integrity sha512-/A4y8jBmWdP53RUY9f8dlc8dRjC/irR5KUMiOhn0IiAEmK2fKuD/7IpUzXi7cNmR8QMxlHdDMJZdB2wMDkiskQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14"