diff --git a/.gitignore b/.gitignore index 78670d2e..fdd30d94 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ node_modules/ dist/ web-build/ +# Expo build +android/ + # Metro .metro-health-check* diff --git a/backend/src/RoomStorage.ts b/backend/src/RoomStorage.ts index 9e493a6e..706a2eba 100644 --- a/backend/src/RoomStorage.ts +++ b/backend/src/RoomStorage.ts @@ -7,6 +7,7 @@ import { QueueableRemote } from "./musicplatform/remotes/Remote"; import { adminSupabase, server } from "./server"; import Room from "./socketio/Room"; import { RoomWithForeignTable } from "./socketio/RoomDatabase"; +import { Response } from "commons/socket.io-types"; const STREAMING_SERVICES = { Spotify: "a2d17b25-d87e-42af-9e79-fd4df6b59222", @@ -126,7 +127,7 @@ export default class RoomStorage { async roomFromUuid( rawUuid: string, hostSocket: Socket | null - ): Promise { + ): Promise> { const { data: remoteRoom } = await adminSupabase .from("rooms") .select("*, streaming_services(*), room_configurations(*)") @@ -134,10 +135,18 @@ export default class RoomStorage { .eq("is_active", true) .single(); - if (!remoteRoom) return null; + if (!remoteRoom) + return { + data: null, + error: "Room not found", + }; const parseRemote = parseRemoteRoom(remoteRoom); - if (!parseRemote) return null; + if (!parseRemote) + return { + data: null, + error: "Error parsing remote room", + }; const { musicPlatform, roomWithConfig } = parseRemote; return Room.getOrCreate( @@ -149,7 +158,10 @@ export default class RoomStorage { ); } - async roomFromCode(code: string, hostSocket: Socket): Promise { + async roomFromCode( + code: string, + hostSocket: Socket + ): Promise> { const { data: remoteRoom } = await adminSupabase .from("rooms") .select("*, streaming_services(*), room_configurations(*)") @@ -157,10 +169,18 @@ export default class RoomStorage { .eq("is_active", true) .single(); - if (!remoteRoom) return null; + if (!remoteRoom) + return { + data: null, + error: "Room not found", + }; const parseRemote = parseRemoteRoom(remoteRoom); - if (!parseRemote) return null; + if (!parseRemote) + return { + data: null, + error: "Error parsing remote room", + }; const { musicPlatform, roomWithConfig } = parseRemote; return Room.getOrCreate( diff --git a/backend/src/musicplatform/Deezer.ts b/backend/src/musicplatform/Deezer.ts index a4de5cb5..7754d05a 100644 --- a/backend/src/musicplatform/Deezer.ts +++ b/backend/src/musicplatform/Deezer.ts @@ -3,13 +3,17 @@ import { JSONTrack } from "commons/backend-types"; import MusicPlatform from "./MusicPlatform"; import { Remote } from "./remotes/Remote"; import Room from "../socketio/Room"; +import { Response } from "commons/socket.io-types"; export default class Deezer extends MusicPlatform { async getRemote( room: Room, musicPlatform: MusicPlatform - ): Promise { - return null; + ): Promise> { + return { + data: null, + error: "Deezer is not implemented", + }; } constructor() { diff --git a/backend/src/musicplatform/MusicPlatform.ts b/backend/src/musicplatform/MusicPlatform.ts index a95c94aa..9114c453 100644 --- a/backend/src/musicplatform/MusicPlatform.ts +++ b/backend/src/musicplatform/MusicPlatform.ts @@ -1,6 +1,7 @@ import { JSONTrack } from "commons/backend-types"; import { Remote } from "./remotes/Remote"; import Room from "../socketio/Room"; +import { Response } from "commons/socket.io-types"; export default abstract class MusicPlatform { private readonly urlPattern: RegExp; @@ -37,7 +38,7 @@ export default abstract class MusicPlatform { abstract getRemote( room: Room, musicPlatform: MusicPlatform - ): Promise; + ): Promise>; } function getNbCapturingGroupRegex(regex: RegExp) { diff --git a/backend/src/musicplatform/SoundCloud.ts b/backend/src/musicplatform/SoundCloud.ts index c393f866..e279bfc5 100644 --- a/backend/src/musicplatform/SoundCloud.ts +++ b/backend/src/musicplatform/SoundCloud.ts @@ -4,6 +4,7 @@ import Room from "../socketio/Room"; import MusicPlatform from "./MusicPlatform"; import { Remote } from "./remotes/Remote"; import SoundCloudRemote from "./remotes/SoundCloudRemote"; +import { Response } from "commons/socket.io-types"; function extractFromTrack(track: SoundcloudTrackV2) { const artists = track.user.username; @@ -43,7 +44,7 @@ export default class SoundCloud extends MusicPlatform { room: Room, // eslint-disable-next-line @typescript-eslint/no-unused-vars musicPlatform: MusicPlatform - ): Promise { + ): Promise> { return SoundCloudRemote.createRemote(this, room); } diff --git a/backend/src/musicplatform/Spotify.ts b/backend/src/musicplatform/Spotify.ts index e48336b6..c857fef5 100644 --- a/backend/src/musicplatform/Spotify.ts +++ b/backend/src/musicplatform/Spotify.ts @@ -6,6 +6,7 @@ import { Remote } from "./remotes/Remote"; import SpotifyRemote from "./remotes/SpotifyRemote"; import Room from "../socketio/Room"; import { Track } from "@spotify/web-api-ts-sdk"; +import { Response } from "commons/socket.io-types"; export default class Spotify extends MusicPlatform { constructor() { @@ -59,7 +60,7 @@ export default class Spotify extends MusicPlatform { async getRemote( room: Room, musicPlatform: MusicPlatform - ): Promise { + ): Promise> { return await SpotifyRemote.createRemote(room, this); } diff --git a/backend/src/musicplatform/TrackMetadata.ts b/backend/src/musicplatform/TrackMetadata.ts index edf95a36..5391fdff 100644 --- a/backend/src/musicplatform/TrackMetadata.ts +++ b/backend/src/musicplatform/TrackMetadata.ts @@ -24,8 +24,13 @@ export default class TrackMetadata { } async toJSON(): Promise { - const JSONTrack = await this.platform.getJsonTrack(this.id); - if (JSONTrack) JSONTrack.votes = []; - return JSONTrack; + try { + const JSONTrack = await this.platform.getJsonTrack(this.id); + if (JSONTrack) JSONTrack.votes = []; + return JSONTrack; + } catch (err) { + console.error(`Failed to fetch data for track ${this.id}`); + return null; + } } } diff --git a/backend/src/musicplatform/remotes/SoundCloudRemote.ts b/backend/src/musicplatform/remotes/SoundCloudRemote.ts index e6b49d74..2c5c690e 100644 --- a/backend/src/musicplatform/remotes/SoundCloudRemote.ts +++ b/backend/src/musicplatform/remotes/SoundCloudRemote.ts @@ -1,11 +1,11 @@ -import { JSONTrack, PlayingJSONTrack } from "commons/backend-types"; -import MusicPlatform from "../MusicPlatform"; -import { Remote } from "./Remote"; +import { PlayingJSONTrack } from "commons/backend-types"; import { LocalPlayerServerToClientEvents, Response, } from "commons/socket.io-types"; import Room from "../../socketio/Room"; +import MusicPlatform from "../MusicPlatform"; +import { Remote } from "./Remote"; export default class SoundCloudRemote extends Remote { room: Room; @@ -20,8 +20,8 @@ export default class SoundCloudRemote extends Remote { static async createRemote( musicPlatform: MusicPlatform, room: Room - ): Promise { - return new SoundCloudRemote(room, musicPlatform); + ): Promise> { + return { data: new SoundCloudRemote(room, musicPlatform), error: null }; } getHostSocket(): (typeof this.room)["hostSocket"] { diff --git a/backend/src/musicplatform/remotes/SpotifyRemote.ts b/backend/src/musicplatform/remotes/SpotifyRemote.ts index de1a7924..522f0c00 100644 --- a/backend/src/musicplatform/remotes/SpotifyRemote.ts +++ b/backend/src/musicplatform/remotes/SpotifyRemote.ts @@ -2,7 +2,7 @@ import { SimplifiedArtist, SpotifyApi, Track } from "@spotify/web-api-ts-sdk"; import { JSONTrack, PlayingJSONTrack } from "commons/backend-types"; import { adminSupabase } from "../../server"; import MusicPlatform from "../MusicPlatform"; -import { QueueableRemote } from "./Remote"; +import { QueueableRemote, Remote } from "./Remote"; import Room from "../../socketio/Room"; import { Response } from "commons/socket.io-types"; @@ -22,70 +22,103 @@ export default class SpotifyRemote extends QueueableRemote { static async createRemote( room: Room, musicPlatform: MusicPlatform - ): Promise { + ): Promise> { const { data, error } = await adminSupabase .from("rooms") .select("*, user_profile(*, bound_services(*))") .eq("id", room.uuid) + .eq( + "user_profile.bound_services.service_id", + "a2d17b25-d87e-42af-9e79-fd4df6b59222" + ) .single(); + if (error) + return { + data: null, + error: "Failed to fetch host's Spotify credentials", + }; if ( - error || - !data || - !data.user_profile || - !data.user_profile.bound_services + data.user_profile?.bound_services === undefined || + data.user_profile?.bound_services.length === 0 || + !data.user_profile.bound_services[0].access_token || + !data.user_profile.bound_services[0].expires_in || + !data.user_profile.bound_services[0].refresh_token ) - return null; + return { + data: null, + error: + "Couldn't find Spotify credentials. Please double check that your Spotify account is linked in your account settings under 'Integrations'", + }; const { access_token, expires_in, refresh_token } = data.user_profile.bound_services[0]; - if (!access_token || !expires_in || !refresh_token) return null; - const expiresIn = parseInt(expires_in); - - // TODO: https://github.com/spotify/spotify-web-api-ts-sdk/issues/79 - const spotifyClient = SpotifyApi.withAccessToken( - process.env.SPOTIFY_CLIENT_ID as string, - { - access_token, - refresh_token, - expires_in: expiresIn, - token_type: "Bearer", - } - ); - return new SpotifyRemote(spotifyClient, musicPlatform); + try { + // TODO: https://github.com/spotify/spotify-web-api-ts-sdk/issues/79 + // Seems like the tokens aren't refreshed properly, this has been fixed in the GitHub repo but we're + // awaiting a release of the library to update and fix the issue + const spotifyClient = SpotifyApi.withAccessToken( + process.env.SPOTIFY_CLIENT_ID as string, + { + access_token, + refresh_token, + expires_in: expiresIn, + token_type: "Bearer", + } + ); + return { + data: new SpotifyRemote(spotifyClient, musicPlatform), + error: null, + }; + } catch (e) { + return { + data: null, + error: + "Failed to authenticate to Spotify using saved credentials. Try disconnecting then reconnecting your Spotify account from your account 'Integrations' page", + }; + } } async getPlaybackState(): Promise> { return runSpotifyCallback(async () => { - const spotifyPlaybackState = - await this.spotifyClient.player.getPlaybackState(); - - if (!spotifyPlaybackState || spotifyPlaybackState.item.type === "episode") - return { data: null, error: "No track is currently playing" }; - - const playbackState = { - ...spotifyPlaybackState, - item: spotifyPlaybackState.item as Track, - }; - - const artistsName = extractArtistsName(playbackState.item.album.artists); - - return { - data: { - isPlaying: playbackState.is_playing, - albumName: playbackState.item.album.name, - artistsName: artistsName, - currentTime: playbackState.progress_ms, - duration: playbackState.item.duration_ms, - imgUrl: playbackState.item.album.images[0].url, - title: playbackState.item.name, - url: playbackState.item.external_urls.spotify, - updated_at: Date.now(), - }, - error: null, - }; + try { + const spotifyPlaybackState = + await this.spotifyClient.player.getPlaybackState(); + + if ( + !spotifyPlaybackState || + spotifyPlaybackState.item.type === "episode" + ) + return { data: null, error: "No track is currently playing" }; + + const playbackState = { + ...spotifyPlaybackState, + item: spotifyPlaybackState.item as Track, + }; + + const artistsName = extractArtistsName( + playbackState.item.album.artists + ); + + return { + data: { + isPlaying: playbackState.is_playing, + albumName: playbackState.item.album.name, + artistsName: artistsName, + currentTime: playbackState.progress_ms, + duration: playbackState.item.duration_ms, + imgUrl: playbackState.item.album.images[0].url, + title: playbackState.item.name, + url: playbackState.item.external_urls.spotify, + updated_at: Date.now(), + }, + error: null, + }; + } catch (e) { + return { data: null, error: "Failed to fetch current Playbackstate" }; + } }); } diff --git a/backend/src/route/AuthCallbackGET.ts b/backend/src/route/AuthCallbackGET.ts index f4aa52a1..76c613bb 100644 --- a/backend/src/route/AuthCallbackGET.ts +++ b/backend/src/route/AuthCallbackGET.ts @@ -117,24 +117,6 @@ const getUserProfile = async (userId: string): Promise => { return userData?.user_profile_id ?? null; }; -const alreadyBoundService = async ({ - service, - user_profile_id, -}: { - service: StreamingService; - user_profile_id: string; -}): Promise<{ alreadyBound: boolean; error: PostgrestError | null }> => { - const { data, error } = await adminSupabase - .from("bound_services") - .select("*") - .eq("user_profile_id", user_profile_id) - .eq("service_id", service); - return { - alreadyBound: data !== null && data.length > 0, - error: error, - }; -}; - export const createAccount = async ({ displayName, accountId, diff --git a/backend/src/socketio/Room.ts b/backend/src/socketio/Room.ts index d82eef3e..3bb06a34 100644 --- a/backend/src/socketio/Room.ts +++ b/backend/src/socketio/Room.ts @@ -12,6 +12,7 @@ import { adminSupabase } from "../server"; import { RoomWithConfigDatabase } from "./RoomDatabase"; export type TypedSocket = Socket; +import { Response } from "commons/socket.io-types"; export default class Room { public readonly uuid: string; @@ -49,20 +50,27 @@ export default class Room { streamingService: MusicPlatform, hostSocket: Socket | null, roomWithConfig: RoomWithConfigDatabase - ): Promise { + ): Promise> { let room = roomStorage.getRoom(uuid); if (room === null) { room = new Room(uuid, streamingService, hostSocket, roomWithConfig); - const remote = await streamingService.getRemote(room, streamingService); + const { data: remote, error } = await streamingService.getRemote( + room, + streamingService + ); + if (error) { + return { data: null, error }; + } room.setRemote(remote); roomStorage.addRoom(room); } else { room.setHostSocket(hostSocket); } - return room; + return { data: room, error: null }; } + setRemote(remote: Remote | null) { this.remote = remote; } diff --git a/backend/src/socketio/RoomIO.ts b/backend/src/socketio/RoomIO.ts index 5332f039..5536ec19 100644 --- a/backend/src/socketio/RoomIO.ts +++ b/backend/src/socketio/RoomIO.ts @@ -34,9 +34,14 @@ export default function onRoomWSConnection(socket: TypedSocket) { async function registerHandlers() { const hostSocket = isHostSocket ? socket : null; - const room = await roomStorage.roomFromUuid(activeRoomId, hostSocket); + const { data: room, error } = await roomStorage.roomFromUuid( + activeRoomId, + hostSocket + ); if (room === null) { + // Errors are handled by the client, with a redirect to the home page + socket.emit("room:error", error); socket.disconnect(); return; } diff --git a/commons/socket.io-types.ts b/commons/socket.io-types.ts index 1232e7d8..d5b320ac 100644 --- a/commons/socket.io-types.ts +++ b/commons/socket.io-types.ts @@ -67,6 +67,9 @@ export interface ServerToClientEvents "queue:update": (room: RoomJSON | Error) => void; /** End the room. */ "room:end": () => void; + // error events are handled by the client in layout of rooms/[id] + // it displays a error page with a button to go back to the home page + "room:error": (error: string) => void; } /** diff --git a/expo/app/(tabs)/profile/account/_layout.tsx b/expo/app/(tabs)/profile/account/_layout.tsx index 782f247b..cd529f97 100644 --- a/expo/app/(tabs)/profile/account/_layout.tsx +++ b/expo/app/(tabs)/profile/account/_layout.tsx @@ -1,21 +1,28 @@ import { Stack } from "expo-router"; -// Account will be mooved to (tabs)/profile/account... +import ErrorBoundary from "../../../../components/ErrorBoundary"; +import ProfileErrorBoundary from "../../../../components/ErrorComponent/ProfileError"; + export default function AccountLayout() { return ( - - - - - + }> + + + + + + ); } diff --git a/expo/app/(tabs)/profile/account/edit.tsx b/expo/app/(tabs)/profile/account/edit.tsx index c5569500..0b428d7d 100644 --- a/expo/app/(tabs)/profile/account/edit.tsx +++ b/expo/app/(tabs)/profile/account/edit.tsx @@ -5,6 +5,7 @@ import { ScrollView, StyleSheet } from "react-native"; import Alert from "../../../../components/Alert"; import Button from "../../../../components/Button"; import ControlledInput from "../../../../components/ControlledInput"; +import ErrorBoundary from "../../../../components/ErrorBoundary"; import { View } from "../../../../components/Themed"; import Warning from "../../../../components/Warning"; import AvatarForm from "../../../../components/profile/AvatarForm"; @@ -55,7 +56,6 @@ export default function PersonalInfo() { }); const inputsChange = watch(); - useEffect(() => { if (user && user.email) { if (user.app_metadata.provider !== "email") setEmailDisabled(true); @@ -194,12 +194,21 @@ export default function PersonalInfo() { rules={usernameRules} errorMessage={errors.username && errors.username.message} /> - { - setProfilePictureChanged(true); - }} - /> + + } + > + { + setProfilePictureChanged(true); + }} + /> + + + ); +} + +const errorStyle = StyleSheet.create({ + page: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 20, + rowGap: 40, + flexDirection: "column", + }, + title: { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 20, + }, +}); diff --git a/expo/components/ErrorComponent/RoomError.tsx b/expo/components/ErrorComponent/RoomError.tsx new file mode 100644 index 00000000..23ca41e0 --- /dev/null +++ b/expo/components/ErrorComponent/RoomError.tsx @@ -0,0 +1,44 @@ +import { MaterialIcons } from "@expo/vector-icons"; +import { StyleSheet } from "react-native"; + +import Button from "../Button"; +import { View, Text } from "../Themed"; + +export default function RoomErrorBoundary(): JSX.Element { + return ( + + + + + Erreur dans votre salle d'écoute + + + + + + ); +} + +const errorStyle = StyleSheet.create({ + page: { + flex: 1, + justifyContent: "center", + alignItems: "center", + padding: 20, + rowGap: 40, + flexDirection: "column", + }, + title: { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 20, + }, +}); diff --git a/expo/components/ErrorComponent/RootError.tsx b/expo/components/ErrorComponent/RootError.tsx new file mode 100644 index 00000000..8db3d1bd --- /dev/null +++ b/expo/components/ErrorComponent/RootError.tsx @@ -0,0 +1,45 @@ +import { StyleSheet } from "react-native"; + +import Button from "../Button"; +import { View, Text } from "../Themed"; + +export const RootErrorBoundary = () => { + return ( + + Oups ! + Erreur interne + + Une erreur est survenue, veuillez réessayer plus tard ou contacter le + support. + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: 15, + padding: 20, + }, + h1: { + fontSize: 50, + fontFamily: "Outfit-Bold", + textAlign: "center", + }, + h2: { + fontSize: 35, + fontFamily: "Outfit-Regular", + textAlign: "center", + }, + h3: { + fontSize: 20, + fontFamily: "Outfit-Regular", + textAlign: "center", + }, +}); diff --git a/expo/components/ErrorComponent/WebsocketError.tsx b/expo/components/ErrorComponent/WebsocketError.tsx new file mode 100644 index 00000000..4551328b --- /dev/null +++ b/expo/components/ErrorComponent/WebsocketError.tsx @@ -0,0 +1,34 @@ +import { MaterialIcons } from "@expo/vector-icons"; + +import { View, Text } from "../Themed"; + +export default function WebsocketError() { + return ( + + + + + Vous n'êtes plus connecté au serveur, tentative de reconnexion en + cours + + + + ); +} diff --git a/expo/index.js b/expo/index.js new file mode 100644 index 00000000..80d3d998 --- /dev/null +++ b/expo/index.js @@ -0,0 +1 @@ +import "expo-router/entry"; diff --git a/expo/lib/AsyncError.ts b/expo/lib/AsyncError.ts new file mode 100644 index 00000000..3ae412b0 --- /dev/null +++ b/expo/lib/AsyncError.ts @@ -0,0 +1,13 @@ +import { useCallback, useState } from "react"; + +export const useAsyncError = () => { + const [, setError] = useState(); + return useCallback( + (e: any) => { + setError(() => { + throw e; + }); + }, + [setError] + ); +}; diff --git a/expo/package-lock.json b/expo/package-lock.json index 0a1d3784..4dae90e0 100644 --- a/expo/package-lock.json +++ b/expo/package-lock.json @@ -18,6 +18,7 @@ "@spotify/web-api-ts-sdk": "^1.1.2", "@supabase/ssr": "^0.0.10", "@supabase/supabase-js": "^2.39.2", + "@types/uuid": "^9.0.7", "aes-js": "^3.1.2", "base64-arraybuffer": "^1.0.2", "expo": "~49.0.15", @@ -9473,9 +9474,8 @@ }, "node_modules/eslint-config-prettier": { "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, + "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9554,9 +9554,8 @@ }, "node_modules/eslint-config-universe/node_modules/eslint-config-prettier": { "version": "8.10.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", - "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", "dev": true, + "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -14685,9 +14684,9 @@ "license": "0BSD" }, "node_modules/jschardet": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.1.0.tgz", - "integrity": "sha512-MND0yjRsoQ/3iFXce7lqV/iBmqH9oWGUTlty36obRBZjhFDWCLKjXgfxY75wYfwlW7EFqw52tyziy/q4WsQmrA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.1.2.tgz", + "integrity": "sha512-mw3CBZGzW8nUBPYhFU2ztZ/kJ6NClQUQVpyzvFMfznZsoC///ZQ30J2RCUanNsr5yF22LqhgYr/lj807/ZleWA==", "dev": true, "engines": { "node": ">=0.1.90" diff --git a/expo/package.json b/expo/package.json index 356b177d..ceab06db 100644 --- a/expo/package.json +++ b/expo/package.json @@ -1,6 +1,6 @@ { "name": "datsmysong", - "main": "expo-router/entry", + "main": "index.js", "version": "1.0.0", "scripts": { "start": "expo start", @@ -24,6 +24,7 @@ "@spotify/web-api-ts-sdk": "^1.1.2", "@supabase/ssr": "^0.0.10", "@supabase/supabase-js": "^2.39.2", + "@types/uuid": "^9.0.7", "aes-js": "^3.1.2", "base64-arraybuffer": "^1.0.2", "expo": "~49.0.15", diff --git a/expo/tsconfig.json b/expo/tsconfig.json index 75edaf82..a15df78a 100644 --- a/expo/tsconfig.json +++ b/expo/tsconfig.json @@ -7,6 +7,7 @@ "**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", - "expo-env.d.ts" - ] + "expo-env.d.ts", + "components/ErrorBoundary.js", + ], }