diff --git a/bun.lock b/bun.lock index 8313530..d9db17c 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "@capacitor/status-bar": "8.0.3", "@fontsource-variable/azeret-mono": "^5.3.0", "@fontsource-variable/commissioner": "^5.3.0", + "@fontsource-variable/instrument-sans": "^5.3.0", "@ionic/vue": "^8.0.0", "@ionic/vue-router": "^8.0.0", "dompurify": "3.4.12", @@ -182,6 +183,8 @@ "@fontsource-variable/commissioner": ["@fontsource-variable/commissioner@5.3.0", "", {}, "sha512-0IEvi8SO1slc5Pc9tA4OtA01I8UiCcEq/uh12oNjfHuABU9Lnz61l2V8o2cMPUUo3BAo5MbISVK2I/DSpBRw7Q=="], + "@fontsource-variable/instrument-sans": ["@fontsource-variable/instrument-sans@5.3.0", "", {}, "sha512-u4gKbDBTNFGkg997tfQn3eHOhHuquWUFTRT/rwzuKtrxX5P2ekfs2x+LgBPP4P32+cC+vUwF1Cr+IdRoPQbrGw=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], diff --git a/package.json b/package.json index dfd3013..0817179 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@capacitor/status-bar": "8.0.3", "@fontsource-variable/azeret-mono": "^5.3.0", "@fontsource-variable/commissioner": "^5.3.0", + "@fontsource-variable/instrument-sans": "^5.3.0", "@ionic/vue": "^8.0.0", "@ionic/vue-router": "^8.0.0", "dompurify": "3.4.12", diff --git a/src/App.vue b/src/App.vue index 7c2860d..6a7ff02 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,11 +1,11 @@ @@ -13,13 +13,15 @@ import { App as CapacitorApp } from '@capacitor/app' import { Capacitor } from '@capacitor/core' import { IonApp, IonRouterOutlet, IonSplitPane } from '@ionic/vue' -import { onBeforeUnmount, onMounted } from 'vue' -import { useRouter } from 'vue-router' +import { computed, onBeforeUnmount, onMounted } from 'vue' +import { useRoute, useRouter } from 'vue-router' import AppMenu from './components/AppMenu.vue' import MobileTabBar from './components/MobileTabBar.vue' import { links } from './router/links' const router = useRouter() +const route = useRoute() +const isLanding = computed(() => route.name === 'landing') let removeBackListener: (() => Promise) | undefined onMounted(async () => { @@ -28,7 +30,7 @@ onMounted(async () => { const listener = await CapacitorApp.addListener('backButton', () => { if (typeof router.options.history.state.back === 'string') { router.back() - } else if (router.currentRoute.value.path !== '/') { + } else if (!['home', 'landing'].includes(String(router.currentRoute.value.name))) { void router.replace(links.home) } else { void CapacitorApp.exitApp() diff --git a/src/activity/recent.ts b/src/activity/recent.ts new file mode 100644 index 0000000..cefe619 --- /dev/null +++ b/src/activity/recent.ts @@ -0,0 +1,140 @@ +import { readonly, ref } from 'vue' +import type { RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router' +import { links } from '@/router/links' + +export const RECENT_ACTIVITY_STORAGE_KEY = 'twisted.recent-activity.v1' +const MAX_RECENT_DESTINATIONS = 8 + +export type RecentDestinationKind = 'person' | 'repository' | 'search' + +export interface RecentDestination { + kind: RecentDestinationKind + label: string + target: string + visitedAt: number +} + +type RecentStorage = Pick + +const recentDestinations = ref([]) +let activeStorage: RecentStorage | undefined + +export function initializeRecentActivity(storage: RecentStorage | undefined): void { + activeStorage = storage + recentDestinations.value = loadRecentActivity(storage) +} + +export function loadRecentActivity(storage: Pick | undefined): RecentDestination[] { + if (!storage) return [] + try { + const saved = storage.getItem(RECENT_ACTIVITY_STORAGE_KEY) + if (saved === null) return [] + const value = JSON.parse(saved) as unknown + if (!Array.isArray(value)) return [] + return value.filter(isRecentDestination).slice(0, MAX_RECENT_DESTINATIONS) + } catch { + return [] + } +} + +export function rememberRecentRoute(route: RouteLocationNormalizedLoaded): void { + const destination = destinationFromRoute(route) + if (!destination) return + + recentDestinations.value = [ + destination, + ...recentDestinations.value.filter((item) => item.kind !== destination.kind || item.target !== destination.target), + ].slice(0, MAX_RECENT_DESTINATIONS) + persistRecentActivity() +} + +export function clearRecentActivity(): void { + recentDestinations.value = [] + try { + activeStorage?.removeItem(RECENT_ACTIVITY_STORAGE_KEY) + } catch { + // The dashboard still clears when browser storage is unavailable. + } +} + +export function recentDestinationLink(destination: RecentDestination): RouteLocationRaw { + if (destination.kind === 'person') return links.profile(destination.target) + if (destination.kind === 'repository') return links.repository(destination.target) + return links.search(destination.target) +} + +export function useRecentActivity() { + return { recentDestinations: readonly(recentDestinations), clearRecentActivity } +} + +function destinationFromRoute(route: RouteLocationNormalizedLoaded): RecentDestination | undefined { + const actor = routeParameter(route.params.actor) + if ( + actor && + isActorIdentifier(actor) && + ['profile', 'actor-activity', 'actor-relationships'].includes(String(route.name)) + ) { + return { + kind: 'person', + label: actor.startsWith('did:') ? actor : `@${actor.replace(/^@/, '')}`, + target: actor, + visitedAt: Date.now(), + } + } + + const repository = routeParameter(route.params.repo) + if (repository?.startsWith('at://')) { + return { kind: 'repository', label: repositoryLabel(repository), target: repository, visitedAt: Date.now() } + } + + const query = typeof route.query.q === 'string' ? route.query.q.trim() : '' + if (route.name === 'search' && query) { + return { kind: 'search', label: query, target: query, visitedAt: Date.now() } + } + + return undefined +} + +function isActorIdentifier(value: string): boolean { + return ( + /^did:[a-z0-9]+:[^\s/]+$/i.test(value) || + /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/i.test(value.replace(/^@/, '')) + ) +} + +function routeParameter(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim()) return value + if (Array.isArray(value)) return value.join('/') + return undefined +} + +function repositoryLabel(repository: string): string { + const part = repository.split('/').filter(Boolean).at(-1) + try { + return part ? decodeURIComponent(part) : 'Repository' + } catch { + return part ?? 'Repository' + } +} + +function isRecentDestination(value: unknown): value is RecentDestination { + if (typeof value !== 'object' || value === null) return false + const item = value as Record + return ( + ['person', 'repository', 'search'].includes(String(item.kind)) && + typeof item.label === 'string' && + item.label.length > 0 && + typeof item.target === 'string' && + item.target.length > 0 && + typeof item.visitedAt === 'number' && + Number.isFinite(item.visitedAt) + ) +} + +function persistRecentActivity(): void { + try { + activeStorage?.setItem(RECENT_ACTIVITY_STORAGE_KEY, JSON.stringify(recentDestinations.value)) + } catch { + // Recent activity remains available for the current session. + } +} diff --git a/src/components/AppMenu.vue b/src/components/AppMenu.vue index 896524e..b5683d0 100644 --- a/src/components/AppMenu.vue +++ b/src/components/AppMenu.vue @@ -1,7 +1,7 @@