From 1927cb93edf110775be9fcf6611a6bca10a154e9 Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Wed, 15 Nov 2023 02:51:40 +0300 Subject: [PATCH 01/10] feat(package): rework package --- packages/navigation/src/BasicNavigator.ts | 199 ------------ .../{hash => HashNavigator}/HashNavigator.ts | 228 ++++++-------- .../src/{hash => HashNavigator}/history.ts | 0 .../navigation/src/HashNavigator/index.ts | 2 + .../navigation/src/HashNavigator/types.ts | 41 +++ .../navigation/src/Navigator/Navigator.ts | 282 ++++++++++++++++++ packages/navigation/src/Navigator/index.ts | 2 + packages/navigation/src/Navigator/types.ts | 55 ++++ packages/navigation/src/ensurePrefix.ts | 9 + packages/navigation/src/hash/fromHistory.ts | 34 --- packages/navigation/src/hash/fromLocation.ts | 13 - packages/navigation/src/hash/getHash.ts | 17 -- packages/navigation/src/hash/index.ts | 4 - packages/navigation/src/index.ts | 5 +- packages/navigation/src/types.ts | 49 --- 15 files changed, 484 insertions(+), 456 deletions(-) delete mode 100644 packages/navigation/src/BasicNavigator.ts rename packages/navigation/src/{hash => HashNavigator}/HashNavigator.ts (51%) rename packages/navigation/src/{hash => HashNavigator}/history.ts (100%) create mode 100644 packages/navigation/src/HashNavigator/index.ts create mode 100644 packages/navigation/src/HashNavigator/types.ts create mode 100644 packages/navigation/src/Navigator/Navigator.ts create mode 100644 packages/navigation/src/Navigator/index.ts create mode 100644 packages/navigation/src/Navigator/types.ts create mode 100644 packages/navigation/src/ensurePrefix.ts delete mode 100644 packages/navigation/src/hash/fromHistory.ts delete mode 100644 packages/navigation/src/hash/fromLocation.ts delete mode 100644 packages/navigation/src/hash/getHash.ts delete mode 100644 packages/navigation/src/hash/index.ts delete mode 100644 packages/navigation/src/types.ts diff --git a/packages/navigation/src/BasicNavigator.ts b/packages/navigation/src/BasicNavigator.ts deleted file mode 100644 index 715958e04..000000000 --- a/packages/navigation/src/BasicNavigator.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { EventEmitter } from '@tma.js/event-emitter'; - -import type { - NavigationEntry, - NavigatorEventsMap, - AllowedEntry, -} from './types.js'; - -/** - * Represents a navigator which can be used in Telegram Mini Apps providing stable routing. - */ -export class BasicNavigator { - private readonly ee = new EventEmitter(); - - constructor( - private entries: NavigationEntry[], - private entriesCursor: number, - ) { - if (entries.length === 0) { - throw new Error('Entries should not be empty'); - } - - if (entriesCursor >= entries.length) { - throw new Error('Cursor should be less than or equal to entries count'); - } - } - - /** - * Converts externally specified entry data to the full entry. - * @param entry - entry data - */ - private formatAllowedEntry(entry: AllowedEntry): NavigationEntry { - let url: URL; - const absolutePath = `https://localhost${this.path}`; - - if (typeof entry === 'string') { - url = new URL(entry, absolutePath); - } else { - const { pathname = '', search = '' } = entry; - url = new URL(`${pathname}${search.startsWith('?') ? '' : '?'}${search}`); - } - - return { - pathname: url.pathname, - search: url.search, - }; - } - - /** - * Emits "change" event with current navigator data. - */ - private emitChange() { - this.ee.emit('change', { - pathname: this.pathname, - search: this.search, - }); - } - - /** - * Returns current entries cursor. - */ - get cursor(): number { - return this.entriesCursor; - } - - /** - * Returns true in case, navigator can go back. - */ - canGoBack(): boolean { - return this.entriesCursor > 0; - } - - /** - * Returns true in case, navigator can go forward. - */ - canGoForward(): boolean { - return this.entriesCursor !== this.entries.length - 1; - } - - /** - * Moves cursor by specified delta. - * @param delta - cursor delta. - * @returns True if changes were done. - */ - go(delta: number): boolean { - // Cursor should be in bounds: [0, this.entries). - const cursor = Math.min( - this.entries.length - 1, - Math.max(this.entriesCursor + delta, 0), - ); - - if (this.entriesCursor === cursor) { - return false; - } - - this.entriesCursor = cursor; - this.emitChange(); - - return true; - } - - /** - * Returns readonly version of current entries. - */ - getEntries(): readonly Readonly[] { - return this.entries.map((item) => Object.freeze(item)); - } - - /** - * Adds new event listener. - */ - on = this.ee.on.bind(this.ee); - - /** - * Removes event listener. - */ - off = this.ee.off.bind(this.ee); - - /** - * Pushes new entry. It removes all entries after the current one and inserts new one. - * @param entry - entry data. - * - * @example Pushing absolute pathname. - * push("/absolute-path"); // "/absolute-path" - * - * @example Pushing relative pathname. - * // Pushing relative path replaces N last path parts, where N is pushed pathname parts count. - * // Pushing empty path is recognized as relative, but not replacing the last pathname part. - * push("relative"); // "/home/root" -> "/home/relative" - * - * @example Pushing query parameters. - * push("/absolute?my-param=1"); // "/home" -> "/absolute?my-param=1" - * push("relative?my-param=1"); // "/home/root" -> "/home/relative?my-param=1" - * push("?my-param=1"); // "/home" -> "/home?my-param=1" - */ - push(entry: AllowedEntry): void { - // In case, current cursor refers not to the last one element in the history, we should - // remove everything after the cursor. - if (this.entriesCursor !== this.entries.length - 1) { - this.entries = this.entries.slice(0, this.entriesCursor + 1); - } - - const formatted = this.formatAllowedEntry(entry); - this.entriesCursor += 1; - this.entries[this.entriesCursor] = formatted; - this.emitChange(); - } - - /** - * Returns current full path including pathname and query parameters. - */ - get path(): string { - return `${this.pathname}${this.search}`; - } - - /** - * Returns current pathname. - * - * @example Always returned value. - * "/abc" - */ - get pathname(): string { - return this.entries[this.entriesCursor].pathname; - } - - /** - * Replaces current entry. Has the same logic as "push" method does. - * @param entry - entry data. - * @see push - * @returns True if changes were done. - */ - replace(entry: AllowedEntry): boolean { - const item = this.formatAllowedEntry(entry); - if (this.search === item.search && this.pathname === item.pathname) { - return false; - } - - this.entries[this.entriesCursor] = item; - this.emitChange(); - - return true; - } - - /** - * Returns current query parameters. - * - * @example Empty parameters. - * "" - * - * @example Empty parameters but with question mark. - * "?" - * - * @example Parameters list. - * "?param=1" - */ - get search(): string { - return this.entries[this.entriesCursor].search; - } -} diff --git a/packages/navigation/src/hash/HashNavigator.ts b/packages/navigation/src/HashNavigator/HashNavigator.ts similarity index 51% rename from packages/navigation/src/hash/HashNavigator.ts rename to packages/navigation/src/HashNavigator/HashNavigator.ts index 8b4d37d60..21b3e7c4e 100644 --- a/packages/navigation/src/hash/HashNavigator.ts +++ b/packages/navigation/src/HashNavigator/HashNavigator.ts @@ -1,47 +1,55 @@ -import { Logger } from '@tma.js/logger'; -import { EventEmitter } from '@tma.js/event-emitter'; import { on, off, postEvent } from '@tma.js/bridge'; +import { EventEmitter } from '@tma.js/event-emitter'; -import { BasicNavigator } from '../BasicNavigator.js'; import { drop, go } from './history.js'; +import { Navigator } from '../Navigator/index.js'; import type { NavigationEntry, - NavigatorOptions, - AllowedEntry, - NavigatorState, - NavigatorEventsMap, -} from '../types.js'; + PerformGoOptions, + PerformPushOptions, + PerformReplaceOptions, + NavigatorConEntry, +} from '../Navigator/types.js'; +import type { + HashNavigatorOptions, + HashNavigatorEventsMap, +} from './types.js'; const CURSOR_VOID = 0; const CURSOR_BACK = 1; const CURSOR_FORWARD = 2; -export class HashNavigator { - private readonly ee = new EventEmitter(); - - private readonly logger: Logger; +export class HashNavigator extends Navigator> { + /** + * Creates navigator from current window location hash. + * @param options - options passed to constructor. + */ + static fromLocation(options?: HashNavigatorOptions): HashNavigator { + const { + search, + pathname, + hash, + } = new URL( + window.location.hash.slice(1), + window.location.href, + ); + + return new HashNavigator([{ search, pathname, hash }], 0, options); + } - private navigator: BasicNavigator; + private readonly ee = new EventEmitter(); private attached = false; constructor( - history: NavigationEntry[], - cursor: number, - { debug = false }: NavigatorOptions = {}, + entries: NavigatorConEntry[], + entriesCursor: number, + options: HashNavigatorOptions = {}, ) { - this.navigator = new BasicNavigator(history, cursor); - this.logger = new Logger('[Hash Navigator]', debug); - } - - /** - * Creates browser history state associated with the current navigator state. - */ - private get state(): NavigatorState { - return { - cursor: this.navigator.cursor, - entries: [...this.navigator.getEntries()], - }; + super(entries, entriesCursor, { + ...options, + loggerPrefix: 'HashNavigator', + }); } /** @@ -49,7 +57,7 @@ export class HashNavigator { * @param state - event state. */ private onPopState = async ({ state }: PopStateEvent) => { - this.logger.log('Received state', state); + this.logger.log('"popstate" event received. State:', state); // In case state is null, we recognize current event as occurring whenever user clicks // any anchor. @@ -61,6 +69,7 @@ export class HashNavigator { // There is only one case when state can be CURSOR_VOID - when history contains // only one element. In this case, we should return user to the current history element. if (state === CURSOR_VOID) { + this.logger.log('Void reached. Moving history forward'); window.history.forward(); return; } @@ -76,12 +85,42 @@ export class HashNavigator { } }; + protected async performGo(options: PerformGoOptions): Promise { + if (!options.updated) { + return; + } + + if (this.attached) { + await this.syncHistory(); + } + + this.emitChanged(options.before, options.after); + } + + protected async performPush({ before, after }: PerformPushOptions): Promise { + if (this.attached) { + await this.syncHistory(); + } + + this.emitChanged(before, after); + } + + protected async performReplace(options: PerformReplaceOptions): Promise { + if (!options.updated) { + return; + } + + if (this.attached) { + window.history.replaceState(null, '', `#${this.path}`); + } + + this.emitChanged(options.before, options.after); + } + /** * Synchronizes current navigator state with browser history. */ private async syncHistory(): Promise { - this.logger.log('Synchronizing with browser history'); - // Remove history change event listener to get rid of side effects related to possible // future calls of history.go. window.removeEventListener('popstate', this.onPopState); @@ -92,46 +131,54 @@ export class HashNavigator { await drop(); // Actualize Telegram Mini Apps BackButton state. - postEvent('web_app_setup_back_button', { is_visible: this.navigator.canGoBack() }); + postEvent('web_app_setup_back_button', { is_visible: this.canGoBack }); - if (this.navigator.canGoBack() && this.navigator.canGoForward()) { + if (this.canGoBack && this.canGoForward) { // We have both previous and next elements. History should be: // [back, *current*, forward] - this.logger.log('Setting up history: [back, current, forward]'); + this.logger.log('Setting up history: [<-, *, ->]'); window.history.replaceState(CURSOR_BACK, ''); - window.history.pushState(this.state, '', hash); + window.history.pushState(null, '', hash); window.history.pushState(CURSOR_FORWARD, ''); await go(-1); - } else if (this.navigator.canGoBack()) { + } else if (this.canGoBack) { // We have only previous element. History should be: // [back, *current*] - this.logger.log('Setting up history: [back, current]'); + this.logger.log('Setting up history: [<-, *]'); window.history.replaceState(CURSOR_BACK, ''); - window.history.pushState(this.state, '', hash); - } else if (this.navigator.canGoForward()) { + window.history.pushState(null, '', hash); + } else if (this.canGoForward) { // We have only next element. History should be: // [*current*, forward] - this.logger.log('Setting up history: [current, forward]'); + this.logger.log('Setting up history: [*, ->]'); - window.history.replaceState(this.state, hash); + window.history.replaceState(null, hash); window.history.pushState(CURSOR_FORWARD, ''); await go(-1); } else { // We have no back and next elements. History should be: // [void, *current*] - this.logger.log('Setting up history: [void, current]'); + this.logger.log('Setting up history: [~, *]'); window.history.replaceState(CURSOR_VOID, ''); - window.history.pushState(this.state, '', hash); + window.history.pushState(null, '', hash); } window.addEventListener('popstate', this.onPopState); } + private emitChanged(from: NavigationEntry, to: NavigationEntry) { + this.ee.emit('change', { + navigator: this, + from, + to, + }); + } + /** * Attaches current navigator to the browser history allowing navigator to manipulate it. */ @@ -145,11 +192,7 @@ export class HashNavigator { return this.syncHistory(); } - /** - * Goes back in history. - * @returns Promise which will be resolved when transition was completed. - */ - back = (): Promise => this.go(-1); + back = () => super.back(); /** * Detaches current navigator from the browser history. @@ -164,35 +207,6 @@ export class HashNavigator { off('back_button_pressed', this.back); } - /** - * Goes forward in history. - * @returns Promise which will be resolved when transition was completed. - */ - forward(): Promise { - return this.go(1); - } - - /** - * Goes in history by specified delta. - * @param delta - history change delta. - * @returns Promise which will be resolved when transition was completed. - * @see BasicNavigator.go - */ - async go(delta: number): Promise { - if (!this.navigator.go(delta)) { - return; - } - - if (this.attached) { - await this.syncHistory(); - } - - this.ee.emit('change', { - pathname: this.pathname, - search: this.search, - }); - } - /** * Adds new event listener. */ @@ -202,64 +216,4 @@ export class HashNavigator { * Removes event listener. */ off = this.ee.off.bind(this.ee); - - /** - * @see BasicNavigator.path - */ - get path() { - return this.navigator.path; - } - - /** - * @see BasicNavigator.pathname - */ - get pathname() { - return this.navigator.pathname; - } - - /** - * Pushes new entry. - * @param entry - new entry to push. - * @returns Promise which will be resolved when transition was completed. - * @see BasicNavigator.push - */ - async push(entry: AllowedEntry): Promise { - this.navigator.push(entry); - - if (this.attached) { - await this.syncHistory(); - } - - this.ee.emit('change', { - pathname: this.pathname, - search: this.search, - }); - } - - /** - * Replaces current history entry. - * @param entry - entry to replace current with. - * @see BasicNavigator.replace - */ - replace(entry: AllowedEntry): void { - if (!this.navigator.replace(entry)) { - return; - } - - if (this.attached) { - window.history.replaceState(this.state, '', `#${this.path}`); - } - - this.ee.emit('change', { - pathname: this.pathname, - search: this.search, - }); - } - - /** - * @see BasicNavigator.search - */ - get search() { - return this.navigator.search; - } } diff --git a/packages/navigation/src/hash/history.ts b/packages/navigation/src/HashNavigator/history.ts similarity index 100% rename from packages/navigation/src/hash/history.ts rename to packages/navigation/src/HashNavigator/history.ts diff --git a/packages/navigation/src/HashNavigator/index.ts b/packages/navigation/src/HashNavigator/index.ts new file mode 100644 index 000000000..3b9c16bbe --- /dev/null +++ b/packages/navigation/src/HashNavigator/index.ts @@ -0,0 +1,2 @@ +export * from './HashNavigator.js'; +export * from './types.js'; diff --git a/packages/navigation/src/HashNavigator/types.ts b/packages/navigation/src/HashNavigator/types.ts new file mode 100644 index 000000000..87c186276 --- /dev/null +++ b/packages/navigation/src/HashNavigator/types.ts @@ -0,0 +1,41 @@ +import type { NavigatorOptions } from '../Navigator/index.js'; +import type { HashNavigator } from './HashNavigator.js'; +import type { NavigationEntry } from '../Navigator/types.js'; + +export type HashNavigatorOptions = Omit; + +interface ChangeEventPayload { + /** + * Navigator instance. + */ + navigator: HashNavigator; + + /** + * Previous navigation state. + */ + from: NavigationEntry; + + /** + * Current navigation state. + */ + to: NavigationEntry; +} + +export interface HashNavigatorEventsMap { + /** + * Being called whenever current history changes. + * @param event - generated event. + */ + change: (event: ChangeEventPayload) => void; +} + +/** + * Navigator event name. + */ +export type HashNavigatorEventName = keyof HashNavigatorEventsMap; + +/** + * Navigator event listener. + */ +export type HashNavigatorEventListener = + HashNavigatorEventsMap[E]; diff --git a/packages/navigation/src/Navigator/Navigator.ts b/packages/navigation/src/Navigator/Navigator.ts new file mode 100644 index 000000000..0a9d37ed2 --- /dev/null +++ b/packages/navigation/src/Navigator/Navigator.ts @@ -0,0 +1,282 @@ +import { Logger } from '@tma.js/logger'; + +import type { + AnyEntry, + PerformGoOptions, + PerformPushOptions, + PerformReplaceOptions, + NavigationEntry, + NavigatorOptions, + NavigatorConEntry, +} from './types.js'; +import { ensurePrefix } from '../ensurePrefix.js'; + +/** + * Represents basic navigator implementation which uses only memory to store and control + * navigation state. + */ +export abstract class Navigator { + protected logger: Logger; + + protected readonly entries: NavigationEntry[]; + + constructor( + entries: NavigatorConEntry[], + protected entriesCursor: number, + { + debug = false, + loggerPrefix = 'Navigator', + }: NavigatorOptions, + ) { + if (entries.length === 0) { + throw new Error('Entries list should not be empty.'); + } + + if (entriesCursor >= entries.length) { + throw new Error('Cursor should be less than entries count.'); + } + + this.entries = entries.map(({ pathname = '', search, hash }) => { + if (!pathname.startsWith('/') && pathname.length > 0) { + throw new Error('Pathname should start with "/"'); + } + + return { + pathname: ensurePrefix(pathname, '/'), + search: search ? ensurePrefix(search, '?') : '', + hash: hash ? ensurePrefix(hash, '#') : '', + }; + }); + this.logger = new Logger(`[${loggerPrefix}]`, debug); + } + + protected abstract performGo(options: PerformGoOptions): T; + + protected abstract performPush(options: PerformPushOptions): T; + + protected abstract performReplace(options: PerformReplaceOptions): T; + + /** + * Converts entry to the navigation entry. + * @param entry - entry data + */ + private formatEntry(entry: AnyEntry): NavigationEntry { + let path: string; + + if (typeof entry === 'string') { + path = entry; + } else { + const { + pathname = '', + search, + hash, + } = entry; + + path = pathname + + (search ? ensurePrefix(search, '?') : '') + + (hash ? ensurePrefix(hash, '#') : ''); + } + + const { + pathname, + search, + hash, + } = new URL(path, `https://localhost${this.path}`); + return { + pathname, + search, + hash, + }; + } + + /** + * Current entry. + */ + protected get entry(): NavigationEntry { + return this.entries[this.entriesCursor]; + } + + /** + * Goes back in history. + */ + back(): T { + return this.go(-1); + } + + /** + * Current entries cursor. + */ + get cursor(): number { + return this.entriesCursor; + } + + /** + * True if navigator can go back. + */ + get canGoBack(): boolean { + return this.entriesCursor > 0; + } + + /** + * True if navigator can go forward. + */ + get canGoForward(): boolean { + return this.entriesCursor !== this.entries.length - 1; + } + + /** + * Goes forward in history. + */ + forward(): T { + return this.go(1); + } + + /** + * Moves entries cursor by specified delta. + * @param delta - cursor delta. + */ + go(delta: number): T { + this.logger.log(`called go(${delta})`); + + // Cursor should be in bounds: [0, this.entries). + const cursor = Math.min( + this.entries.length - 1, + Math.max(this.entriesCursor + delta, 0), + ); + + if (this.entriesCursor === cursor) { + return this.performGo({ + updated: false, + delta, + }); + } + + const before = this.entry; + this.entriesCursor = cursor; + const after = this.entry; + + this.logger.log('State changed', { before, after }); + + return this.performGo({ + updated: true, + delta, + before, + after, + }); + } + + /** + * Returns copy of navigator entries. + */ + getEntries(): NavigationEntry[] { + return this.entries.map((entry) => ({ ...entry })); + } + + /** + * Current hash. + * @example + * "", "#", "#hash" + */ + get hash(): string { + return this.entry.hash; + } + + /** + * Pushes new entry. Method replaces all entries after the current one with the inserted. + * @param entry - entry data. + * + * @example Pushing absolute pathname. + * push("/absolute-path"); // "/absolute-path" + * + * @example Pushing relative pathname. + * // Pushing relative path replaces N last path parts, where N is pushed pathname parts count. + * // Pushing empty path is recognized as relative, but not replacing the last pathname part. + * push("relative"); // "/home/root" -> "/home/relative" + * + * @example Pushing query parameters. + * push("/absolute?my-param=1"); // "/home" -> "/absolute?my-param=1" + * push("relative?my-param=1"); // "/home/root" -> "/home/relative?my-param=1" + * push("?my-param=1"); // "/home" -> "/home?my-param=1" + * + * @example Pushing hash. + * push("#my-hash"); // "/home" -> "/home#my-hash" + * push("johny#my-hash"); // "/home/root" -> "/home/johny#my-hash" + */ + push(entry: AnyEntry): T { + // In case, current cursor refers not to the last one element in the history, we should + // remove everything after the cursor. + if (this.entriesCursor !== this.entries.length - 1) { + this.entries.splice(this.entriesCursor + 1); + } + + const formatted = this.formatEntry(entry); + const before = this.entry; + this.entriesCursor += 1; + this.entries[this.entriesCursor] = formatted; + const after = this.entry; + + this.logger.log('State changed', { before, after }); + + return this.performPush({ + before, + after, + }); + } + + /** + * Current full path including pathname, query parameters and hash. + */ + get path(): string { + return `${this.pathname}${this.search}${this.hash}`; + } + + /** + * Current pathname. + * @example + * "/", "/abc" + */ + get pathname(): string { + return this.entry.pathname; + } + + /** + * Replaces current entry. Has the same logic as `push` method. + * @param entry - entry data. + * @see push + * @returns True if changes were done. + */ + replace(entry: AnyEntry): T { + const formattedEntry = this.formatEntry(entry); + if ( + this.search === formattedEntry.search + && this.pathname === formattedEntry.pathname + && this.hash === formattedEntry.hash + ) { + return this.performReplace({ + updated: false, + entry: formattedEntry, + }); + } + + const before = this.entry; + this.entries[this.entriesCursor] = formattedEntry; + const after = this.entry; + + this.logger.log('State changed', { before, after }); + + return this.performReplace({ + updated: true, + before, + after, + }); + } + + /** + * Current query parameters. + * @example + * "", "?", "?a=1" + */ + get search(): string { + return this.entry.search; + } +} diff --git a/packages/navigation/src/Navigator/index.ts b/packages/navigation/src/Navigator/index.ts new file mode 100644 index 000000000..f9db41882 --- /dev/null +++ b/packages/navigation/src/Navigator/index.ts @@ -0,0 +1,2 @@ +export * from './Navigator.js'; +export * from './types.js'; diff --git a/packages/navigation/src/Navigator/types.ts b/packages/navigation/src/Navigator/types.ts new file mode 100644 index 000000000..cdc59512b --- /dev/null +++ b/packages/navigation/src/Navigator/types.ts @@ -0,0 +1,55 @@ +export interface NavigationEntry { + pathname: string; + search: string; + hash: string; +} + +export type NavigatorConEntry = Partial; + +/** + * Entry information is allowed to be used in `push` and `replace` Navigator methods. + * Should be either path or object partially describing it. + */ +export type AnyEntry = string | Partial; + +export type PerformGoOptions = + | { + updated: false; + delta: number; +} + | { + updated: true; + delta: number; + before: NavigationEntry; + after: NavigationEntry; +}; + +export interface PerformPushOptions { + before: NavigationEntry; + after: NavigationEntry; +} + +export type PerformReplaceOptions = + | { + updated: false; + entry: NavigationEntry; +} + | { + updated: true; + before: NavigationEntry; + after: NavigationEntry; +}; + +export interface NavigatorOptions { + /** + * Should navigator display debug messages. + * @default false + */ + debug?: boolean; + + /** + * Prefix used for logger. + * @default 'Navigator' + */ + loggerPrefix?: string; +} diff --git a/packages/navigation/src/ensurePrefix.ts b/packages/navigation/src/ensurePrefix.ts new file mode 100644 index 000000000..9a2ad4812 --- /dev/null +++ b/packages/navigation/src/ensurePrefix.ts @@ -0,0 +1,9 @@ +/** + * Ensures, that specified value starts with the specified prefix. If it doesn't, function appends + * prefix. + * @param value - value to check. + * @param prefix - prefix to add. + */ +export function ensurePrefix(value: string, prefix: string): string { + return value.startsWith(prefix) ? value : `${prefix}${value}`; +} diff --git a/packages/navigation/src/hash/fromHistory.ts b/packages/navigation/src/hash/fromHistory.ts deleted file mode 100644 index a0a8b3327..000000000 --- a/packages/navigation/src/hash/fromHistory.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { array, json, number, string } from '@tma.js/parsing'; - -import { HashNavigator } from './HashNavigator.js'; -import type { NavigatorOptions, NavigatorState } from '../types.js'; - -const parser = json({ - cursor: number(), - entries: array().of( - json({ - pathname: string(), - search: string(), - }), - ), -}); - -/** - * Attempts to create a navigator from the current browser history state. This function will - * properly work in case, the last time browser history was managed by some other `Navigator`. - * - * Method returns null in case, it was unable to create `Navigator`. - * - * FIXME: This method will not work as expected in Telegram Web. Learn more: - * https://github.com/Telegram-Mini-Apps/tma.js/issues/150 - * @param options - options passed to constructor. - */ -export function fromHistory(options?: NavigatorOptions): HashNavigator | null { - try { - const state = parser.parse(window.history.state); - - return state ? new HashNavigator(state.entries, state.cursor, options) : null; - } catch (e) { - return null; - } -} diff --git a/packages/navigation/src/hash/fromLocation.ts b/packages/navigation/src/hash/fromLocation.ts deleted file mode 100644 index 3613e7d08..000000000 --- a/packages/navigation/src/hash/fromLocation.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { HashNavigator } from './HashNavigator.js'; -import type { NavigatorOptions } from '../types.js'; - -/** - * Creates navigator from current window location hash. - * @param options - options passed to constructor. - */ -export function fromLocation(options?: NavigatorOptions): HashNavigator { - const { hash, href } = window.location; - const { search, pathname } = new URL(hash.slice(1), href); - - return new HashNavigator([{ search, pathname }], 0, options); -} diff --git a/packages/navigation/src/hash/getHash.ts b/packages/navigation/src/hash/getHash.ts deleted file mode 100644 index d23030a1c..000000000 --- a/packages/navigation/src/hash/getHash.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Returns string after first met "#" symbol. - * @param value - string to take hash part from. - * - * @example No hash. - * getHash('/path'); // null - * - * @example Has hash. - * getHash('/path#abc'); // 'abc' - * - * @example Has double hash. - * getHash('/path#abc#another'); // 'abc#another' - */ -export function getHash(value: string): string | null { - const match = value.match(/#(.+)/); - return match ? match[1] : null; -} diff --git a/packages/navigation/src/hash/index.ts b/packages/navigation/src/hash/index.ts deleted file mode 100644 index 0473c823f..000000000 --- a/packages/navigation/src/hash/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './fromHistory.js'; -export * from './fromLocation.js'; -export * from './getHash.js'; -export * from './HashNavigator.js'; diff --git a/packages/navigation/src/index.ts b/packages/navigation/src/index.ts index 12c4e7a24..ef379d6ed 100644 --- a/packages/navigation/src/index.ts +++ b/packages/navigation/src/index.ts @@ -1,3 +1,2 @@ -export * from './hash/index.js'; -export * from './BasicNavigator.js'; -export * from './types.js'; +export * from './HashNavigator/index.js'; +export * from './Navigator/index.js'; diff --git a/packages/navigation/src/types.ts b/packages/navigation/src/types.ts deleted file mode 100644 index 3ed843d89..000000000 --- a/packages/navigation/src/types.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { EventName } from '@tma.js/event-emitter'; - -export interface NavigationEntry { - pathname: string; - search: string; -} - -/** - * Browser history state which associates with navigator state. - */ -export interface NavigatorState { - cursor: number; - entries: NavigationEntry[]; -} - -export interface NavigatorOptions { - /** - * Should navigator display debug messages. - * @default false - */ - debug?: boolean; -} - -export interface NavigatorEventsMap { - /** - * Being called whenever current history changes. - * @param event - occurred event. - */ - change: (event: NavigationEntry) => void; -} - -/** - * Navigator event name. - */ -export type NavigatorEventName = EventName; - -/** - * Navigator event listener. - */ -export type NavigatorEventListener = NavigatorEventsMap[E]; - -/** - * Entry information allowed to be used in push and replace Navigator methods. - * Should be either path or object partially describing it. - */ -export type AllowedEntry = string | { - pathname?: string; - search?: string; -}; From 0101d877605676bd1b3c9de9482a86a59a5111c3 Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Wed, 15 Nov 2023 02:52:22 +0300 Subject: [PATCH 02/10] tests(package): add some tests and config --- packages/navigation/package.json | 1 + .../tests/HashNavigator/HashNavigator.ts | 144 ++++++++++++++++++ .../navigation/tests/HashNavigator/history.ts | 49 ++++++ packages/navigation/vite.config.ts | 14 +- 4 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 packages/navigation/tests/HashNavigator/HashNavigator.ts create mode 100644 packages/navigation/tests/HashNavigator/history.ts diff --git a/packages/navigation/package.json b/packages/navigation/package.json index 2fcf68ffe..95540f24d 100644 --- a/packages/navigation/package.json +++ b/packages/navigation/package.json @@ -39,6 +39,7 @@ "scripts": { "lint": "eslint -c .eslintrc.cjs src/**/*", "typecheck": "tsc --noEmit -p tsconfig.build.json", + "test": "vitest", "build": "pnpm run build:default && pnpm run build:iife", "build:default": "vite build --config vite.config.ts", "build:iife": "vite build --config vite.iife.config.ts" diff --git a/packages/navigation/tests/HashNavigator/HashNavigator.ts b/packages/navigation/tests/HashNavigator/HashNavigator.ts new file mode 100644 index 000000000..2a7ad75ad --- /dev/null +++ b/packages/navigation/tests/HashNavigator/HashNavigator.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; + +import { HashNavigator } from '../../src/index.js'; + +// TODO: Add more tests. + +describe('constructor', () => { + it('should throw an error if entries list is empty', () => { + expect(() => new HashNavigator([], 0)).toThrow('Entries list should not be empty.'); + }); + + it('should throw an error if cursor equals to higher than entries count', () => { + expect(() => new HashNavigator([{ pathname: '/' }], 1)) + .toThrow('Cursor should be less than entries count.'); + expect(() => new HashNavigator([{ pathname: '/' }], 2)) + .toThrow('Cursor should be less than entries count.'); + }); +}); + +describe('methods', () => { + // describe('go', () => { + // }); + // + // describe('forward', () => { + // }); + // + // describe('back', () => { + // }); + + describe('getEntries', () => { + it('should return deep clone of navigator entries', () => { + const initialEntries = [{ + search: '?b', + hash: '#c', + pathname: '/a', + }]; + const navigator = new HashNavigator(initialEntries, 0); + const entries = navigator.getEntries(); + + expect(entries).toStrictEqual(initialEntries); + expect(entries).not.toBe(initialEntries); + + expect(entries[0]).toStrictEqual(initialEntries[0]); + expect(entries[0]).not.toBe(initialEntries[0]); + }); + }); + + // describe('push', () => { + // }); + // + describe('replace', () => { + it('should replace current entry', () => { + const navigator = new HashNavigator([{ + search: '?b', + hash: '#c', + pathname: '/a', + }], 0); + const [prevEntry] = navigator.getEntries(); + + expect(navigator.cursor).toBe(0); + navigator.replace('/b'); + const [entry] = navigator.getEntries(); + + expect(prevEntry).not.toStrictEqual(entry); + expect(navigator.cursor).toBe(0); + }); + }); +}); + +describe('getters', () => { + describe('pathname', () => { + it('should return current entry pathname', () => { + expect(new HashNavigator([{ pathname: '/abc' }], 0).pathname).toBe('/abc'); + }); + }); + + describe('search', () => { + it('should return current entry search', () => { + expect( + new HashNavigator([{ + search: '?a=1', + pathname: '/', + }], 0).search, + ).toBe('?a=1'); + }); + }); + + describe('hash', () => { + it('should return current entry hash', () => { + expect( + new HashNavigator([{ + hash: '#abc', + pathname: '/', + }], 0).hash, + ).toBe('#abc'); + }); + }); + + describe('path', () => { + it('should combine current entry pathname, search and hash', () => { + expect( + new HashNavigator([{ + search: '?b', + hash: '#c', + pathname: '/a', + }], 0).path, + ).toBe('/a?b#c'); + }); + }); + + describe('canGoBack', () => { + it('should return false if cursor === 0', () => { + expect( + new HashNavigator([{ pathname: '/' }], 0).canGoBack, + ).toBe(false); + }); + + it('should return true if cursor > 0', () => { + expect( + new HashNavigator([ + { pathname: '/a' }, + { pathname: '/b' }, + ], 1).canGoBack, + ).toBe(true); + }); + }); + + describe('canGoForward', () => { + it('should return false if cursor === entries.length - 1', () => { + expect( + new HashNavigator([{ pathname: '/' }], 0).canGoForward, + ).toBe(false); + }); + + it('should return true if cursor < entries.length - 1', () => { + expect( + new HashNavigator([ + { pathname: '/a' }, + { pathname: '/b' }, + ], 0).canGoForward, + ).toBe(true); + }); + }); +}); diff --git a/packages/navigation/tests/HashNavigator/history.ts b/packages/navigation/tests/HashNavigator/history.ts new file mode 100644 index 000000000..f744251e4 --- /dev/null +++ b/packages/navigation/tests/HashNavigator/history.ts @@ -0,0 +1,49 @@ +import { describe, vi, it, afterEach, expect } from 'vitest'; +import { drop, go } from '../../src/HashNavigator/history.js'; + +function mockHistoryLength(length: number) { + vi + .spyOn(window.history, 'length', 'get') + .mockImplementation(() => length); +} + +function mockPushState(impl?: History['pushState']) { + const spy = vi.spyOn(window.history, 'pushState'); + + if (impl) { + spy.mockImplementation(impl); + } +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// TODO: Add more tests. + +describe('go', () => { + it('should return true if delta is 0', () => { + expect(go(0)).resolves.toBe(true); + }); +}); + +describe('drop', () => { + it('should do nothing in case, history contains only 1 element', () => { + const pushStateSpy = vi.fn(); + mockHistoryLength(1); + mockPushState(pushStateSpy); + + expect(pushStateSpy).not.toHaveBeenCalled(); + }); + + it('should push empty state', () => { + const pushStateSpy = vi.fn(); + mockHistoryLength(2); + mockPushState(pushStateSpy); + + drop(); + + expect(pushStateSpy).toHaveBeenCalledOnce(); + expect(pushStateSpy).toHaveBeenCalledWith(null, ''); + }); +}); diff --git a/packages/navigation/vite.config.ts b/packages/navigation/vite.config.ts index affd90261..9ac90b5b1 100644 --- a/packages/navigation/vite.config.ts +++ b/packages/navigation/vite.config.ts @@ -1,9 +1,19 @@ -import { createViteConfig } from 'build-utils'; +import { createViteConfig, createVitestConfig } from 'build-utils'; import packageJson from './package.json'; export default createViteConfig({ packageName: packageJson.name, formats: ['es', 'cjs'], - external: ['@tma.js/bridge', '@tma.js/parsing', '@tma.js/event-emitter', '@tma.js/logger'], + external: Object.keys(packageJson.dependencies), + test: createVitestConfig({ + environment: 'happy-dom', + // TODO: Uncomment, when more tests are ready. + // coverage: { + // branches: 100, + // functions: 100, + // statements: 100, + // lines: 100, + // }, + }), }); From 3ed7fdf5ea2cf24b2a74252a335272eb9ac75299 Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Wed, 15 Nov 2023 02:52:35 +0300 Subject: [PATCH 03/10] chore(deps): remove @tma.js/parsing --- packages/navigation/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/navigation/package.json b/packages/navigation/package.json index 95540f24d..50474ff4a 100644 --- a/packages/navigation/package.json +++ b/packages/navigation/package.json @@ -47,8 +47,7 @@ "dependencies": { "@tma.js/bridge": "workspace:*", "@tma.js/event-emitter": "workspace:*", - "@tma.js/logger": "workspace:*", - "@tma.js/parsing": "workspace:*" + "@tma.js/logger": "workspace:*" }, "devDependencies": { "build-utils": "workspace:*", From 0a39b2266eedfcade31f78a9c9897d45f6b7a2af Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Wed, 15 Nov 2023 02:52:48 +0300 Subject: [PATCH 04/10] chore(readme): update readme --- packages/navigation/README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/navigation/README.md b/packages/navigation/README.md index 18530d365..a9d0a3959 100644 --- a/packages/navigation/README.md +++ b/packages/navigation/README.md @@ -1,26 +1,26 @@ # @tma.js/navigation -[code-badge]: https://img.shields.io/badge/source-black?logo=github +[npm-link]: https://npmjs.com/package/@tma.js/navigation -[code-link]: https://github.com/Telegram-Mini-Apps/tma.js/tree/master/packages/navigation +[npm-shield]: https://img.shields.io/npm/v/@tma.js/navigation?logo=npm -[docs-link]: https://docs.telegram-mini-apps.com/packages/typescript/tma-js-navigation +![[npm-link]][npm-shield] -[docs-badge]: https://img.shields.io/badge/documentation-blue?logo=gitbook&logoColor=white +Package for manipulating Mini App navigation on the Telegram Mini Apps platform. -[npm-link]: https://npmjs.com/package/@tma.js/navigation +This library is a part of TypeScript packages ecosystem around Telegram Web +Apps. To see full documentation and other libraries, please, visit +[this](https://docs.telegram-mini-apps.com/packages/typescript/tma-js-navigation) link. -[npm-badge]: https://img.shields.io/npm/v/@tma.js/navigation?logo=npm +## Installation -[size-badge]: https://img.shields.io/bundlephobia/minzip/@tma.js/navigation +```bash +npm i @tma.js/navigation +``` -[![NPM][npm-badge]][npm-link] -![Size][size-badge] -[![docs-badge]][docs-link] -[![code-badge]][code-link] +or -Custom navigation classes and utilities which could be used to control Mini App navigation. +```bash +yarn add @tma.js/navigation +``` -This library is a part of TypeScript packages ecosystem around Telegram Mini Apps. You can learn more -about this package in -this [documentation][docs-link]. From 1012ddab71a9e29907f14ff08729b7aebb70e758 Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Wed, 15 Nov 2023 02:53:00 +0300 Subject: [PATCH 05/10] chore(lock): update lock --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68fd48169..dd5dcdbe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,9 +237,6 @@ importers: '@tma.js/logger': specifier: workspace:* version: link:../logger - '@tma.js/parsing': - specifier: workspace:* - version: link:../parsing devDependencies: build-utils: specifier: workspace:* From dc7739d1dd405c48afe99cf95e7f7888bb214211 Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Wed, 15 Nov 2023 02:53:30 +0300 Subject: [PATCH 06/10] feat(packages): add @tma.js/navigation docs --- apps/docs/.vitepress/config.mts | 1 + .../packages/typescript/tma-js-navigation.md | 375 ++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 apps/docs/packages/typescript/tma-js-navigation.md diff --git a/apps/docs/.vitepress/config.mts b/apps/docs/.vitepress/config.mts index 848949f46..2c668e343 100644 --- a/apps/docs/.vitepress/config.mts +++ b/apps/docs/.vitepress/config.mts @@ -137,6 +137,7 @@ export default defineConfig({ link: '/packages/typescript/tma-js-init-data-node', }, { text: '@tma.js/launch-params', link: '/packages/typescript/tma-js-launch-params' }, + { text: '@tma.js/navigation', link: '/packages/typescript/tma-js-navigation' }, { text: '@tma.js/sdk', collapsed: true, diff --git a/apps/docs/packages/typescript/tma-js-navigation.md b/apps/docs/packages/typescript/tma-js-navigation.md new file mode 100644 index 000000000..fedb4eadb --- /dev/null +++ b/apps/docs/packages/typescript/tma-js-navigation.md @@ -0,0 +1,375 @@ +--- +outline: [2, 3] +--- + +# @tma.js/navigation + +[npm-link]: https://npmjs.com/package/@tma.js/navigation + +[npm-shield]: https://img.shields.io/npm/v/@tma.js/navigation?logo=npm + +![[npm-link]][npm-shield] + +Package for manipulating Mini App navigation on the Telegram Mini Apps platform. + +## Installation + +::: code-group + +```bash [pnpm] +pnpm i @tma.js/navigation +``` + +```bash [npm] +npm i @tma.js/navigation +``` + +```bash [yarn] +yarn add @tma.js/navigation +``` + +::: + +## About navigation + +Navigation in mobile applications has a rather complex nature. We use the term 'mobile' here +because, at the moment, Mini Apps are designed to resemble mobile applications, so the navigation +should follow suit. + +Since Mini Apps are web applications meant to emulate mobile interfaces, it's essential to compare +browser and mobile navigation mechanisms. It's safe to say that they don't have much in common. + +In simple terms, browser navigation operates over a two-linked list of history entries. Developers +can navigate through each node using forward or back navigation methods. It's also possible to +replace the current entry and add new entries, removing all those placed after the current one. + +On the contrary, mobile navigation allows developers to use a multi-navigation context, implying the +existence of several navigation contexts across the application. + +However, browser navigation comes with rather strict restrictions that make it challenging to +comfortably mimic the behavior seen in mobile applications within Telegram Mini Apps. This is why +this package was implemented. + +## `HashNavigator` + +`HashNavigator` is a class that implements a navigator working with the browser's History API. This +navigator extends the standard one, which provides basic navigation functionality, but it also +applies logic specific to the browser's history. + +Creating an instance of `HashNavigator` and using its methods doesn't automatically update the +browser history. To achieve this, developers should [attach](#attaching) it. Until then, the +navigator will only update its internal state and notify all its subscribers about changes. Manual +attachment is necessary to prevent situations where several navigators of this type are created, and +each tries to control the browser history with its internal state. + +This navigator employs some hacks related to the browser history, resulting in all its navigation +methods (`push`, `replace`, `go`, etc.) returning promises that will be resolved when the browser +completes the navigation. Internally, these methods update the browser's history to provide +the correct native navigation UI to a user. + +::: info +In most cases, these methods don't take much time to complete. As a general observation, it takes +about 10ms for the browser to finish navigation. +::: + +### Instantiating + +To create an instance of this class, developers can use the class constructor, which accepts +navigation entries, a cursor (index) pointing to the entry in the entries list, and additional +options: + +```typescript +import { HashNavigator } from '@tma.js/navigation'; + +const navigator = new HashNavigator( + [{ + pathname: '/index', + search: '?a=123', + hash: '#somehash' + }], + 0, + { debug: true }, +); +``` + +::: warning +If an empty entries list or a cursor pointing to a non-existent entry is passed, the constructor +will throw an appropriate error. +::: + +Developers are also allowed to use static `fromLocation` method. This method +creates a navigator instance with only one entry, which will be constructed from the +`window.location.hash`: + +```typescript +import { HashNavigator } from '@tma.js/navigation'; + +const navigator = HashNavigator.fromLocation(); +``` + +### Attaching + +To allow navigator to control the browser's history, it is required to attach via `attach` +method: + +```typescript +import { HashNavigator } from '@tma.js/navigation'; + +const navigator = new HashNavigator(...); + +navigator.attach().then(() => { + console.log('Attachment completed'); +}); +``` + +This method returns a promise that will be resolved when the attachment is completed. + +To stop navigator from modifying the browser's history, use `detach` method: + +```typescript +navigator.detach(); +``` + +### Navigating + +The navigator provides a list of methods for developers to manipulate the navigation history. + +#### `go` + +This method moves the entries cursor by the specified delta. It returns a promise that will be +resolved when the navigation is completed. + +```typescript +// Go back in history by 1 entry. +navigator.go(-1).then(() => { + console.log('Navigation completed'); +}); +``` + +#### `back` + +This method is the shorthand for `go(-1)`: + +```typescript +navigator.back().then(() => { + console.log('Navigation completed'); +}); +``` + +#### `forward` + +This method is the shorthand for `go(1)`: + +```typescript +navigator.forward().then(() => { + console.log('Navigation completed'); +}); +``` + +#### `push` + +To add a new navigation entry, use the `push` method. This method allows passing a new path, +described either by a string or an object with optional properties `pathname`, `search`, and `hash`. + +Pushing a new navigation entry causes the navigator to replace all entries starting from the next +one relative to the current cursor with the new one. In other words, it functions similarly to the +browser's History API. + +In the upcoming examples, let's assume that the current entry is `/home/blog`. + +##### Absolute pathname + +Specifying an absolute path will not merge it with the current one; instead, it will be used in its +entirety: + +```typescript +navigator.push('/database'); +// or +navigator.push({ pathname: '/database' }); +// Navigator will add new entry, and current one becomes /database +``` + +##### Relative pathname + +Specifying a relative pathname will exhibit the same behavior as in the browser: + +```typescript +navigator.push('license'); +// or +navigator.push({ pathname: 'license' }); +// Navigator will add new entry, and current one becomes /home/license +``` + +##### Search + +To add an entry with query parameters, use question mark (`?`) or `search` entry property: + +```typescript +navigator.push('?id=1'); +// or +navigator.push({ search: '?id=1' }); +// Navigator will add new entry, and current one becomes /home/blog?id=1 +``` + +::: info +Pushing a new pathname with different or missing query parameters will result in the loss of current +query parameters. To prevent this, the developer should pass them again. +::: + +##### Hash + +Adding the hash part of the entry follows the same logic as [search](#search), but using a +hashtag (`#`) and the `hash` entry property. + +```typescript +navigator.push('#introduction'); +// or +navigator.push({ hash: '#introduction' }); +// Navigator will add new entry, and current one becomes /home/blog#introduction +``` + +#### `replace` + +The `replace` method functions similarly to the [push](#push) method, but it doesn't create a new +entry. Instead, it replaces the current one. + +### Properties + +

+ +#### `pathname` + +Current entry pathname. + +```typescript +const navigator = new HashNavigator([{ pathname: '/index' }], 0); +navigator.pathname; // '/index' +``` + +#### `search` + +Current entry query parameters. + +```typescript +const navigator = new HashNavigator([{ search: '?id=177' }], 0); +navigator.search; // '?id=177' +``` + +#### `hash` + +Current entry hash. + +```typescript +const navigator = new HashNavigator([{ hash: '#intro' }], 0); +navigator.hash; // '#hash' +``` + +#### `path` + +Current entry path. It concatenates [pathname](#pathname), [search](#search) and [hash](#hash). + +```typescript +const navigator = new HashNavigator( + [{ + pathname: '/index', + search: '?a=123', + hash: '#somehash' + }], + 0, +); +navigator.path; // '/index?a=123#somehash' +``` + +#### `cursor` + +Current entry cursor in entries list. + +```typescript +const navigator = new HashNavigator([ + { pathname: '/' }, + { pathname: '/blog' }, +], 0); +navigator.cursor; // 0 +navigator.forward(); +navigator.cursor; // 1 +``` + +#### `canGoBack` + +True if navigator can go back in navigation history. + +```typescript +const navigator = new HashNavigator([{ pathname: '/' }], 0); +navigator.canGoBack; // false + +navigator.push('/blog'); +navigator.canGoBack; // true +``` + +#### `canGoForward` + +True if navigator can go forward in navigation history. + +```typescript +const navigator = new HashNavigator([{ pathname: '/' }], 0); +navigator.canGoForward; // false + +navigator.push('/blog'); +navigator.back(); +navigator.canGoForward; // true +``` + +## Example + +Here is the example how developer could instantiate the stable instance of `HashNavigator`. + +```typescript +import { + HashNavigator, + type HashNavigatorOptions, +} from '@tma.js/navigation'; +import { retrieveLaunchData } from '@tma.js/launch-params'; + +function createNavigator() { + let navigator: HashNavigator | undefined; + const navigatorOptions: HashNavigatorOptions = { + debug: true, + }; + + // If page was reloaded, we assume that navigator had to previously save + // its state in the session storage. + if (retrieveLaunchData().isPageReload) { + const stateRaw = sessionStorage.getItem('hash-navigator-state'); + if (stateRaw) { + try { + const { cursor, entries } = JSON.parse(stateRaw); + navigator = new HashNavigator(entries, cursor, navigatorOptions); + } catch (e) { + console.error('Unable to restore hash navigator state.', e); + } + } + } + + // In case, we could not restore its state, or it is the fresh start, we + // can create empty navigator. + if (!navigator) { + navigator = new HashNavigator([{}], 0, navigatorOptions); + } + + const saveState = (nav: HashNavigator) => { + sessionStorage.setItem('hash-navigator-state', JSON.stringify({ + cursor: nav.cursor, + entries: nav.getEntries(), + })); + } + + // Whenever navigator changes its state, we save it in the session storage. + navigator.on('change', ({ navigator: nav }) => saveState(nav)); + + // Save initial state to make sure nothing will break when page will + // be reloaded. + saveState(navigator); + + return navigator; +} +``` From 8c7e8a84a060a67d3d4c54c3e59499f434ecef59 Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Wed, 15 Nov 2023 02:54:02 +0300 Subject: [PATCH 07/10] fix(launch-params): fix some typos --- apps/docs/launch-parameters/init-data.md | 34 ++++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/docs/launch-parameters/init-data.md b/apps/docs/launch-parameters/init-data.md index 8d800bd3d..017d52ab8 100644 --- a/apps/docs/launch-parameters/init-data.md +++ b/apps/docs/launch-parameters/init-data.md @@ -216,9 +216,9 @@ This section provides a complete list of parameters used in initialization data. - Optional. An object containing information about the user-the interlocutor of the - current user. It is returned only for private chats, as well as for Mini App opened - through the attachments menu. + Optional. An object containing data about the chat partner of the current user in + the chat where the bot was launched via the attachment menu. Returned only for private chats + and only for Mini Apps launched via the attachment menu. @@ -230,7 +230,7 @@ This section provides a complete list of parameters used in initialization data. Optional. The value of the startattach or startapp query parameter specified in the link. It is returned only for Mini Apps opened through the - attachments menu. + attachment menu. @@ -329,16 +329,16 @@ Describes the chat information. Describes information about a user or bot. -| Property | Type | Description | -|--------------------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| added_to_attachment_menu | `boolean` | _Optional_. True, if this user added the bot to the attachment menu. | -| allows_write_to_pm | `boolean` | _Optional_. True, if this user allowed the bot to message them. | -| is_premium | `boolean` | _Optional_. Has the user purchased Telegram Premium. | -| first_name | `string` | Bot or user name. | -| id | `number` | Bot or user ID. | -| is_bot | `boolean` | _Optional_. Is the user a bot. | -| is_premium | `boolean` | _Optional_. Has the user purchased Telegram Premium. | -| last_name | `string` | _Optional_. User's last name. | -| language_code | `string` | _Optional_. [IETF](https://en.wikipedia.org/wiki/IETF_language_tag) user's language. | -| photo_url | `string` | _Optional_. Link to the user's or bot's photo. Photos can have formats `.jpeg` and `.svg'. It is returned only for Mini Apps opened through the attachments menu. | -| username | `string` | _Optional_. Login of the bot or user. | \ No newline at end of file +| Property | Type | Description | +|--------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| added_to_attachment_menu | `boolean` | _Optional_. True, if this user added the bot to the attachment menu. | +| allows_write_to_pm | `boolean` | _Optional_. True, if this user allowed the bot to message them. | +| is_premium | `boolean` | _Optional_. Has the user purchased Telegram Premium. | +| first_name | `string` | Bot or user name. | +| id | `number` | Bot or user ID. | +| is_bot | `boolean` | _Optional_. Is the user a bot. | +| is_premium | `boolean` | _Optional_. Has the user purchased Telegram Premium. | +| last_name | `string` | _Optional_. User's last name. | +| language_code | `string` | _Optional_. [IETF](https://en.wikipedia.org/wiki/IETF_language_tag) user's language. | +| photo_url | `string` | _Optional_. Link to the user's or bot's photo. Photos can have formats `.jpeg` and `.svg`. It is returned only for Mini Apps opened through the attachment menu. | +| username | `string` | _Optional_. Login of the bot or user. | \ No newline at end of file From e8a36f102924a1c82f5aab6f20f2969193fb3284 Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Wed, 15 Nov 2023 03:01:37 +0300 Subject: [PATCH 08/10] feat(utils): implement getHash utility --- .../solid-router-integration/src/getHash.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 packages/solid-router-integration/src/getHash.ts diff --git a/packages/solid-router-integration/src/getHash.ts b/packages/solid-router-integration/src/getHash.ts new file mode 100644 index 000000000..d23030a1c --- /dev/null +++ b/packages/solid-router-integration/src/getHash.ts @@ -0,0 +1,17 @@ +/** + * Returns string after first met "#" symbol. + * @param value - string to take hash part from. + * + * @example No hash. + * getHash('/path'); // null + * + * @example Has hash. + * getHash('/path#abc'); // 'abc' + * + * @example Has double hash. + * getHash('/path#abc#another'); // 'abc#another' + */ +export function getHash(value: string): string | null { + const match = value.match(/#(.+)/); + return match ? match[1] : null; +} From da24ff9a82d614e00ca9d63b7f682571e73ac2f8 Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Wed, 15 Nov 2023 03:02:13 +0300 Subject: [PATCH 09/10] fix(createintegration): actualize according to navigation package changes --- .../src/createIntegration.ts | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/solid-router-integration/src/createIntegration.ts b/packages/solid-router-integration/src/createIntegration.ts index 1571d2cf2..2e4bfcfee 100644 --- a/packages/solid-router-integration/src/createIntegration.ts +++ b/packages/solid-router-integration/src/createIntegration.ts @@ -5,12 +5,13 @@ import { createIntegration as createRouterIntegration, type RouterIntegration, } from '@solidjs/router'; -import { - getHash, - type HashNavigator, - type NavigatorEventListener, +import type { + HashNavigator, + HashNavigatorEventListener, } from '@tma.js/navigation'; +import { getHash } from './getHash.js'; + type Accessor = () => T; /** @@ -45,19 +46,19 @@ function scrollToHash(hash: string, fallbackTop: boolean) { /** * Creates integration for `@solidjs/router` package. - * @param getNavigator - HashNavigator accessor. + * @param navigator - HashNavigator accessor. */ -export function createIntegration(getNavigator: Accessor): RouterIntegration { +export function createIntegration(navigator: Accessor): RouterIntegration { return createRouterIntegration( // Router calls this getter whenever it wants to get actual navigation state. - () => getNavigator().path, + () => navigator().path, // Setter is called when some of the router functionality was used. For example, . ({ scroll = false, value = '', replace = false }) => { if (replace) { - getNavigator().replace(value); + void navigator().replace(value); } else { - void getNavigator().push(value); + void navigator().push(value); } const hash = getHash(value); if (!hash) { @@ -74,19 +75,23 @@ export function createIntegration(getNavigator: Accessor): Router // This function is called when Router context is initialized. It is the best place to // bind to navigator state changes, which could occur outside. (notify: (value: string) => void) => { - const onChange: NavigatorEventListener<'change'> = ({ search, pathname }) => { - notify(`${pathname}${search}`); - }; - const navigator = getNavigator(); - navigator.on('change', onChange); + const onChange: HashNavigatorEventListener<'change'> = (event) => { + const { + to: { + hash, + pathname, + search, + }, + } = event; - return () => { - navigator.off('change', onChange); + notify(`${pathname}${search}${hash}`); }; + + return navigator().on('change', onChange); }, { go(delta: number) { - getNavigator().go(delta); + void navigator().go(delta); }, renderPath: (path: string) => `#${path}`, parsePath: (str: string) => { From 657c2b13b4ab1de6143e37c3e8be1b656297eba6 Mon Sep 17 00:00:00 2001 From: Vladislav Kibenko Date: Wed, 15 Nov 2023 03:06:18 +0300 Subject: [PATCH 10/10] docs(changeset): First major package release. --- .changeset/short-houses-begin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/short-houses-begin.md diff --git a/.changeset/short-houses-begin.md b/.changeset/short-houses-begin.md new file mode 100644 index 000000000..74c24cb46 --- /dev/null +++ b/.changeset/short-houses-begin.md @@ -0,0 +1,5 @@ +--- +"@tma.js/navigation": major +--- + +First major package release.