diff --git a/.github/actions/android-sdk-packages/action.yml b/.github/actions/android-sdk-packages/action.yml new file mode 100644 index 0000000000..a65482a565 --- /dev/null +++ b/.github/actions/android-sdk-packages/action.yml @@ -0,0 +1,29 @@ +name: Android SDK packages +description: >- + Installs the SDK packages reactivecircus/android-emulator-runner would download, with retries. + The action downloads the emulator and the system image on every run and doesn't retry: a + corrupted download ("Error reading Zip content from a SeekableByteChannel") fails the job before + the emulator boots. sdkmanager skips packages that are installed and current, so the action's own + install becomes a no-op. +inputs: + api-level: + description: The system image API level, as passed to android-emulator-runner + required: true +runs: + using: composite + steps: + - name: Install the emulator and the system image + shell: bash + env: + API_LEVEL: ${{ inputs.api-level }} + run: | + sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + packages=(emulator platform-tools "platforms;android-$API_LEVEL" "system-images;android-$API_LEVEL;default;x86_64") + yes | "$sdkmanager" --licenses > /dev/null || true + for attempt in 1 2 3; do + "$sdkmanager" --install "${packages[@]}" --channel=0 > /dev/null && exit 0 + echo "::warning::sdkmanager failed (attempt $attempt of 3); retrying in 30 s" + sleep 30 + done + echo "::error::Could not install ${packages[*]}" + exit 1 diff --git a/.github/workflows/e2e-test-cron.yml b/.github/workflows/e2e-test-cron.yml index 8af4ead87a..bf6ce67f9b 100644 --- a/.github/workflows/e2e-test-cron.yml +++ b/.github/workflows/e2e-test-cron.yml @@ -352,6 +352,10 @@ jobs: working-directory: examples/SampleApp run: bundle exec fastlane allure_launch cron:true continue-on-error: true + # The emulator runner downloads these without retrying; a corrupted download fails the job. + - uses: ./.github/actions/android-sdk-packages + with: + api-level: ${{ matrix.api-level }} - name: Run e2e tests (Android emulator) id: tests uses: reactivecircus/android-emulator-runner@v2 @@ -362,7 +366,10 @@ jobs: emulator-options: ${{ env.ANDROID_EMULATOR_OPTIONS }} disable-animations: true working-directory: examples/SampleApp - script: bundle exec fastlane test_e2e platform:android device:att skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" coverage:${{ matrix.coverage == true }} batch:${{ matrix.batch }} batch_count:${{ matrix.batch_count }} + # Logcat is written on the runner, so it survives the emulator dying mid-run (RN-513). + script: | + nohup "$ANDROID_HOME/platform-tools/adb" -s emulator-5554 logcat -b all -v threadtime > "$RUNNER_TEMP/logcat.txt" 2>&1 < /dev/null & + bundle exec fastlane test_e2e platform:android device:att skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH" coverage:${{ matrix.coverage == true }} batch:${{ matrix.batch }} batch_count:${{ matrix.batch_count }} # Also when tests failed: those keep their coverage from the previous map. Not when they never # ran: a batch without its failed-tests file keeps the whole previous map. @@ -407,6 +414,7 @@ jobs: examples/E2ETestApp/stream-chat-test-mock-server/logs examples/E2ETestApp/artifacts /tmp/android-runner + ${{ runner.temp }}/logcat.txt coverage_map: name: Build e2e coverage map diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 4a7af02577..398fe3e59b 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -386,6 +386,10 @@ jobs: with: name: e2e-selection path: examples/E2ETestApp + # The emulator runner downloads these without retrying; a corrupted download fails the job. + - uses: ./.github/actions/android-sdk-packages + with: + api-level: ${{ env.ANDROID_API_LEVEL }} - name: Run e2e tests (Android emulator) uses: reactivecircus/android-emulator-runner@v2 with: diff --git a/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml b/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml index dcd82215a6..32f558c6ef 100644 --- a/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml +++ b/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ + }; +/** A port free on the host's IPv4 and IPv6 loopback: `::` listens on both (dual stack). */ +const freePort = () => + new Promise((resolve, reject) => { + const server = createServer(); + server.once('error', reject); + server.listen({ host: '::', port: 0 }, () => { + const address = server.address(); + server.close(() => + address && typeof address === 'object' + ? resolve(address.port) + : reject(new Error('No free port')), + ); + }); + }); + /** * Client for `stream-chat-test-mock-server`. The driver (`driver.rb`) spawns one mock server per * test on its own port; `start` asks it for one and `stop` shuts it down. @@ -27,13 +44,32 @@ export class MockServer { return this.url.replace('localhost', host); } - /** Never retried: every call to the driver's `/start` spawns a new server on a new port. */ - async start(testName: string): Promise { - const response = await this.execute( - `${MockServer.driverUrl}/start/${encodeURIComponent(testName)}?api_version=v2×tamps=ns`, - ); - this.port = (await response.text()).trim(); - await this.waitUntilReady(); + /** + * Every call to the driver's `/start` spawns a new server, so a start is only retried after the + * server failed to answer, and always on a new port. + * + * The port is picked here rather than by the driver: on CI the driver's probe twice handed out a + * port the server then never answered on (41215, an hour apart, nightly 43138494994). This probe + * also checks IPv6, which `localhost` may resolve to. + */ + async start(testName: string, attempts = 3): Promise { + for (let attempt = 1; ; attempt++) { + const port = await freePort(); + const response = await this.execute( + `${MockServer.driverUrl}/start/${encodeURIComponent(testName)}` + + `?api_version=v2×tamps=ns&port=${port}`, + ); + this.port = (await response.text()).trim(); + try { + await this.waitUntilReady(); + return; + } catch (error) { + await this.stop(); + if (attempt >= attempts) { + throw error; + } + } + } } async stop(): Promise { diff --git a/examples/E2ETestApp/e2e/robots/UserRobot.ts b/examples/E2ETestApp/e2e/robots/UserRobot.ts index 3f1a115e4c..9eea84db68 100644 --- a/examples/E2ETestApp/e2e/robots/UserRobot.ts +++ b/examples/E2ETestApp/e2e/robots/UserRobot.ts @@ -57,9 +57,10 @@ const tapInContextMenu = async (selector: Detox.NativeMatcher) => { }; /** - * Long-presses a message until its menu opens. iOS runs without Detox's idle sync, so a long press - * that lands while the list is still settling after a scroll is cancelled by the movement, and the - * menu never opens. `target` is re-resolved on each attempt, since the frames may have moved. + * Long-presses a message until its menu opens. A long press that lands while the list is still + * settling after a scroll is cancelled by the movement, and the menu never opens: on iOS, which runs + * without Detox's idle sync, and now and then on Android too. `target` is re-resolved on each + * attempt, since the frames may have moved. */ const longPressUntilMenuOpens = async ( target: () => Promise<{ index: number; selector: Detox.NativeMatcher }>, @@ -68,11 +69,7 @@ const longPressUntilMenuOpens = async ( for (let attempt = 1; ; attempt++) { const { index, selector } = await target(); await waitToAppearAndLongPress(selector, { index }); - if ( - device.getPlatform() !== 'ios' || - attempt >= attempts || - (await waitForExistence(ContextMenu.backdrop, { timeout: 3000 })) - ) { + if (attempt >= attempts || (await waitForExistence(ContextMenu.backdrop, { timeout: 3000 }))) { return; } } @@ -353,6 +350,12 @@ export class UserRobot extends Robot { .perform(async () => { await (await waitToAppearAndClick(CreatePoll.fields, { index: 0 })).replaceText(question); for (const [index, option] of options.entries()) { + if (device.getPlatform() === 'android') { + // The keyboard, often already open from the composer, covers the option field that + // appears below the last filled one, so it never counts as visible. A tap outside + // the fields hides it (see below). + await element(CreatePoll.questionLabel).tap(); + } await ( await waitToAppearAndClick(CreatePoll.fields, { index: index + 1 }) ).replaceText(option); diff --git a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts index 278e33824c..aece96ded9 100644 --- a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts +++ b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts @@ -104,6 +104,31 @@ declare module './UserRobot' { } } +/** + * Waits until `selector` has been hidden for `stableMs` in a row. A single check can land before an + * element briefly shows: on iOS 17 the scroll-to-bottom button flashes while the list catches up + * with a message that just arrived, so "hidden" read once was a coin toss (nightly 43138494994). + */ +const waitUntilStaysHidden = async ( + selector: Detox.NativeMatcher, + timeout: number, + stableMs = 1000, +) => { + const deadline = Date.now() + timeout; + let hiddenSince: number | undefined; + while (Date.now() < deadline) { + if (await isDisplayed(selector, 0)) { + hiddenSince = undefined; + } else { + hiddenSince ??= Date.now(); + if (Date.now() - hiddenSince >= stableMs) { + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } +}; + export const assertVisibility = async ( selector: Detox.NativeMatcher, shouldBeDisplayed: boolean, @@ -123,6 +148,7 @@ export const assertVisibility = async ( } } else { await waitToDisappear(selector, { index: 0, timeout }); + await waitUntilStaysHidden(selector, timeout); jestExpect(await isDisplayed(selector, 0)).toBe(false); } }; diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index f9d9921325..f5585d280b 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -56,63 +56,51 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW // `created_at` is a numeric wire timestamp, directly comparable (`new Date(ns)` yields NaN). const createdAtTimestamp = message.created_at; const messageId = message.id; - const nextMessageId = nextMessage?.id; - const nextMessageIsOwn = nextMessage?.user?.id === client.userId; - const nextMessageCreatedAt = nextMessage?.created_at ?? undefined; + const isOwnMessage = message.user?.id === client.userId; + const previousMessageId = previousMessage?.id; + const previousMessageCreatedAt = previousMessage?.created_at ?? undefined; const isThreadList = !!messagePaginator.parentMessageId; - // The unread separator goes on the row above the first unread message from another user. It is - // located per message inside the selector, so the flag stays `false` for every other row and a - // mark-read only re-renders the boundary rows whose flag flips, not the whole list. + // The unread separator sits on the first unread row, between its date separator and the message. + // Resolved per row in the selector, so only the boundary rows re-render when it moves. const showUnreadSeparatorSelector = useCallback( (snapshot: UnreadSnapshotState) => { let showUnreadSeparator: boolean; - if (isThreadList) { + if (isThreadList || !previousMessageId) { showUnreadSeparator = false; } else if (snapshot.firstUnreadMessageId) { - // Frozen boundary (channel open or mark-unread): anchor to the known first unread ID. - showUnreadSeparator = nextMessageId === snapshot.firstUnreadMessageId; + // Channel open and mark-unread freeze the boundary at a known first unread ID. + showUnreadSeparator = messageId === snapshot.firstUnreadMessageId; } else if (snapshot.unreadCount) { - // Live boundary: the separator goes on the last read row (created at or before - // `lastReadAt`) whose newer neighbor is the first unread from another user. Requiring this - // row to be read keeps it to one separator when own messages are interleaved among the - // unreads, as an own message sent after the boundary is not read. + // The live boundary is the first unread from another user whose previous message is read. + // Own messages among the unreads are not read, so this matches only once. const lastReadAt = snapshot.lastReadAt ?? 0; - const nextIsUnreadFromOther = - !!nextMessageId && - !nextMessageIsOwn && - nextMessageCreatedAt !== undefined && - nextMessageCreatedAt > lastReadAt; - const thisIsRead = - typeof createdAtTimestamp === 'number' && createdAtTimestamp <= lastReadAt; - // Own messages sent elsewhere after the boundary leave no read row above the first unread, - // so the rule above never fires. Anchor on the last read message then. - const isLastReadBeforeOwnUnread = - thisIsRead && - messageId === snapshot.lastReadMessageId && - nextMessageIsOwn && - nextMessageCreatedAt !== undefined && - nextMessageCreatedAt > lastReadAt; - showUnreadSeparator = (nextIsUnreadFromOther && thisIsRead) || isLastReadBeforeOwnUnread; + const thisIsUnread = + typeof createdAtTimestamp === 'number' && createdAtTimestamp > lastReadAt; + const previousIsRead = + previousMessageCreatedAt !== undefined && previousMessageCreatedAt <= lastReadAt; + // When own messages follow the last read one, anchor on the last read message instead. + const isOwnUnreadAfterLastRead = + isOwnMessage && previousMessageId === snapshot.lastReadMessageId; + showUnreadSeparator = + thisIsUnread && previousIsRead && (!isOwnMessage || isOwnUnreadAfterLastRead); } else { showUnreadSeparator = false; } return { showUnreadSeparator, - // Only the boundary row needs the unread count (for its inline indicator). Gating it on - // `showUnreadSeparator` keeps it `undefined` elsewhere, so a changing channel-wide count - // does not re-render the whole list. + // Only the boundary row reads the count, so count changes don't re-render the other rows. unreadCount: showUnreadSeparator ? snapshot.unreadCount : undefined, }; }, [ createdAtTimestamp, + isOwnMessage, isThreadList, messageId, - nextMessageCreatedAt, - nextMessageId, - nextMessageIsOwn, + previousMessageCreatedAt, + previousMessageId, ], ); const { showUnreadSeparator, unreadCount } = useStateStore( @@ -135,6 +123,11 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW ) : null; + const renderUnreadIndicator = showUnreadUnderlay ? ( + + + + ) : null; const renderMessage = ( {allowDateSeparatorForSystemMessages ? renderDateSeperator : null} + {renderUnreadIndicator} ) : wrapMessageInTheme ? ( {renderDateSeperator} + {renderUnreadIndicator} {renderMessage} ) : ( <> {renderDateSeperator} + {renderUnreadIndicator} {renderMessage} )} - {showUnreadUnderlay && ( - - - - )} ); }); diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx index 0f1053a760..0556e70e2f 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx @@ -652,5 +652,29 @@ describe('AttachmentUploadPreviewList', () => { expect(screen.queryAllByTestId('image-attachment-upload-preview')).toHaveLength(1); }); }); + + it('shows the duration a sent video carries in custom.duration', async () => { + const custom = { duration: 125000, mime_type: 'video/mp4' }; + + act(() => { + channel.messageComposer.initState({ + composition: generateMessage({ + attachments: [ + generateVideoAttachmentBase({ + asset_url: 'https://video-attachment.mp4', + custom, + thumb_url: 'https://video-attachment-thumb.png', + }), + ], + }), + }); + }); + + renderComponent({ channel, client, props: {} }); + + await waitFor(() => { + expect(screen.getByText('2m')).toBeTruthy(); + }); + }); }); }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx index 7a74540a12..77e862d119 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/VideoAttachmentUploadPreview.tsx @@ -29,7 +29,13 @@ export const VideoAttachmentUploadPreview = ({ // `localMetadata.file` is `File | FileReference`; only the RN `FileReference` carries a // `duration` (and a required `uri` to discriminate on), so read it through a guard. const localFile = attachment.localMetadata.file; - const duration = localFile && 'uri' in localFile ? localFile.duration : undefined; + const { custom } = attachment; + const duration = + localFile && 'uri' in localFile + ? localFile.duration + : custom && 'duration' in custom && typeof custom.duration === 'number' + ? custom.duration + : undefined; return previewUri ? ( <> diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index 406127e83f..4ff9472703 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -564,7 +564,7 @@ describe('MessageList', () => { ); }; - it('should render the InlineUnreadIndicator after the last read message when own messages sent elsewhere follow it', async () => { + it('should render the InlineUnreadIndicator above the first unread message when own messages sent elsewhere follow the last read one', async () => { const { getAllByLabelText, getByTestId } = await renderWithUnreadAfterOwn([ 'own', 'other', @@ -574,7 +574,7 @@ describe('MessageList', () => { await waitFor(() => { expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); expect( - within(getByTestId('message-list-item-5')).queryByLabelText('Inline unread indicator'), + within(getByTestId('message-list-item-6')).queryByLabelText('Inline unread indicator'), ).toBeTruthy(); }); }); @@ -589,11 +589,64 @@ describe('MessageList', () => { await waitFor(() => { expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); expect( - within(getByTestId('message-list-item-5')).queryByLabelText('Inline unread indicator'), + within(getByTestId('message-list-item-6')).queryByLabelText('Inline unread indicator'), ).toBeTruthy(); }); }); + it('should render the InlineUnreadIndicator below the date separator of the first unread message', async () => { + const user1 = generateUser(); + const user2 = generateUser(); + // Messages 0-5 (read) on one day, 6-9 (unread) on the next, so the first unread row carries a + // date separator. + const dayOne = new Date('2020-01-01T00:00:00.000Z').getTime(); + const dayTwo = new Date('2020-01-02T00:00:00.000Z').getTime(); + const messages = Array.from({ length: 10 }, (_, i) => + generateMessage({ + id: `${i}`, + text: `message-${i}`, + timestamp: new Date((i < 6 ? dayOne : dayTwo) + i * 1000), + user: user2, + }), + ); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 }), generateMember({ user: user2 })], + messages, + read: [ + { + user: user1, + last_read: msToNs(dayOne + 5000), + last_read_message_id: '5', + unread_messages: 4, + }, + ] as unknown as NonNullable[0]>['read'], + }); + + const chatClient = await getTestClientWithUser({ id: user1.id } as UserResponse); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const { getAllByLabelText, getByTestId } = render( + + + + + + + , + ); + + await waitFor(() => { + expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); + expect( + within(getByTestId('message-list-item-6')) + .queryAllByTestId(/^(date-separator|inline-unread-indicator|message-wrapper)$/) + .map((node) => node.props.testID), + ).toEqual(['date-separator', 'inline-unread-indicator', 'message-wrapper']); + }); + }); + it("should not render the UnreadMessagesIndicator when there's no unread messages", async () => { const user1 = generateUser(); const user2 = generateUser(); diff --git a/package/src/components/Poll/components/MultipleVotesSettings.tsx b/package/src/components/Poll/components/MultipleVotesSettings.tsx index dab00f2030..d86851dcfc 100644 --- a/package/src/components/Poll/components/MultipleVotesSettings.tsx +++ b/package/src/components/Poll/components/MultipleVotesSettings.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { I18nManager, Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; +import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'; import Animated, { LinearTransition, StretchInY, StretchOutY } from 'react-native-reanimated'; @@ -238,14 +238,15 @@ const useStyles = () => { row: { alignItems: 'center', flexDirection: 'row', - gap: primitives.spacingXxs, + gap: primitives.spacingXs, }, input: { color: semantics.textPrimary, fontSize: primitives.typographyFontSizeMd, - paddingHorizontal: primitives.spacingSm, + minWidth: 32, + paddingHorizontal: 0, paddingVertical: primitives.spacingMd, - textAlign: I18nManager.isRTL ? 'right' : 'left', + textAlign: 'center', }, buttonEnabled: { borderColor: semantics.borderCoreOnSurface, diff --git a/package/src/middlewares/__tests__/attachments.test.ts b/package/src/middlewares/__tests__/attachments.test.ts index 395fd00635..51927cdf99 100644 --- a/package/src/middlewares/__tests__/attachments.test.ts +++ b/package/src/middlewares/__tests__/attachments.test.ts @@ -81,6 +81,29 @@ describe('setupVideoAttachmentPreviewMiddleware', () => { expect(state.attachment.localMetadata.previewUri).toBeUndefined(); }); + it('sends the picked file duration along as custom.duration', () => { + const { next } = runPrepare( + localVideoAttachment({ + custom: { mime_type: 'video/mp4' }, + localMetadata: { + file: { duration: 125000, name: 'clip.mp4', uri: 'file://local/clip.mp4' }, + id: 'clip', + uploadState: 'uploading', + }, + }), + ); + + const state = next.mock.calls[0][0] as { attachment: LocalAttachment }; + expect(state.attachment.custom).toEqual({ duration: 125000, mime_type: 'video/mp4' }); + }); + + it('leaves custom untouched when the picked file has no duration', () => { + const { next } = runPrepare(localVideoAttachment({ custom: { mime_type: 'video/mp4' } })); + + const state = next.mock.calls[0][0] as { attachment: LocalAttachment }; + expect(state.attachment.custom).toEqual({ mime_type: 'video/mp4' }); + }); + it('forwards anything that is not a local video', () => { const image = runPrepare( localVideoAttachment({ image_url: 'file://local/photo.jpg', type: 'image' }), diff --git a/package/src/middlewares/attachments.ts b/package/src/middlewares/attachments.ts index 41d5e7c1cb..00dea363a8 100644 --- a/package/src/middlewares/attachments.ts +++ b/package/src/middlewares/attachments.ts @@ -6,7 +6,9 @@ import { /** * Sets a local video's `localMetadata.previewUri` to its `thumb_url`, because the picked video - * file itself cannot be rendered as a preview (other attachments preview the file URI). + * file itself cannot be rendered as a preview (other attachments preview the file URI). Also sends + * the picked file's duration along as `custom.duration`, since the local file is gone once the + * message is sent (e.g. when editing it). */ const createVideoAttachmentPreviewMiddleware = (): AttachmentPreUploadMiddleware => ({ id: 'stream-io/message-composer-ui-middleware/video-attachment-preview', @@ -18,10 +20,16 @@ const createVideoAttachmentPreviewMiddleware = (): AttachmentPreUploadMiddleware return forward(); } + // Only the RN `FileReference` carries a `duration` (and a required `uri` to discriminate on). + const { file } = attachment.localMetadata; + const duration = file && 'uri' in file ? file.duration : undefined; + const custom = duration ? { ...attachment.custom, duration } : attachment.custom; + return next({ ...state, attachment: { ...attachment, + custom, localMetadata: { ...attachment.localMetadata, previewUri: attachment.thumb_url,