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 @@
Skip to content
-
-
+
+
-
+
@@ -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 @@