Compare commits

..

9 Commits

Author SHA1 Message Date
Maurice 35e0613986 test(native): read the listener names as strings, so the appLinks case typechecks 2026-11-13 08:34:05 +01:00
Maurice ccfa92f650 feat(mobile): the assistant is an entry of the dock, sorted like the pages
The home assistant sat behind the More button on every page outside a
trip, so a dock with free slots showed "..." for a single entry. It is now
an entry of the dock list while its addon is on and it can run for the
user: an account that never sorted its dock gets it in a slot the addons
leave free, and Settings > Appearance > Mobile can move it between the bar
and More like any page.
2026-11-13 08:27:31 +01:00
Maurice fdb2095ced fix(native): a widget tap opens its page every time, also after iOS ended the page
A widget or Live Activity tap reached the UI as Capacitor's appUrlOpen, or
as the launch URL. When iOS had ended the web view's process in the
background, the event went to the old page's listeners and was lost, and
the reloaded page found the same link in getLaunchUrl that it had already
followed once and skipped it: the second tap on the packing widget opened
the start page.

The binary now takes com.liketrek.trek://navigate links itself and hands
them over like a quick action, held until the UI takes it and kept for a
page that is still loading. It reports the feature appLinks, and the UI
only follows the link through Capacitor for binaries without it.
2026-11-13 08:27:23 +01:00
Maurice d6a6b49b6b test(planner): the #3074 case in a file of its own, and the category pick inside guessedCategory, so both files stay within their size 2026-11-12 23:18:51 +01:00
Maurice 0855bafca7 fix(search): place searches find a name typed without its accents (#3075)
"Obidos" did not find "Óbidos", nor "Sao Bras" "São Brás". The search of the
places list, a collection, the saved-place picker, the import from a trip
and the location picker now compare without case and Latin accents, with
letters such as ß, ø or ł folded to their plain spelling. Marks outside Latin
script stay, since there they change the word.
2026-11-12 23:06:36 +01:00
Maurice a39f3fe40c fix(planner): a place added from a collection keeps its category (#3074)
The place form guessed the category of a picked place from its kind, so a
place taken from a collection inside a trip lost the category it was saved
under, while adding it from the collection kept it. A picked place that
carries a category this server has now brings it along; a category chosen
in the form by hand still wins.
2026-11-12 23:06:35 +01:00
Maurice f13a0aa6ee fix(collections): pins on the map show their place's category (#3073)
The server nests a saved place's category, while the markers and the hover
card read the flat fields a trip place carries, so every pin on a
collection's map was the grey default. The map now hands its places on with
the category's colour, icon and name where the markers look for them.
2026-11-12 22:02:05 +01:00
Maurice 10d1a61614 fix(ios): a running TREK Cloud subscription is found on a fresh install too
The check before a purchase read only the entitlements on the device. Those
can be empty while the subscription runs, so the app offered the plans and
the purchase changed the running subscription's plan in the App Store. The
status of the subscription group now decides when the device has none. A
renewal the device has not finished yet no longer counts as a purchase
waiting for its instance either, which also let the check through.
2026-11-12 21:21:51 +01:00
github-actions[bot] 2bcfc1bfcb chore: bump version to 5.1.4 [skip ci] 2026-11-12 19:51:06 +00:00
29 changed files with 531 additions and 157 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 5.1.3 version: 5.1.4
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "5.1.3" appVersion: "5.1.4"
+29 -2
View File
@@ -17,6 +17,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
for context in connectionOptions.urlContexts where context.url.isFileURL { for context in connectionOptions.urlContexts where context.url.isFileURL {
TrekSharedFiles.receive(context.url) TrekSharedFiles.receive(context.url)
} }
// Launched by a tap on a widget or a Live Activity: held like a quick action.
// Capacitor still hears of the link, the UI ignores it there (feature appLinks).
for context in connectionOptions.urlContexts {
if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) }
}
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions) SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
} }
@@ -40,7 +45,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// com.liketrek.trek://share: the share sheet left an item for the import. // com.liketrek.trek://share: the share sheet left an item for the import.
let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" } let handoffs = URLContexts.filter { $0.url.scheme == "com.liketrek.trek" && $0.url.host == "share" }
if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() } if !handoffs.isEmpty { TrekSharedFiles.receiveFromShareExtension() }
let others = URLContexts.subtracting(files).subtracting(handoffs) // com.liketrek.trek://navigate/<path>: a widget or Live Activity tap. Through
// Capacitor it was an event a page reloading after the system ended its process
// never heard, and that page then took the same link for one already followed.
let appLinks = URLContexts.filter { TrekQuickActions.appLinkPath($0.url) != nil }
for context in appLinks {
if let path = TrekQuickActions.appLinkPath(context.url) { TrekQuickActions.open(path: path) }
}
let others = URLContexts.subtracting(files).subtracting(handoffs).subtracting(appLinks)
if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) } if !others.isEmpty { SceneDelegateProxy.shared.scene(scene, openURLContexts: others) }
} }
@@ -104,10 +116,25 @@ enum TrekQuickActions {
static func open(_ item: UIApplicationShortcutItem) -> Bool { static func open(_ item: UIApplicationShortcutItem) -> Bool {
guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false } guard let path = item.userInfo?[pathKey] as? String, path.hasPrefix("/") else { return false }
if let onOpen { onOpen(path) } else { pending = path } open(path: path)
return true return true
} }
/// A path to open: at once when the UI listens, else held for it to take.
/// Widget and Live Activity taps come this way too, so each is followed once.
static func open(path: String) {
if let onOpen { onOpen(path) } else { pending = path }
}
/// The path in com.liketrek.trek://navigate/<path>, the link widgets and Live Activities open.
static func appLinkPath(_ url: URL) -> String? {
let prefix = "com.liketrek.trek://navigate"
guard url.absoluteString.hasPrefix(prefix) else { return nil }
let path = String(url.absoluteString.dropFirst(prefix.count))
if path.isEmpty { return "/" }
return path.hasPrefix("/") ? path : nil
}
/// The action the app was launched with, once. /// The action the app was launched with, once.
static func take() -> String? { static func take() -> String? {
defer { pending = nil } defer { pending = nil }
+47 -9
View File
@@ -69,7 +69,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an /// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing. /// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
static var features: [String] { static var features: [String] {
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate", "filePreview"] var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "appLinks", "clientCertificate", "filePreview"]
if canAddWalletPasses { names.append("wallet") } if canAddWalletPasses { names.append("wallet") }
if TrekWatchLink.isAvailable { names.append("watch") } if TrekWatchLink.isAvailable { names.append("watch") }
return names return names
@@ -94,8 +94,9 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
TrekKeyCommands.onPress = { [weak self] id in TrekKeyCommands.onPress = { [weak self] id in
self?.notifyListeners("keyCommand", data: ["id": id]) self?.notifyListeners("keyCommand", data: ["id": id])
} }
// Kept until a listener takes it: a tap while the page reloads reaches the new page.
TrekQuickActions.onOpen = { [weak self] path in TrekQuickActions.onOpen = { [weak self] path in
DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path]) } DispatchQueue.main.async { self?.notifyListeners("quickAction", data: ["path": path], retainUntilConsumed: true) }
} }
} }
@@ -685,7 +686,7 @@ extension TrekShellPlugin {
// A held one that was delivered stops the purchase here. One that was paid // A held one that was delivered stops the purchase here. One that was paid
// but never set up is handed back as bought, so its instance is set up now. // but never set up is handed back as bought, so its instance is set up now.
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") { if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
if await TrekStore.isUnfinished(held.id) { if await TrekStore.awaitingSetup(held) {
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId]) call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
} else { } else {
call.resolve(["kind": "subscribed"]) call.resolve(["kind": "subscribed"])
@@ -759,8 +760,8 @@ extension TrekShellPlugin {
call.resolve(["held": false]) call.resolve(["held": false])
return return
} }
let unfinished = await TrekStore.isUnfinished(held.id) let awaiting = await TrekStore.awaitingSetup(held)
call.resolve(["held": !unfinished]) call.resolve(["held": !awaiting])
} }
} }
} }
@@ -793,17 +794,54 @@ enum TrekStore {
} }
} }
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64, productId: String)? { typealias Held = (jws: String, id: UInt64, productId: String, firstPurchase: Bool)
var newest: (date: Date, jws: String, id: UInt64, productId: String)?
/// The subscription this Apple ID holds under `prefix`, newest first. The
/// entitlements on the device come first; on a fresh install they can still
/// be empty while the subscription runs, so the status of the subscription
/// group, which StoreKit asks the App Store for, decides then.
static func currentEntitlement(prefix: String) async -> Held? {
var newest: (date: Date, held: Held)?
for await result in Transaction.currentEntitlements { for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result, guard case .verified(let transaction) = result,
transaction.productID.hasPrefix(prefix), transaction.productID.hasPrefix(prefix),
transaction.revocationDate == nil else { continue } transaction.revocationDate == nil else { continue }
if newest == nil || transaction.purchaseDate > newest!.date { if newest == nil || transaction.purchaseDate > newest!.date {
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id, transaction.productID) newest = (transaction.purchaseDate, held(result.jwsRepresentation, transaction))
} }
} }
return newest.map { (jws: $0.jws, id: $0.id, productId: $0.productId) } if let newest { return newest.held }
return await subscriptionGroupHeld(prefix: prefix)
}
/// The plans the control plane sells (`cloud.<plan>.<monthly|yearly>`, as
/// productIdFor in iap.ts); one of them is enough to find their group.
private static let plans = ["start", "basis", "plus", "pro"]
private static func subscriptionGroupHeld(prefix: String) async -> Held? {
let ids = plans.flatMap { ["\(prefix)\($0).monthly", "\(prefix)\($0).yearly"] }
guard let products = try? await Product.products(for: ids),
let group = products.compactMap({ $0.subscription?.subscriptionGroupID }).first,
let statuses = try? await Product.SubscriptionInfo.status(for: group) else { return nil }
let running: [Product.SubscriptionInfo.RenewalState] = [.subscribed, .inGracePeriod, .inBillingRetryPeriod]
for status in statuses where running.contains(status.state) {
if case .verified(let transaction) = status.transaction, transaction.revocationDate == nil {
return held(status.transaction.jwsRepresentation, transaction)
}
}
return nil
}
private static func held(_ jws: String, _ transaction: Transaction) -> Held {
(jws: jws, id: transaction.id, productId: transaction.productID, firstPurchase: transaction.originalID == transaction.id)
}
/// A first purchase that was paid but whose instance was never set up: handed
/// back as bought so it is set up now. A renewal the device has not finished
/// yet is no such purchase; its subscription is running and counts as held.
static func awaitingSetup(_ held: Held) async -> Bool {
guard held.firstPurchase else { return false }
return await isUnfinished(held.id)
} }
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists. /// Paid but not delivered yet: the shell finishes a transaction once its instance exists.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/client", "name": "@trek/client",
"version": "5.1.3", "version": "5.1.4",
"engines": { "engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}, },
@@ -12,6 +12,7 @@ import L from 'leaflet';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, waitFor } from '../../../tests/helpers/render'; import { act, render, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store'; import { resetAllStores } from '../../../tests/helpers/store';
import { withMarkerCategory } from '../../pages/collections/collectionsModel';
import CollectionMap from './CollectionMap'; import CollectionMap from './CollectionMap';
vi.mock('../../hooks/useGeolocation', () => ({ vi.mock('../../hooks/useGeolocation', () => ({
@@ -258,3 +259,26 @@ describe('CollectionMap framing', () => {
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true); expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
}); });
}); });
describe('CollectionMap pins (#3073)', () => {
const MUSEUM = { id: 4, name: 'Museum', color: '#7c3aed', icon: 'Landmark' };
it('FE-COMP-COLMAP-008: a saved place carries its category where the markers read it', () => {
const pin = withMarkerCategory({ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM });
expect(pin).toMatchObject({ category_name: 'Museum', category_color: '#7c3aed', category_icon: 'Landmark' });
expect(withMarkerCategory(place(2, 'Somewhere', 48.2, 16.37))).toMatchObject({
category_name: null,
category_color: null,
category_icon: null,
});
});
it('FE-COMP-COLMAP-009: the pin of a place with a category wears its colour, not the grey default', async () => {
const { view } = await renderMap([
{ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM },
]);
await waitFor(() => expect(view.container.querySelector('.leaflet-marker-icon')).not.toBeNull());
const html = view.container.querySelector('.leaflet-marker-icon')?.innerHTML ?? '';
expect(html.toLowerCase()).toContain('#7c3aed');
});
});
@@ -1,7 +1,7 @@
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { MapViewAuto } from '../Map/MapViewAuto' import { MapViewAuto } from '../Map/MapViewAuto'
import type { CollectionPlace } from '@trek/shared' import type { CollectionPlace } from '@trek/shared'
import { mappablePlaces } from '../../pages/collections/collectionsModel' import { mappablePlaces, withMarkerCategory } from '../../pages/collections/collectionsModel'
import { useTileUrl } from '../../hooks/useTileUrl' import { useTileUrl } from '../../hooks/useTileUrl'
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults' import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
import type { ViewportPadding } from '../../utils/mapViewport' import type { ViewportPadding } from '../../utils/mapViewport'
@@ -70,7 +70,7 @@ function framePoints(places: CollectionPlace[]): [number, number][] {
* while the map is built rather than flying in. * while the map is built rather than flying in.
*/ */
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement { export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
const pts = useMemo(() => mappablePlaces(places), [places]) const pts = useMemo(() => mappablePlaces(places).map(withMarkerCategory), [places])
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON) const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
const focusPoints = useMemo(() => framePoints(pts), [pts]) const focusPoints = useMemo(() => framePoints(pts), [pts])
// The places as a set: another list, a filter or a new place frames again, while a // The places as a set: another list, a filter or a new place frames again, while a
@@ -6,6 +6,7 @@ import { loadUnionPlaces } from '../../store/collectionUnion'
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel' import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
import type { CollectionPlace, CollectionStatus } from '@trek/shared' import type { CollectionPlace, CollectionStatus } from '@trek/shared'
import type { TranslationFn } from '../../types' import type { TranslationFn } from '../../types'
import { textMatchesSearch } from '../../utils/placeSearch'
import { fs } from '../shared/DialogShell' import { fs } from '../shared/DialogShell'
import { EditorField, INPUT, LABEL } from '../shared/dialogParts' import { EditorField, INPUT, LABEL } from '../shared/dialogParts'
import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts' import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts'
@@ -143,12 +144,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
const [shown, setShown] = useState(PAGE) const [shown, setShown] = useState(PAGE)
const visible = useMemo(() => { const visible = useMemo(() => {
const q = search.trim().toLowerCase()
const list = places.filter(p => { const list = places.filter(p => {
if (listFilter !== 'all' && p.collection_id !== listFilter) return false if (listFilter !== 'all' && p.collection_id !== listFilter) return false
if (statusFilter !== 'all' && p.status !== statusFilter) return false if (statusFilter !== 'all' && p.status !== statusFilter) return false
if (!q) return true return textMatchesSearch([p.name, p.address], search)
return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)
}) })
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center)) if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
else list.sort((a, b) => a.name.localeCompare(b.name)) else list.sort((a, b) => a.name.localeCompare(b.name))
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { formatDate } from '../../utils/formatters' import { formatDate } from '../../utils/formatters'
import { textMatchesSearch } from '../../utils/placeSearch'
import { getApiErrorMessage } from '../../types' import { getApiErrorMessage } from '../../types'
import type { Category, TranslationFn } from '../../types' import type { Category, TranslationFn } from '../../types'
import type { CollectionImportablePlace, Trip } from '@trek/shared' import type { CollectionImportablePlace, Trip } from '@trek/shared'
@@ -128,10 +129,9 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
const savedCount = items.length - newCount const savedCount = items.length - newCount
const visible = useMemo(() => { const visible = useMemo(() => {
const q = query.trim().toLowerCase()
return items return items
.filter(p => (onlyNew ? !p.already_in_list : true)) .filter(p => (onlyNew ? !p.already_in_list : true))
.filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)) .filter(p => textMatchesSearch([p.name, p.address], query))
.sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name)) .sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name))
}, [items, onlyNew, query]) }, [items, onlyNew, query])
@@ -1,11 +1,13 @@
// FE-W4NAVH-001 to FE-W4NAVH-005 // FE-W4NAVH-001 to FE-W4NAVH-008
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { CalendarDays, Globe, LayoutGrid } from 'lucide-react' import { CalendarDays, Globe, LayoutGrid, Sparkles } from 'lucide-react'
import { renderHook } from '@testing-library/react' import { renderHook } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext' import { TranslationProvider } from '../../i18n/TranslationContext'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore' import { usePluginStore } from '../../store/pluginStore'
import { useNavItems } from './navItems' import { useAssistantStore } from '../../store/assistantStore'
import type { AiStatusResponse } from '@trek/shared'
import { useDockNavItems, useNavItems } from './navItems'
function wrapper({ children }: { children: React.ReactNode }) { function wrapper({ children }: { children: React.ReactNode }) {
return <TranslationProvider>{children}</TranslationProvider> return <TranslationProvider>{children}</TranslationProvider>
@@ -16,6 +18,7 @@ const render = () => renderHook(() => useNavItems(), { wrapper })
beforeEach(() => { beforeEach(() => {
useAddonStore.setState({ addons: [] }) useAddonStore.setState({ addons: [] })
usePluginStore.setState({ plugins: [] }) usePluginStore.setState({ plugins: [] })
useAssistantStore.setState({ status: null })
}) })
describe('useNavItems', () => { describe('useNavItems', () => {
@@ -77,3 +80,33 @@ describe('useNavItems', () => {
expect(result.current).toBe(first) expect(result.current).toBe(first)
}) })
}) })
describe('useDockNavItems', () => {
const renderDock = () => renderHook(() => useDockNavItems(), { wrapper })
const assistantAddon = (enabled: boolean) =>
useAddonStore.setState({ addons: [{ id: 'ai_assistant', name: 'AI assistant', icon: 'Sparkles', type: 'integration', enabled }] as never })
it('FE-W4NAVH-006: adds the assistant after the pages while its addon is on and it can run', () => {
assistantAddon(true)
useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
const { result } = renderDock()
expect(result.current.map(i => i.id)).toEqual(['dashboard', 'assistant'])
expect(result.current[1]).toMatchObject({ to: '', label: 'Assistant', icon: Sparkles })
})
it('FE-W4NAVH-007: leaves it out while the addon is off', () => {
assistantAddon(false)
useAssistantStore.setState({ status: { available: true } as AiStatusResponse })
expect(renderDock().result.current.map(i => i.id)).toEqual(['dashboard'])
})
it('FE-W4NAVH-008: leaves it out while it cannot run for this user', () => {
assistantAddon(true)
useAssistantStore.setState({ status: { available: false } as AiStatusResponse })
expect(renderDock().result.current.map(i => i.id)).toEqual(['dashboard'])
})
})
+33 -5
View File
@@ -1,7 +1,8 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, type LucideIcon } from 'lucide-react' import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Sparkles, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useAssistantStore } from '../../store/assistantStore'
import { usePluginStore } from '../../store/pluginStore' import { usePluginStore } from '../../store/pluginStore'
import { resolvePluginIcon } from '../shared/PluginIcon' import { resolvePluginIcon } from '../shared/PluginIcon'
@@ -12,16 +13,21 @@ import { resolvePluginIcon } from '../shared/PluginIcon'
* *
* Stable ids used for persistence: `'dashboard'` (pinned), an enabled global * Stable ids used for persistence: `'dashboard'` (pinned), an enabled global
* addon's id (vacay|atlas|journey|collections|future), or `'plugin:<id>'` for a * addon's id (vacay|atlas|journey|collections|future), or `'plugin:<id>'` for a
* page plugin. Dashboard is always first and immovable. * page plugin. Dashboard is always first and immovable. The mobile dock adds
* `'assistant'` (see useDockNavItems).
*/ */
export interface NavItemDef { export interface NavItemDef {
id: string id: string
/** The page it opens; empty for the assistant, which opens the home chat instead. */
to: string to: string
label: string label: string
icon: LucideIcon icon: LucideIcon
pinned?: boolean pinned?: boolean
} }
/** The home assistant's entry in the mobile dock: a chat it opens, not a page. */
export const ASSISTANT_NAV_ID = 'assistant'
// Signature glyphs for the four catalog global addons, keyed by id so the known // Signature glyphs for the four catalog global addons, keyed by id so the known
// nav items keep their icon regardless of the (string) icon the addon row ships; // nav items keep their icon regardless of the (string) icon the addon row ships;
// any future/unknown global addon resolves through its manifest icon name. // any future/unknown global addon resolves through its manifest icon name.
@@ -33,7 +39,7 @@ const ADDON_ICONS: Record<string, LucideIcon> = {
} }
/** The bar holds Dashboard + at most this many custom items; the rest go to More. /** The bar holds Dashboard + at most this many custom items; the rest go to More.
* Two keeps the dock at 3 destinations (Dashboard + 2) so the More slot still fits. */ * Two keeps the dock at 3 entries (Dashboard + 2) so the More slot still fits. */
export const MOBILE_NAV_MAX_BAR = 2 export const MOBILE_NAV_MAX_BAR = 2
/** /**
* The built-in dock next to Dashboard for an un-customised account, in priority * The built-in dock next to Dashboard for an un-customised account, in priority
@@ -44,9 +50,11 @@ export const MOBILE_NAV_MAX_BAR = 2
* to every day, not the map of countries it already visited. Journey ships * to every day, not the map of countries it already visited. Journey ships
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas * disabled though, so an instance that never turned it on keeps the Vacay/Atlas
* dock it has today. Listing all four known global addons rather than just the * dock it has today. Listing all four known global addons rather than just the
* top two also keeps the dock filled when one of them is switched off. * top two also keeps the dock filled when one of them is switched off. The
* assistant comes last: it takes a slot only one of the addons left free, rather
* than sitting alone behind the More button.
*/ */
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections'] export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections', ASSISTANT_NAV_ID]
export function buildNavItems( export function buildNavItems(
globalAddons: { id: string; name: string; icon: string }[], globalAddons: { id: string; name: string; icon: string }[],
@@ -90,6 +98,26 @@ export function useNavItems(): NavItemDef[] {
) )
} }
/**
* The entries the mobile dock arranges: the nav items, and the home assistant
* while its addon is on and it can run for this user. The settings customizer
* sorts the same list, so the assistant can go into the bar or under More.
*/
export function useDockNavItems(): NavItemDef[] {
const { t } = useTranslation()
const items = useNavItems()
const addonOn = useAddonStore((s) => s.isEnabled('ai_assistant'))
const available = useAssistantStore((s) => s.status?.available === true)
const withAssistant = addonOn && available
return useMemo(
() =>
withAssistant
? [...items, { id: ASSISTANT_NAV_ID, to: '', label: t('assistant.launcher.tooltip'), icon: Sparkles }]
: items,
[items, withAssistant, t],
)
}
export interface MobileNavSplit { export interface MobileNavSplit {
/** Items shown directly in the dock, Dashboard first. */ /** Items shown directly in the dock, Dashboard first. */
bar: NavItemDef[] bar: NavItemDef[]
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState } from 'react' import { useEffect, useId, useRef, useState } from 'react'
import { MapPin, Search } from 'lucide-react' import { MapPin, Search } from 'lucide-react'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
import { textMatchesSearch } from '../../utils/placeSearch'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useLocationBias } from '../../hooks/useLocationBias' import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions' import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
@@ -196,7 +197,7 @@ function SourceMark({ label }: { label: string | null }) {
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) { function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
const showPicks = !value && typed.length < 3 && !!places?.length const showPicks = !value && typed.length < 3 && !!places?.length
const rows: (T | LocationPoint)[] = showPicks const rows: (T | LocationPoint)[] = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase())) ? (places ?? []).filter(p => textMatchesSearch([p.name], typed))
: results : results
return { showPicks, rows } return { showPicks, rows }
} }
@@ -0,0 +1,59 @@
// FE-PLANNER-PLACEFORM-125: a place picked from a collection inside a trip (#3074).
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { collectionsApi } from '../../api/collections';
import { useAddonStore } from '../../store/addonStore';
import PlaceFormModal from './PlaceFormModal';
vi.mock('../shared/CustomTimePicker', async () => ({
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
}));
beforeEach(() => {
resetAllStores();
seedPlaceFormStores();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('PlaceFormModal with a place from a collection', () => {
it('FE-PLANNER-PLACEFORM-125: a place picked from a collection keeps the category it was saved under (#3074)', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }],
loaded: true,
});
vi.spyOn(collectionsApi, 'list').mockResolvedValue({
collections: [{ id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }],
incomingInvites: [],
});
vi.spyOn(collectionsApi, 'get').mockResolvedValue({
collection: { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' },
places: [
{
id: 10,
collection_id: 1,
name: 'Zebra Cafe',
status: 'idea',
lat: 35.66,
lng: 139.7,
category_id: 7,
category: { id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
},
],
});
const onSave = vi.fn().mockResolvedValue(undefined);
// A cafe saved as "Must see": the guess from its kind would be the cafe category.
const categories = [
{ id: 3, name: 'Cafe', color: '#7c3aed', icon: 'Coffee' },
{ id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
];
render(<PlaceFormModal {...placeFormProps} categories={categories} onSave={onSave} />);
fireEvent.click(await screen.findByText('Zebra Cafe'));
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Zebra Cafe', category_id: '7' }));
});
});
@@ -410,3 +410,15 @@ export function formPin(form: Pick<PlaceFormData, 'lat' | 'lng'>): { lat: number
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null
return { lat, lng } return { lat, lng }
} }
/**
* The category a picked place already carries, as the form's value, when this server
* has it. A place saved in a collection keeps its category_id, and categories are the
* server's own, so it fits the trip as it stands. Guessing from the place's kind
* instead dropped it (#3074).
*/
export function savedCategoryId(result: Record<string, unknown>, categories: readonly { id: number }[]): string | null {
const id = result.category_id
if (typeof id !== 'number') return null
return categories.some(c => c.id === id) ? String(id) : null
}
@@ -19,7 +19,7 @@ import { weekFromPeriods } from './placeHours'
import type { PlaceOpeningHours } from '@trek/shared' import type { PlaceOpeningHours } from '@trek/shared'
import { import {
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload, DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
prefillForm, type PlaceFormData, type PlacePrefill, prefillForm, savedCategoryId, type PlaceFormData, type PlacePrefill,
} from './PlaceFormModal.helpers' } from './PlaceFormModal.helpers'
import { useNewCategory, usePlaceForm } from './usePlaceForm' import { useNewCategory, usePlaceForm } from './usePlaceForm'
import { usePlaceSearch } from './usePlaceSearch' import { usePlaceSearch } from './usePlaceSearch'
@@ -135,9 +135,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// the next pick may replace it and a hand choice is never overwritten (#2282). // the next pick may replace it and a hand choice is never overwritten (#2282).
const categoryGuessedRef = useRef(false) const categoryGuessedRef = useRef(false)
const guessedCategory = (result: Record<string, unknown>): string => { const guessedCategory = (result: Record<string, unknown>): string => {
const id = guessCategoryId(result, categories || []) const saved = savedCategoryId(result, categories || []) // a collection's place keeps its own (#3074)
const id = saved ? null : guessCategoryId(result, categories || [])
categoryGuessedRef.current = id != null categoryGuessedRef.current = id != null
return id != null ? String(id) : '' return saved ?? (id != null ? String(id) : '')
} }
// The hours the details column looked up, as a week the contact block can take over (#2472). // The hours the details column looked up, as a week the contact block can take over (#2472).
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null) const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
@@ -1,4 +1,4 @@
import { useNavItems, splitMobileNav, MOBILE_NAV_MAX_BAR, type NavItemDef } from '../Layout/navItems' import { useDockNavItems, splitMobileNav, MOBILE_NAV_MAX_BAR, type NavItemDef } from '../Layout/navItems'
export interface MobileNavValue { export interface MobileNavValue {
bar: string[] bar: string[]
@@ -36,7 +36,7 @@ export function useMobileNavEditor(
value: MobileNavValue, value: MobileNavValue,
onChange: (next: MobileNavValue) => void, onChange: (next: MobileNavValue) => void,
): MobileNavEditor { ): MobileNavEditor {
const items = useNavItems() const items = useDockNavItems()
const split = splitMobileNav(items, value) const split = splitMobileNav(items, value)
const dashboard = split.bar.find((i) => i.id === 'dashboard') const dashboard = split.bar.find((i) => i.id === 'dashboard')
const barItems = split.bar.filter((i) => i.id !== 'dashboard') const barItems = split.bar.filter((i) => i.id !== 'dashboard')
@@ -1,4 +1,4 @@
// FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-008 // FE-COMP-MBOTTOMNAV-001 to FE-COMP-MBOTTOMNAV-011
vi.mock('../../api/websocket', () => ({ vi.mock('../../api/websocket', () => ({
connect: vi.fn(), connect: vi.fn(),
@@ -23,11 +23,19 @@ import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories'; import { buildUser } from '../../../tests/helpers/factories';
import { useAssistantStore } from '../../store/assistantStore'; import { useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { AiStatusResponse } from '@trek/shared'; import type { AiStatusResponse } from '@trek/shared';
import MBottomNav from './MBottomNav'; import MBottomNav from './MBottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' }); const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
const vacay = { id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true };
const atlas = { id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true };
const assistantAddon = { id: 'ai_assistant', name: 'AI assistant', type: 'integration', icon: 'sparkles', enabled: true };
const withAssistant = () =>
useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } });
const withCollections = () => const withCollections = () =>
seedStore(useAddonStore, { seedStore(useAddonStore, {
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }], addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }],
@@ -40,6 +48,10 @@ beforeEach(() => {
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
}); });
afterEach(() => {
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
});
describe('MBottomNav', () => { describe('MBottomNav', () => {
it('FE-COMP-MBOTTOMNAV-001: the dock "+" creates a trip on an unclaimed route', async () => { it('FE-COMP-MBOTTOMNAV-001: the dock "+" creates a trip on an unclaimed route', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -100,14 +112,46 @@ describe('MBottomNav', () => {
expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument();
}); });
it('FE-COMP-MBOTTOMNAV-008: the More list leads with the assistant while it can run, and opens the home chat', async () => { it('FE-COMP-MBOTTOMNAV-008: the assistant takes a free dock slot, so More does not hold it alone', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
useAssistantStore.setState({ status: { available: true } as AiStatusResponse, window: { open: false, minimized: false } }); withAssistant();
seedStore(useAddonStore, { addons: [atlas, assistantAddon] });
render(<MBottomNav />, { initialEntries: ['/atlas'] });
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Assistant' }));
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
expect(mockNavigate).not.toHaveBeenCalled();
});
it('FE-COMP-MBOTTOMNAV-009: with the dock full the assistant waits under More, and opens the home chat from there', async () => {
const user = userEvent.setup();
withAssistant();
seedStore(useAddonStore, { addons: [vacay, atlas, assistantAddon] });
render(<MBottomNav />, { initialEntries: ['/atlas'] }); render(<MBottomNav />, { initialEntries: ['/atlas'] });
await user.click(screen.getByRole('button', { name: 'More' })); await user.click(screen.getByRole('button', { name: 'More' }));
await user.click(screen.getByRole('button', { name: 'Assistant' })); await user.click(screen.getByRole('button', { name: 'Assistant' }));
expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false }); expect(useAssistantStore.getState().window).toEqual({ open: true, minimized: false });
expect(mockNavigate).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled();
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } }); });
it('FE-COMP-MBOTTOMNAV-010: the assistant goes where the settings put it', () => {
withAssistant();
seedStore(useAddonStore, { addons: [vacay, atlas, assistantAddon] });
seedStore(useSettingsStore, {
settings: { ...useSettingsStore.getState().settings, appearance: { mobileNav: { bar: ['assistant', 'atlas'], more: ['vacay'] } } },
});
render(<MBottomNav />, { initialEntries: ['/atlas'] });
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Atlas' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Vacay' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument();
});
it('FE-COMP-MBOTTOMNAV-011: no assistant entry while its addon is off', () => {
withAssistant();
seedStore(useAddonStore, { addons: [atlas, { ...assistantAddon, enabled: false }] });
render(<MBottomNav />, { initialEntries: ['/atlas'] });
expect(screen.queryByRole('button', { name: 'Assistant' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument();
}); });
}); });
+26 -26
View File
@@ -2,17 +2,14 @@ import { useEffect, useState } from 'react'
import { useNavigate, useLocation, matchPath } from 'react-router' import { useNavigate, useLocation, matchPath } from 'react-router'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { ChevronRight, MoreHorizontal, Plus, Search, Sparkles, Upload } from 'lucide-react' import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { normalizeAppearance } from '@trek/shared' import { normalizeAppearance } from '@trek/shared'
import { useNavItems, splitMobileNav } from '../../components/Layout/navItems' import { useDockNavItems, splitMobileNav, ASSISTANT_NAV_ID, type NavItemDef } from '../../components/Layout/navItems'
import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction' import { isNavItemActive, useCreateAction } from '../../components/Layout/useCreateAction'
import MFab from './MFab' import MFab from './MFab'
import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome' import { useAssistantHomeLauncher } from '../../components/Assistant/useAssistantHome'
import MDockScrim from './MDockScrim' import MDockScrim from './MDockScrim'
interface NavItem { to: string; label: string; icon: LucideIcon }
/** /**
* Floating glass dock of the mobile shell. Same tab/gating/"+" logic as the * Floating glass dock of the mobile shell. Same tab/gating/"+" logic as the
* legacy BottomNav (addons, ?create= contract), redesigned as the demo's icon * legacy BottomNav (addons, ?create= contract), redesigned as the demo's icon
@@ -28,7 +25,8 @@ export default function MBottomNav() {
// the dock yields the slot instead (see screenFabSlot below, #1811). // the dock yields the slot instead (see screenFabSlot below, #1811).
const create = useCreateAction(true) const create = useCreateAction(true)
const [moreOpen, setMoreOpen] = useState(false) const [moreOpen, setMoreOpen] = useState(false)
// The home chat leads the More list on every page outside a trip, as the assistant tile leads a trip's. // The home chat, an entry of the dock like the pages (useDockNavItems): sorted
// into the bar or under More in the settings, there only while it can run.
const assistant = useAssistantHomeLauncher() const assistant = useAssistantHomeLauncher()
// Close the popover when the route changes underneath it (browser back etc.). // Close the popover when the route changes underneath it (browser back etc.).
@@ -37,20 +35,21 @@ export default function MBottomNav() {
// The dock split + order is user-configurable (Settings → Appearance → Mobile); // The dock split + order is user-configurable (Settings → Appearance → Mobile);
// an un-customised account falls back to the built-in Dashboard + vacay/atlas // an un-customised account falls back to the built-in Dashboard + vacay/atlas
// dock with everything else under "More". // dock with everything else under "More".
const navItems = useNavItems() const navItems = useDockNavItems()
const appearance = useSettingsStore(s => s.settings.appearance) const appearance = useSettingsStore(s => s.settings.appearance)
const split = splitMobileNav(navItems, normalizeAppearance(appearance).mobileNav) const split = splitMobileNav(navItems, normalizeAppearance(appearance).mobileNav)
const dockItems: NavItem[] = split.bar const dockItems = split.bar
const moreItems: NavItem[] = split.more const moreItems = split.more
const isActive = (to: string) => isNavItemActive(location.pathname, to) const isAssistant = (item: NavItemDef) => item.id === ASSISTANT_NAV_ID
const moreActive = moreItems.some(item => isActive(item.to)) const isActive = (item: NavItemDef) => !isAssistant(item) && isNavItemActive(location.pathname, item.to)
const moreRows = [ const open = (item: NavItemDef): void => {
...(assistant.available ? [{ key: 'assistant', label: t('assistant.launcher.tooltip'), icon: Sparkles, go: assistant.open }] : []), if (isAssistant(item)) assistant.open()
...moreItems.map(({ to, label, icon }) => ({ key: to, label, icon, go: () => navigate(to) })), else void navigate(item.to)
] }
const hasMore = moreRows.length > 0 const moreActive = moreItems.some(isActive)
const hasMore = moreItems.length > 0
// The FAB gives way to a decorative logo slot on screens without an add // The FAB gives way to a decorative logo slot on screens without an add
// action (settings/admin, demo Z. 1372/1429). // action (settings/admin, demo Z. 1372/1429).
@@ -82,16 +81,17 @@ export default function MBottomNav() {
// A map runs to the screen's edge on purpose; everything else fades out under the dock. // A map runs to the screen's edge on purpose; everything else fades out under the dock.
const overMap = location.pathname.startsWith('/atlas') || matchPath('/journey/:id', location.pathname) !== null const overMap = location.pathname.startsWith('/atlas') || matchPath('/journey/:id', location.pathname) !== null
const renderItem = ({ to, label, icon: Icon }: NavItem) => { const renderItem = (item: NavItemDef) => {
const active = isActive(to) const { id, label, icon: Icon } = item
const active = isActive(item)
// Fixed sizes per slot (demo): the dashboard grid is 18/2.1, every other // Fixed sizes per slot (demo): the dashboard grid is 18/2.1, every other
// slot 21/1.9 — independent of the active state. // slot 21/1.9 — independent of the active state.
const dash = to === '/dashboard' const dash = id === 'dashboard'
return ( return (
<button <button
key={to} key={id}
type="button" type="button"
onClick={() => navigate(to)} onClick={() => open(item)}
aria-label={label} aria-label={label}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
className={circleCls(active)} className={circleCls(active)}
@@ -111,17 +111,17 @@ export default function MBottomNav() {
role="presentation" role="presentation"
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{moreRows.map(({ key, label, icon: Icon, go }) => ( {moreItems.map((item) => (
<button <button
key={key} key={item.id}
type="button" type="button"
onClick={() => { setMoreOpen(false); go() }} onClick={() => { setMoreOpen(false); open(item) }}
className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-start" className="flex items-center gap-[13px] rounded-[18px] bg-[color:var(--m-ic)] px-4 py-[14px] text-start"
> >
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] text-m-ink"> <span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] text-m-ink">
<Icon size={20} strokeWidth={2} /> <item.icon size={20} strokeWidth={2} />
</span> </span>
<span className="block min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{label}</span> <span className="block min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{item.label}</span>
<ChevronRight size={17} strokeWidth={2} className="flex-none text-m-faint" /> <ChevronRight size={17} strokeWidth={2} className="flex-none text-m-faint" />
</button> </button>
))} ))}
+15 -1
View File
@@ -5,7 +5,7 @@ import { StatusBar, Style } from '@capacitor/status-bar';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { closeTopOverlay } from '../mobile/overlayStack'; import { closeTopOverlay } from '../mobile/overlayStack';
import { isEffectivelyOffline } from '../sync/networkMode'; import { isEffectivelyOffline } from '../sync/networkMode';
import { checkAppUpdate } from './capabilities'; import { checkAppUpdate, hasNativeFeature } from './capabilities';
import { installNativeGeolocation } from './geolocation'; import { installNativeGeolocation } from './geolocation';
import { prepareKeyboardFields } from './keyboardFields'; import { prepareKeyboardFields } from './keyboardFields';
import { scrollToTop, startNativeApp } from './nativeApp'; import { scrollToTop, startNativeApp } from './nativeApp';
@@ -28,6 +28,7 @@ vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn() }));
vi.mock('./capabilities', () => ({ vi.mock('./capabilities', () => ({
checkAppUpdate: vi.fn(async () => false), checkAppUpdate: vi.fn(async () => false),
loadNativeCapabilities: vi.fn(async () => ({})), loadNativeCapabilities: vi.fn(async () => ({})),
hasNativeFeature: vi.fn(() => false),
})); }));
vi.mock('./geolocation', () => ({ installNativeGeolocation: vi.fn() })); vi.mock('./geolocation', () => ({ installNativeGeolocation: vi.fn() }));
vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) })); vi.mock('./sharedFiles', () => ({ startSharedFiles: vi.fn(async () => {}) }));
@@ -51,6 +52,7 @@ beforeEach(() => {
vi.mocked(isNativeIos).mockReturnValue(false); vi.mocked(isNativeIos).mockReturnValue(false);
vi.mocked(isEffectivelyOffline).mockReturnValue(false); vi.mocked(isEffectivelyOffline).mockReturnValue(false);
vi.mocked(keepOfflineShellFresh).mockResolvedValue(); vi.mocked(keepOfflineShellFresh).mockResolvedValue();
vi.mocked(hasNativeFeature).mockReturnValue(false);
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
document.documentElement.removeAttribute('data-native-app'); document.documentElement.removeAttribute('data-native-app');
document.documentElement.removeAttribute('data-native-ios'); document.documentElement.removeAttribute('data-native-ios');
@@ -210,6 +212,18 @@ describe('startNativeApp', () => {
expect(openPushTarget).not.toHaveBeenCalled(); expect(openPushTarget).not.toHaveBeenCalled();
}); });
it('leaves widget links to the binary when it hands them over as quick actions', async () => {
sessionStorage.clear();
vi.mocked(hasNativeFeature).mockImplementation((feature) => feature === 'appLinks');
vi.mocked(App.getLaunchUrl).mockResolvedValue({ url: 'com.liketrek.trek://navigate/trips/5?tab=listen' });
startNativeApp();
await vi.waitFor(() => expect(hasNativeFeature).toHaveBeenCalledWith('appLinks'));
await Promise.resolve();
expect(App.getLaunchUrl).not.toHaveBeenCalled();
expect(vi.mocked(App.addListener).mock.calls.map(([name]) => String(name))).not.toContain('appUrlOpen');
expect(openPushTarget).not.toHaveBeenCalled();
});
it('leaves the start page alone for a launch without a TREK link', async () => { it('leaves the start page alone for a launch without a TREK link', async () => {
sessionStorage.clear(); sessionStorage.clear();
vi.mocked(App.getLaunchUrl).mockResolvedValue({ url: 'https://example.com/elsewhere' }); vi.mocked(App.getLaunchUrl).mockResolvedValue({ url: 'https://example.com/elsewhere' });
+12 -7
View File
@@ -5,7 +5,7 @@ import { StatusBar, Style } from '@capacitor/status-bar';
import { closeTopOverlay } from '../mobile/overlayStack'; import { closeTopOverlay } from '../mobile/overlayStack';
import { useSettingsStore } from '../store/settingsStore'; import { useSettingsStore } from '../store/settingsStore';
import { isEffectivelyOffline } from '../sync/networkMode'; import { isEffectivelyOffline } from '../sync/networkMode';
import { checkAppUpdate, loadNativeCapabilities } from './capabilities'; import { checkAppUpdate, hasNativeFeature, loadNativeCapabilities } from './capabilities';
import { installNativeGeolocation } from './geolocation'; import { installNativeGeolocation } from './geolocation';
import { fadeInImages } from './imageFade'; import { fadeInImages } from './imageFade';
import { keepFieldInView } from './keepFieldInView'; import { keepFieldInView } from './keepFieldInView';
@@ -54,13 +54,18 @@ export function startNativeApp(): void {
// A quick action on the app icon opens its page, also the one the app was launched with. // A quick action on the app icon opens its page, also the one the app was launched with.
void loadNativeCapabilities().then(() => startQuickActions()); void loadNativeCapabilities().then(() => startQuickActions());
// A widget or Live Activity tap opens com.liketrek.trek://navigate/<path>. // A widget or Live Activity tap opens com.liketrek.trek://navigate/<path>. A
void App.addListener('appUrlOpen', ({ url }) => { // binary with the feature appLinks hands it over as a quick action instead,
const prefix = `${APP_URL_SCHEME}://navigate`; // held until taken, so it is only followed here for older binaries.
if (url.startsWith(prefix)) openPushTarget(url.slice(prefix.length) || '/'); void loadNativeCapabilities().then(() => {
if (hasNativeFeature('appLinks')) return;
void App.addListener('appUrlOpen', ({ url }) => {
const prefix = `${APP_URL_SCHEME}://navigate`;
if (url.startsWith(prefix)) openPushTarget(url.slice(prefix.length) || '/');
});
// A tap that launched the app comes as the launch URL, not as appUrlOpen.
void openLaunchLink();
}); });
// A tap that launched the app comes as the launch URL, not as appUrlOpen.
void openLaunchLink();
// A tap on the status bar scrolls the list under it back to the top, as in // A tap on the status bar scrolls the list under it back to the top, as in
// every iOS app (the status-bar plugin fires statusTap on iOS). // every iOS app (the status-bar plugin fires statusTap on iOS).
+5 -2
View File
@@ -211,9 +211,12 @@ export interface TrekShellPlugin {
): Promise<{ remove: () => Promise<void> }>; ): Promise<{ remove: () => Promise<void> }>;
/** Feature quickActions (iOS): the actions a long press on the app icon shows, in the UI's language. */ /** Feature quickActions (iOS): the actions a long press on the app icon shows, in the UI's language. */
setQuickActions(options: { items: NativeQuickAction[] }): Promise<void>; setQuickActions(options: { items: NativeQuickAction[] }): Promise<void>;
/** Feature quickActions: the action the app was launched with, before the UI ran. */ /**
* Feature quickActions: the action the app was launched with, before the UI ran.
* With the feature appLinks also a widget or Live Activity tap, as its path.
*/
takeQuickAction(): Promise<{ path: string | null }>; takeQuickAction(): Promise<{ path: string | null }>;
/** Feature quickActions: an action chosen while the app was running. */ /** Feature quickActions: an action chosen (with appLinks: a widget tapped) while the app was running. */
addListener( addListener(
event: 'quickAction', event: 'quickAction',
listener: (payload: { path: string }) => void listener: (payload: { path: string }) => void
@@ -53,6 +53,17 @@ const HOLE = undefined as unknown as CollectionPlace;
describe('collectionsModel', () => { describe('collectionsModel', () => {
// ── filterPlaces ──────────────────────────────────────────────────────────── // ── filterPlaces ────────────────────────────────────────────────────────────
describe('filterPlaces', () => { describe('filterPlaces', () => {
it('FE-COLLECTIONS-MODEL-045: the search finds Óbidos and São Brás typed without accents (#3075)', () => {
const places = [
cp({ id: 1, name: 'Óbidos', status: 'idea' }),
cp({ id: 2, name: 'São Brás de Alportel', status: 'idea' }),
cp({ id: 3, name: 'Lagos', status: 'idea', address: 'Rua de Évora' }),
];
expect(filterPlaces(places, 'all', 'obidos').map(p => p.id)).toEqual([1]);
expect(filterPlaces(places, 'all', 'Sao Bras').map(p => p.id)).toEqual([2]);
expect(filterPlaces(places, 'all', 'evora').map(p => p.id)).toEqual([3]);
});
it('FE-COLLECTIONS-MODEL-001: status filter keeps only the matching status', () => { it('FE-COLLECTIONS-MODEL-001: status filter keeps only the matching status', () => {
const places = [ const places = [
cp({ id: 1, name: 'A', status: 'idea' }), cp({ id: 1, name: 'A', status: 'idea' }),
@@ -1,5 +1,6 @@
import type { CollectionPlace, CollectionLabel } from '@trek/shared' import type { CollectionPlace, CollectionLabel } from '@trek/shared'
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore' import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
import { textMatchesSearch } from '../../utils/placeSearch'
/** /**
* Pure data shaping + presentation metadata for the Collections page. No React * Pure data shaping + presentation metadata for the Collections page. No React
@@ -36,7 +37,6 @@ export function filterPlaces(
labelFilter: number[] = [], labelFilter: number[] = [],
ratingFilter: number | 'all' = 'all', ratingFilter: number | 'all' = 'all',
): CollectionPlace[] { ): CollectionPlace[] {
const q = search.trim().toLowerCase()
return places.filter(p => { return places.filter(p => {
if (!p) return false if (!p) return false
if (statusFilter !== 'all' && p.status !== statusFilter) return false if (statusFilter !== 'all' && p.status !== statusFilter) return false
@@ -44,12 +44,7 @@ export function filterPlaces(
// Minimum-average-stars filter (#1435): unrated places drop out once a floor is set. // Minimum-average-stars filter (#1435): unrated places drop out once a floor is set.
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
if (!q) return true return textMatchesSearch([p.name, p.address, p.notes], search)
return (
p.name.toLowerCase().includes(q) ||
(p.address ?? '').toLowerCase().includes(q) ||
(p.notes ?? '').toLowerCase().includes(q)
)
}) })
} }
@@ -93,6 +88,25 @@ export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number') return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number')
} }
/**
* A saved place with its category where the trip map reads it. The server nests a
* collection place's category (`category.color`), the markers and the hover card
* read the flat fields a trip place carries, so every pin here was the grey default
* (#3073).
*/
export function withMarkerCategory(place: CollectionPlace): CollectionPlace & {
category_name: string | null
category_color: string | null
category_icon: string | null
} {
return {
...place,
category_name: place.category?.name ?? null,
category_color: place.category?.color ?? null,
category_icon: place.category?.icon ?? null,
}
}
/** /**
* Normalise a user-typed link: prepend https:// when there's no scheme so the * Normalise a user-typed link: prepend https:// when there's no scheme so the
* href is absolute (a bare "booking.com" would otherwise resolve relative to the * href is absolute (a bare "booking.com" would otherwise resolve relative to the
+23 -1
View File
@@ -1,6 +1,6 @@
// FE-UTIL-PLACESEARCH-001 to FE-UTIL-PLACESEARCH-004 // FE-UTIL-PLACESEARCH-001 to FE-UTIL-PLACESEARCH-004
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { placeMatchesSearch } from './placeSearch' import { foldForSearch, placeMatchesSearch, textMatchesSearch } from './placeSearch'
const place = { const place = {
name: 'Café Central', name: 'Café Central',
@@ -32,3 +32,25 @@ describe('placeMatchesSearch', () => {
expect(placeMatchesSearch(place, 'pizza')).toBe(false) expect(placeMatchesSearch(place, 'pizza')).toBe(false)
}) })
}) })
describe('search without accents (#3075)', () => {
it('FE-UTIL-PLACESEARCH-005: a query without accents finds the accented name, and the other way round', () => {
for (const [query, name] of [['Obidos', 'Óbidos'], ['Evora', 'Évora'], ['Sao Bras', 'São Brás'], ['Loule', 'Loulé']]) {
expect(placeMatchesSearch({ name, address: null, description: null, notes: null }, query)).toBe(true)
expect(placeMatchesSearch({ name: query, address: null, description: null, notes: null }, name)).toBe(true)
}
})
it('FE-UTIL-PLACESEARCH-006: letters with the mark built in fold to their plain spelling', () => {
expect(foldForSearch('Straße')).toBe('strasse')
expect(foldForSearch('Łódź')).toBe('lodz')
expect(foldForSearch('Ærø')).toBe('aero')
expect(textMatchesSearch(['Kraków'], 'krakow')).toBe(true)
})
it('FE-UTIL-PLACESEARCH-007: marks that change a word outside Latin script stay, so が is not か', () => {
expect(foldForSearch('が')).toBe('が')
expect(textMatchesSearch(['かき'], 'がき')).toBe(false)
expect(foldForSearch('한국')).toBe('한국')
})
})
+29 -5
View File
@@ -10,9 +10,33 @@ type Searchable = Pick<Place, 'name' | 'address' | 'description' | 'notes'>
* can never disagree about what a search finds. * can never disagree about what a search finds.
*/ */
export function placeMatchesSearch(place: Searchable, query: string): boolean { export function placeMatchesSearch(place: Searchable, query: string): boolean {
const q = query.trim().toLowerCase() return textMatchesSearch([place.name, place.address, place.description, place.notes], query)
if (!q) return true }
return [place.name, place.address, place.description, place.notes].some(
field => !!field && field.toLowerCase().includes(q), /** The combining accents of Latin script (U+0300 to U+036F), the marks NFD splits off a letter. */
) const LATIN_MARKS = new RegExp(`[${String.fromCharCode(0x300)}-${String.fromCharCode(0x36f)}]`, 'g')
/** Letters that carry their mark in the letter itself, so taking the marks off leaves them as they are. */
const FOLDED_LETTERS: Record<string, string> = { ß: 'ss', ø: 'o', æ: 'ae', œ: 'oe', ł: 'l', đ: 'd', ð: 'd', þ: 'th', ı: 'i' }
/**
* Text as a search compares it: lower case and without accents, so "Obidos" finds
* "Óbidos", "Sao Bras" finds "São Brás" and the other way round (#3075). Only
* Latin marks go: a kana's voicing mark or a Hangul syllable stays whole, since
* there it changes the word.
*/
export function foldForSearch(text: string): string {
return text
.toLocaleLowerCase()
.normalize('NFD')
.replace(LATIN_MARKS, '')
.replace(/[ßøæœłđðþı]/g, ch => FOLDED_LETTERS[ch] ?? ch)
.normalize('NFC')
}
/** Whether any of the fields holds the query, accents and case aside. An empty query matches everything. */
export function textMatchesSearch(fields: readonly (string | null | undefined)[], query: string): boolean {
const q = foldForSearch(query.trim())
if (!q) return true
return fields.some(field => !!field && foldForSearch(field).includes(q))
} }
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "@trek/root", "name": "@trek/root",
"version": "5.1.3", "version": "5.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@trek/root", "name": "@trek/root",
"version": "5.1.3", "version": "5.1.4",
"workspaces": [ "workspaces": [
"client", "client",
"server", "server",
@@ -29,7 +29,7 @@
}, },
"client": { "client": {
"name": "@trek/client", "name": "@trek/client",
"version": "5.1.3", "version": "5.1.4",
"dependencies": { "dependencies": {
"@assistant-ui/react": "0.15.32", "@assistant-ui/react": "0.15.32",
"@capacitor/action-sheet": "^8.1.1", "@capacitor/action-sheet": "^8.1.1",
@@ -25676,7 +25676,7 @@
}, },
"server": { "server": {
"name": "@trek/server", "name": "@trek/server",
"version": "5.1.3", "version": "5.1.4",
"dependencies": { "dependencies": {
"@aws-lite/client": "^0.24.1", "@aws-lite/client": "^0.24.1",
"@aws-lite/s3": "^0.2.7", "@aws-lite/s3": "^0.2.7",
@@ -26076,7 +26076,7 @@
}, },
"shared": { "shared": {
"name": "@trek/shared", "name": "@trek/shared",
"version": "5.1.3", "version": "5.1.4",
"dependencies": { "dependencies": {
"isomorphic-dompurify": "^3.23.0", "isomorphic-dompurify": "^3.23.0",
"zod": "^4.6.7" "zod": "^4.6.7"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@trek/root", "name": "@trek/root",
"private": true, "private": true,
"version": "5.1.3", "version": "5.1.4",
"engines": { "engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/server", "name": "@trek/server",
"version": "5.1.3", "version": "5.1.4",
"engines": { "engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}, },
@@ -1,5 +1,5 @@
/** /**
* How TREK 5.1.3 registers with the TREK services. * How TREK 5.1.4 registers with the TREK services.
* *
* The issuer writes this file for each release and the release pipeline * The issuer writes this file for each release and the release pipeline
* commits it with the version bump. It is not edited by hand, and its layout * commits it with the version bump. It is not edited by hand, and its layout
@@ -8,86 +8,101 @@
*/ */
import type { DialectSelfCheck, RegistrationDialect } from './registration-dialect.types'; import type { DialectSelfCheck, RegistrationDialect } from './registration-dialect.types';
import { createHash, pbkdf2Sync } from 'node:crypto'; import { createHash, createHmac, hkdfSync } from 'node:crypto';
const DIALECT = 'qDlOapZrw47x'; const DIALECT = '8546tJZpbjQb';
const POND_HILL = Buffer.from('53ee01dfb2711f0b4b7cb1b5d7cd644801289dabda654bf6f53ba0f4727d5d26', 'hex'); const ARROW_BARLEY = Buffer.from('95fd08ded3ef3e32af04cf42290d5a6d1ad24d88827f9c745ba46b810962cd8e', 'hex');
function saddle(alg: string, data: Buffer): Buffer { function mill(a: Buffer, b: Buffer): Buffer {
return createHash(alg).update(data).digest(); const parts: Buffer[] = [];
for (let i = 0; i < Math.max(a.length, b.length); i++) parts.push(a.subarray(i, i + 1), b.subarray(i, i + 1));
return Buffer.concat(parts);
} }
function frost(value: number): Buffer { function moss(input: Buffer, size: number): Buffer {
const out = Buffer.alloc(8);
out.writeBigUInt64BE(BigInt(value));
return out;
}
function hollow(input: Buffer, size: number): Buffer {
const out = Buffer.alloc(size); const out = Buffer.alloc(size);
input.forEach((byte, i) => out.writeUInt8(out.readUInt8(i % size) ^ byte, i % size)); input.forEach((byte, i) => out.writeUInt8(out.readUInt8(i % size) ^ byte, i % size));
return out; return out;
} }
function zephyr(value: number): Buffer { function meadow(alg: string, key: Buffer, data: Buffer): Buffer {
const out: number[] = []; return createHmac(alg, key).update(data).digest();
let rest = value;
do {
const low = rest % 128;
rest = Math.floor(rest / 128);
out.push(low | (Math.sign(rest) << 7));
} while (rest > 0);
return Buffer.from(out);
} }
function barrel(input: Buffer, width: number): Buffer { function rapids(part: Buffer): Buffer {
const out = Buffer.from(input); const head = Buffer.alloc(4);
for (let i = 0; i + width <= out.length; i += width) out.subarray(i, i + width).reverse(); head.writeUInt32LE(part.length);
return Buffer.concat([head, part]);
}
function hollow(value: number): Buffer {
const out = Buffer.alloc(8);
out.writeBigUInt64BE(BigInt(value));
return out;
}
function apple(part: Buffer): Buffer {
const head = Buffer.alloc(2);
head.writeUInt16LE(part.length);
return Buffer.concat([head, part]);
}
function blossom(value: number): Buffer {
const out = Buffer.alloc(4);
out.writeUInt32BE(value >>> 0);
return out; return out;
} }
export const registrationDialect: RegistrationDialect = { export const registrationDialect: RegistrationDialect = {
id: DIALECT, id: DIALECT,
releasedFor: '5.1.3', releasedFor: '5.1.4',
createdAt: '2026-11-10', createdAt: '2026-11-12',
challengePath: `/v1/register/${DIALECT}/challenge`, challengePath: `/v1/register/${DIALECT}/challenge`,
registerPath: `/v1/register/${DIALECT}`, registerPath: `/v1/register/${DIALECT}`,
begin({ challenge, version, random }) { begin({ challenge, version, random, nowSeconds }) {
const garnet = Buffer.from(challenge.c, 'utf8'); const chapel = Buffer.from(challenge.c, 'utf8');
const glade = Buffer.from(version, 'utf8'); const fjord = Buffer.from(version, 'utf8');
const banner = createHash('sha256').update(garnet).digest(); const cliff = createHash('sha256').update(chapel).digest();
const kettle = Buffer.from([0x5a]); const horizon = Buffer.concat([cliff].map(apple));
const river = Buffer.concat([random, kettle, POND_HILL, kettle, banner, kettle, glade]); const pasture = Buffer.concat([cliff.subarray(0x10, 20), Buffer.from([0x8c]), horizon]);
const robin = createHash('shake128', { outputLength: 31 }).update(river).digest(); const sorrel = Buffer.from(pasture.toString('hex'));
const larch = barrel(robin, 8); const bell = meadow('sha256', sorrel, chapel);
const ocean = createHash('sha3-512').update(larch).digest();
return { return {
work(nonce) { work(nonce) {
const wren = Buffer.concat([banner.subarray(0xa, 16), garnet, frost(nonce), glade, random]); const cinder = Buffer.concat([
const crystal = pbkdf2Sync(wren, banner, 10, 0x32, 'sha256'); rapids(random), rapids(fjord), rapids(hollow(nonce)), rapids(blossom(nowSeconds)), rapids(chapel),
const seal = hollow(crystal, 20); ]);
const mist = Buffer.from([...seal.subarray(0x1b % seal.length), ...seal.subarray(0, 0x1b % seal.length)]); const tide = Buffer.from([
const coast = barrel(mist, 4); 0, 0xa, 9, 0xb, 1, 15, 3, 16, 13, 6, 8, 4, 0xc, 14, 0x11, 2, 5, 7,
const dawn = saddle('sha512', coast); ].map((from) => cinder.readUInt8(from)));
const yarrow = barrel(dawn, 8); const cottage = Buffer.concat([tide, cinder.subarray(18)]);
return { password: yarrow, salt: ocean }; const sapling = Buffer.from(hkdfSync(
'sha256', cottage, chapel.subarray(8, 0xc), Buffer.from('9b25443bf88e4cbf', 'hex'), 43,
));
const rowan = Buffer.from(Array.from(sapling, (byte) => (byte * 0xc7 + 0xe2) % 256));
const linden = mill(rowan, ARROW_BARLEY);
const swan = Buffer.from(linden.subarray(2, 41));
return { password: swan, salt: bell };
}, },
request(nonce) { request(nonce) {
const pilgrim = Buffer.concat([ const falcon = Buffer.concat([
garnet, Buffer.from([7]), glade, Buffer.from([7]), banner, Buffer.from([7]), zephyr(nonce), Buffer.from([7]), Buffer.from(nonce.toString(16), 'utf8'), Buffer.from([0x4b]), cliff.subarray(24, 28), Buffer.from([0x4b]),
random, chapel, Buffer.from([0x4b]), ARROW_BARLEY, Buffer.from([0x4b]), random.subarray(3, 9), Buffer.from([0x4b]),
fjord, Buffer.from([0x4b]), blossom(nowSeconds),
]); ]);
const pepper = Buffer.from(Array.from(pilgrim, (byte) => (byte * 203 + 60) % 256)); const pelican = moss(falcon, 0x14);
const mesa = createHash('blake2b512').update(pepper).digest(); const kelp = meadow('sha512', pelican, fjord);
return { return {
body: { body: {
brookCactus: mesa.toString('base64'), willow_jetty: {
tundra: random.toString('hex'), echo: nonce,
ivoryCape: version, banner: version,
mill: nonce, lava_breeze: random.toString('hex'),
jadeBadge: challenge.c, flint_lava: challenge.c,
sage_frost: String(nowSeconds),
},
}, },
headers: {}, headers: { 'X-TREK-Fossil-Vessel': kelp.toString('base64url') },
}; };
}, },
}; };
@@ -96,13 +111,13 @@ export const registrationDialect: RegistrationDialect = {
/** Fixed inputs and what this module must make of them; TREK's tests and release job run it. */ /** Fixed inputs and what this module must make of them; TREK's tests and release job run it. */
export const SELF_CHECK: DialectSelfCheck = { export const SELF_CHECK: DialectSelfCheck = {
challenge: { c: 'v1.selfcheck.qDlOapZrw47x', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 }, challenge: { c: 'v1.selfcheck.8546tJZpbjQb', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 },
version: '5.1.3', version: '5.1.4',
random: '000102030405060708090a0b0c0d0e0f', random: '000102030405060708090a0b0c0d0e0f',
nowSeconds: 1800000000, nowSeconds: 1800000000,
nonce: 41, nonce: 41,
inputSha256: 'd0459dd032ca32758d4960be87026844ca0e5f2ccc0cd087838c7cc9f1f054d6', inputSha256: '35bde8e3256e05441ac19c8b6b22f9d4e4adb59b5564f101078aa597e70ece99',
saltSha256: 'e6beeb65b892a5f7e68977ceb33e0193bcf284904db6b55884823b5b2a948787', saltSha256: '8730a0377647a045a6fceade4a2c8c7dd3854e56cc6a87cd20ef94e87107493b',
scrypt: 'cb5f7842c312c4442f9d750305c31d2ea2df36556dbf80e17b56df859d2bc1e9', scrypt: '884c7cf0406e20ccbba3372999d07d80b3cfc13eb9a464510b68942868840189',
requestSha256: '53bc1ae4f560b17631848e100dd2469040bbadc8ae61c881263a489bd14842ed', requestSha256: '49848ec61f040261e65627b276eb996a83ed3d096b5a0216e8ba00a75a90cf70',
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@trek/shared", "name": "@trek/shared",
"version": "5.1.3", "version": "5.1.4",
"engines": { "engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}, },