import axios from '@nextcloud/axios'; import { generateUrl } from '@nextcloud/router'; import type { IDay, IPhoto, IImageInfo } from './types'; const euc = encodeURIComponent; /** Access NativeX over localhost */ const BASE_URL = 'http://127.0.0.1'; /** NativeX asynchronous API */ export const API = { /** * Local days API. * @regex ^/api/days$ * @returns {IDay[]} for all locally available days. */ DAYS: () => `${BASE_URL}/api/days`, /** * Local photos API. * @regex ^/api/days/\d+$ * @param dayId Day ID to fetch photos for * @returns {IPhoto[]} for all locally available photos for this day. */ DAY: (dayId: number) => `${BASE_URL}/api/days/${dayId}`, /** * Local photo metadata API. * @regex ^/api/image/info/\d+$ * @param fileId File ID of the photo * @returns {IImageInfo} for the given file ID (local). */ IMAGE_INFO: (fileId: number) => `${BASE_URL}/api/image/info/${fileId}`, /** * Delete files using local fileids. * @regex ^/api/image/delete/\d+(,\d+)*$ * @param fileIds List of AUIDs to delete * @returns {void} * @throws Return an error code if the user denies the deletion. */ IMAGE_DELETE: (auids: number[]) => `${BASE_URL}/api/image/delete/${auids.join(',')}`, /** * Local photo preview API. * @regex ^/image/preview/\d+$ * @param fileId File ID of the photo * @returns {Blob} JPEG preview of the photo. */ IMAGE_PREVIEW: (fileId: number) => `${BASE_URL}/image/preview/${fileId}`, /** * Local photo full API. * @regex ^/image/full/\d+$ * @param fileId File ID of the photo * @returns {Blob} JPEG full image of the photo. */ IMAGE_FULL: (fileId: number) => `${BASE_URL}/image/full/${fileId}`, /** * Share a URL with native page. * The native client MUST NOT download the object but share the URL directly. * @regex ^/api/share/url/.+$ * @param url URL to share (double-encoded) * @returns {void} */ SHARE_URL: (url: string) => `${BASE_URL}/api/share/url/${euc(euc(url))}`, /** * Share an object (as blob) natively using a given URL. * The native client MUST download the object using a download manager * and immediately prompt the user to download it. The asynchronous call * must return only after the object has been downloaded. * @regex ^/api/share/blob/.+$ * @param url URL to share (double-encoded) * @returns {void} */ SHARE_BLOB: (url: string) => `${BASE_URL}/api/share/blob/${euc(euc(url))}`, /** * Share a local file (as blob) with native page. * @regex ^/api/share/local/\d+$ * @param fileId File ID of the photo * @returns {void} */ SHARE_LOCAL: (fileId: number) => `${BASE_URL}/api/share/local/${fileId}`, /** * Get list of local folders configuration. * @regex ^/api/config/local-folders$ * @returns {LocalFolderConfig[]} List of local folders configuration */ CONFIG_LOCAL_FOLDERS: () => `${BASE_URL}/api/config/local-folders`, }; /** NativeX synchronous API. */ export type NativeX = { /** * Check if the native interface is available. * @returns Should always return true. */ isNative: () => boolean; /** * Set the theme color of the app. * @param color Color to set * @param isDark Whether the theme is dark (for navigation bar) */ setThemeColor: (color: string, isDark: boolean) => void; /** * Play a tap sound for UI interaction. */ playTouchSound: () => void; /** * Make a native toast to the user. * @param message Message to show * @param long Whether the toast should be shown for a long time */ toast: (message: string, long?: boolean) => void; /** * Start downloading a file from a given URL. * @param url URL to download from * @param filename Filename to save as * @details An error must be shown to the user natively if the download fails. */ downloadFromUrl: (url: string, filename: string) => void; /** * Play a video from the given file ID (local file). * @param fileid File ID of the video */ playVideoLocal: (fileid: string) => void; /** * Play a video from the given URL(s). * @param fileid Remote file ID of the video (used for play tracking) * @param urlArray JSON-encoded array of URLs to play * @details The URL array may contain multiple URLs, e.g. direct playback * and HLS separately. The native client must try to play the first URL. */ playVideoRemote: (fileid: string, urlArray: string) => void; /** * Destroy the video player. * @param fileid File ID of the video * @details The native client must destroy the video player and free up resources. * If the fileid doesn't match the playing video, the call must be ignored. */ destroyVideo: (fileid: string) => void; /** * Set the local folders configuration to show in the timeline. * @param json JSON-encoded array of LocalFolderConfig */ configSetLocalFolders: (json: string) => void; /** * Start the login process * @param baseUrl Base URL of the Nextcloud instance * @param loginFlowUrl URL to start the login flow */ login: (baseUrl: string, loginFlowUrl: string) => void; /** * Log out from Nextcloud and delete the tokens. */ logout: () => void; /** * Reload the app. */ reload: () => void; }; /** Setting of whether a local folder is enabled */ export type LocalFolderConfig = { id: string; name: string; enabled: boolean; }; /** The native interface is a global object that is injected by the native app. */ const nativex: NativeX = globalThis.nativex; /** * @returns Whether the native interface is available. */ export function has() { return !!nativex; } /** * Change the theme color of the app to default. */ export async function setTheme(color?: string, dark?: boolean) { if (!has()) return; color ??= getComputedStyle(document.body).getPropertyValue('--color-main-background'); dark ??= (document.body.hasAttribute('data-theme-default') && window.matchMedia('(prefers-color-scheme: dark)').matches) || document.body.hasAttribute('data-theme-dark') || document.body.hasAttribute('data-theme-dark-highcontrast'); nativex?.setThemeColor?.(color, dark); } /** * Download a file from the given URL. */ export async function downloadFromUrl(url: string) { // Make HEAD request to get filename const res = await axios.head(url); let filename = res.headers['content-disposition']; if (res.status !== 200 || !filename) return; // Extract filename from header without quotes filename = filename.split('filename="')[1].slice(0, -1); // Hand off to download manager nativex?.downloadFromUrl?.(addOrigin(url), filename); } /** * Play touch sound. */ export async function playTouchSound() { nativex?.playTouchSound?.(); } /** * Play a video from the given file ID (local file). */ export async function playVideoLocal(fileid: number) { nativex?.playVideoLocal?.(fileid.toString()); } /** * Play a video from the given URL. */ export async function playVideoRemote(fileid: number, urls: string[]) { nativex?.playVideoRemote?.(fileid.toString(), JSON.stringify(urls.map(addOrigin))); } /** * Destroy the video player. */ export async function destroyVideo(fileId: number) { nativex?.destroyVideo?.(fileId.toString()); } /** * Share a URL with native page. */ export async function shareUrl(url: string) { await axios.get(API.SHARE_URL(addOrigin(url))); } /** * Download a blob from the given URL and share it. */ export async function shareBlobFromUrl(url: string) { if (url.startsWith(BASE_URL)) { throw new Error('Cannot share localhost URL'); } await axios.get(API.SHARE_BLOB(addOrigin(url))); } /** * Share a local file with native page. */ export async function shareLocal(fileId: number) { await axios.get(API.SHARE_LOCAL(fileId)); } /** * Extend a list of days with local days. * Fetches the local days from the native interface. */ export async function extendDaysWithLocal(days: IDay[]) { if (!has()) return; // Query native part const res = await fetch(API.DAYS()); if (!res.ok) return; const local: IDay[] = await res.json(); const remoteMap = new Map(days.map((d) => [d.dayid, d])); // Merge local days into remote days for (const day of local) { const remote = remoteMap.get(day.dayid); if (remote) { remote.count = Math.max(remote.count, day.count); } else { days.push(day); } } // TODO: sort depends on view // (but we show it for only timeline anyway for now) days.sort((a, b) => b.dayid - a.dayid); } /** * Extend a list of photos with local photos. * Fetches the local photos from the native interface and filters out duplicates. * * @param dayId Day ID to append local photos to * @param photos List of photos to append to (duplicates will not be added) * @returns */ export async function extendDayWithLocal(dayId: number, photos: IPhoto[]) { if (!has()) return; // Query native part const res = await fetch(API.DAY(dayId)); if (!res.ok) return; // Merge local photos into remote photos const localPhotos: IPhoto[] = await res.json(); const serverAUIDs = new Set(photos.map((p) => p.auid)); // Filter out files that are only available locally const localOnly = localPhotos.filter((p) => !serverAUIDs.has(p.auid)); localOnly.forEach((p) => (p.islocal = true)); photos.push(...localOnly); // Sort by epoch value photos.sort((a, b) => (b.epoch ?? 0) - (a.epoch ?? 0)); } /** * Request deletion of local photos wherever available. * @param photos List of photos to delete * @throws If the request fails */ export async function deleteLocalPhotos(photos: IPhoto[]): Promise { if (!has()) return; const auids = photos.map((p) => p.auid).filter((a) => !!a) as number[]; await axios.get(API.IMAGE_DELETE(auids)); } /** * Get list of local folders configuration. * Should be called only if NativeX is available. */ export async function getLocalFolders() { return (await axios.get(API.CONFIG_LOCAL_FOLDERS())).data; } /** * Set list of local folders configuration. */ export async function setLocalFolders(config: LocalFolderConfig[]) { nativex?.configSetLocalFolders(JSON.stringify(config)); } /** * Log out from Nextcloud and pass ahead. */ export async function logout() { await axios.get(generateUrl('logout')); if (!has()) window.location.reload(); nativex?.logout(); } /** * Add current origin to URL if doesn't have any protocol or origin. */ function addOrigin(url: string) { return url.match(/^(https?:)?\/\//) ? url : url.startsWith('/') ? `${location.origin}${url}` : `${location.origin}/${url}`; }