Compare commits
60 Commits
feat/locat
...
feat/post-
| Author | SHA1 | Date | |
|---|---|---|---|
|
013bf46e1f
|
|||
| 40ad90aa2d | |||
| 82ac2b577d | |||
| ffea38b986 | |||
| 28d26b3c87 | |||
| 78b0ed94bd | |||
| ee957ea7b1 | |||
| 6ae0ad1d40 | |||
|
941acb826c
|
|||
| 3d492a5d56 | |||
|
08dd437952
|
|||
| 2fc20759dd | |||
|
963bf073d1
|
|||
| c0b3db0e11 | |||
|
ca4a337dcd
|
|||
| 769e2d4eb0 | |||
|
5e9094710d
|
|||
|
5556f3fbf9
|
|||
|
0176979925
|
|||
|
971aba0932
|
|||
|
68e319e4b8
|
|||
| c042af88f3 | |||
|
0608f2ac61
|
|||
| 1ade63dd8c | |||
|
8df340d9af
|
|||
|
727280e8b1
|
|||
| d30f70494b | |||
| 413a57c156 | |||
|
d9625198d6
|
|||
| 959167a93c | |||
|
3ebb47c5ab
|
|||
| cd29a60bab | |||
|
2f082b5833
|
|||
| f987253e53 | |||
|
699155e0d8
|
|||
| 4d6cac7ec8 | |||
|
1f2920a7ad
|
|||
| 476c6f06d9 | |||
|
bfdc5e67b7
|
|||
|
2cf6536e48
|
|||
|
c7a1048320
|
|||
|
512faf191e
|
|||
| e5d65816dc | |||
| 13c411c842 | |||
| e8ba49d7bb | |||
| 3010eb8990 | |||
| 6c4982ae85 | |||
| f557a0f967 | |||
| 1b2929c2b6 | |||
| e5f1273baf | |||
| c3429e1a77 | |||
|
54e4b0dcf7
|
|||
|
31a82c1d9f
|
|||
| d1102fe1ac | |||
| db0c57f04b | |||
| 9e3fe2ea16 | |||
| 949b7c8571 | |||
| bd6cc3c963 | |||
| aff9464245 | |||
| 0db6cae82b |
@@ -10,12 +10,13 @@
|
||||
"context": ".",
|
||||
"dockerfile": "Dockerfile"
|
||||
},
|
||||
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh"
|
||||
"postCreateCommand": "bun install",
|
||||
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh",
|
||||
// Features add additional features to your environment. See https://containers.dev/features
|
||||
// Beware: features are not supported on all platforms and may have unintended side-effects.
|
||||
// "features": {
|
||||
// "ghcr.io/devcontainers/features/docker-in-docker": {
|
||||
// "moby": false
|
||||
// }
|
||||
// }
|
||||
"features": {
|
||||
"ghcr.io/tailscale/codespace/tailscale": {
|
||||
"version": "latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
.gitignore
vendored
@@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
core
|
||||
|
||||
8
.ona/automations.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
expo:
|
||||
name: Expo Dev Server
|
||||
description: Expo development server for aris-client
|
||||
triggeredBy:
|
||||
- postDevcontainerStart
|
||||
commands:
|
||||
start: cd apps/aris-client && ./scripts/run-dev-server.sh
|
||||
@@ -6,3 +6,9 @@ BETTER_AUTH_SECRET=
|
||||
|
||||
# Base URL of the backend
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Apple WeatherKit credentials
|
||||
WEATHERKIT_PRIVATE_KEY=
|
||||
WEATHERKIT_KEY_ID=
|
||||
WEATHERKIT_TEAM_ID=
|
||||
WEATHERKIT_SERVICE_ID=
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"@aris/source-location": "workspace:*",
|
||||
"@aris/source-tfl": "workspace:*",
|
||||
"@aris/source-weatherkit": "workspace:*",
|
||||
"arktype": "^2.1.29",
|
||||
"better-auth": "^1",
|
||||
"hono": "^4",
|
||||
"pg": "^8"
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { Context, Next } from "hono"
|
||||
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||
|
||||
import type { AuthSession, AuthUser } from "./session.ts"
|
||||
|
||||
import { auth } from "./index.ts"
|
||||
|
||||
type SessionUser = typeof auth.$Infer.Session.user
|
||||
type Session = typeof auth.$Infer.Session.session
|
||||
|
||||
export interface SessionVariables {
|
||||
user: SessionUser | null
|
||||
session: Session | null
|
||||
user: AuthUser | null
|
||||
session: AuthSession | null
|
||||
}
|
||||
|
||||
export type AuthSessionEnv = { Variables: SessionVariables }
|
||||
|
||||
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap extends SessionVariables {}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +55,22 @@ export async function requireSession(c: Context, next: Next): Promise<Response |
|
||||
*/
|
||||
export async function getSessionFromHeaders(
|
||||
headers: Headers,
|
||||
): Promise<{ user: SessionUser; session: Session } | null> {
|
||||
): Promise<{ user: AuthUser; session: AuthSession } | null> {
|
||||
const session = await auth.api.getSession({ headers })
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only middleware that injects a fake user and session.
|
||||
* Pass userId to simulate an authenticated request, or omit to get 401.
|
||||
*/
|
||||
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
|
||||
return async (c: Context, next: Next): Promise<Response | void> => {
|
||||
if (!userId) {
|
||||
return c.json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
c.set("user", { id: userId } as AuthUser)
|
||||
c.set("session", { id: "mock-session" } as AuthSession)
|
||||
await next()
|
||||
}
|
||||
}
|
||||
|
||||
4
apps/aris-backend/src/auth/session.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { auth } from "./index.ts"
|
||||
|
||||
export type AuthUser = typeof auth.$Infer.Session.user
|
||||
export type AuthSession = typeof auth.$Infer.Session.session
|
||||
140
apps/aris-backend/src/feed/http.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { ActionDefinition, Context, FeedItem, FeedSource } from "@aris/core"
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import { UserSessionManager } from "../session/index.ts"
|
||||
import { registerFeedHttpHandlers } from "./http.ts"
|
||||
|
||||
interface FeedResponse {
|
||||
items: Array<{
|
||||
id: string
|
||||
type: string
|
||||
priority: number
|
||||
timestamp: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
errors: Array<{ sourceId: string; error: string }>
|
||||
}
|
||||
|
||||
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
||||
return {
|
||||
id,
|
||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||
return {}
|
||||
},
|
||||
async executeAction(): Promise<unknown> {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext(): Promise<Partial<Context> | null> {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return items
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
||||
const app = new Hono()
|
||||
registerFeedHttpHandlers(app, {
|
||||
sessionManager,
|
||||
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
describe("GET /api/feed", () => {
|
||||
test("returns 401 without auth", async () => {
|
||||
const manager = new UserSessionManager([])
|
||||
const app = buildTestApp(manager)
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test("returns cached feed when available", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "item-1",
|
||||
type: "test",
|
||||
priority: 0.8,
|
||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||
data: { value: 42 },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
// Prime the cache
|
||||
const session = manager.getOrCreate("user-1")
|
||||
await session.engine.refresh()
|
||||
expect(session.engine.lastFeed()).not.toBeNull()
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as FeedResponse
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0]!.id).toBe("item-1")
|
||||
expect(body.items[0]!.type).toBe("test")
|
||||
expect(body.items[0]!.priority).toBe(0.8)
|
||||
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
||||
expect(body.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("forces refresh when no cached feed", async () => {
|
||||
const items: FeedItem[] = [
|
||||
{
|
||||
id: "fresh-1",
|
||||
type: "test",
|
||||
priority: 0.5,
|
||||
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
||||
data: { fresh: true },
|
||||
},
|
||||
]
|
||||
const manager = new UserSessionManager([() => createStubSource("test", items)])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as FeedResponse
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0]!.id).toBe("fresh-1")
|
||||
expect(body.items[0]!.data.fresh).toBe(true)
|
||||
expect(body.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("serializes source errors as message strings", async () => {
|
||||
const failingSource: FeedSource = {
|
||||
id: "failing",
|
||||
async listActions() {
|
||||
return {}
|
||||
},
|
||||
async executeAction() {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext() {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
throw new Error("connection timeout")
|
||||
},
|
||||
}
|
||||
const manager = new UserSessionManager([() => failingSource])
|
||||
const app = buildTestApp(manager, "user-1")
|
||||
|
||||
const res = await app.request("/api/feed")
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as FeedResponse
|
||||
expect(body.items).toHaveLength(0)
|
||||
expect(body.errors).toHaveLength(1)
|
||||
expect(body.errors[0]!.sourceId).toBe("failing")
|
||||
expect(body.errors[0]!.error).toBe("connection timeout")
|
||||
})
|
||||
})
|
||||
41
apps/aris-backend/src/feed/http.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Context, Hono } from "hono"
|
||||
|
||||
import { createMiddleware } from "hono/factory"
|
||||
|
||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
|
||||
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||
|
||||
interface FeedHttpHandlersDeps {
|
||||
sessionManager: UserSessionManager
|
||||
authSessionMiddleware: AuthSessionMiddleware
|
||||
}
|
||||
|
||||
export function registerFeedHttpHandlers(
|
||||
app: Hono,
|
||||
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
||||
) {
|
||||
const inject = createMiddleware<Env>(async (c, next) => {
|
||||
c.set("sessionManager", sessionManager)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
||||
}
|
||||
|
||||
async function handleGetFeed(c: Context<Env>) {
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
|
||||
const feed = session.engine.lastFeed() ?? (await session.engine.refresh())
|
||||
|
||||
return c.json({
|
||||
items: feed.items,
|
||||
errors: feed.errors.map((e) => ({
|
||||
sourceId: e.sourceId,
|
||||
error: e.error.message,
|
||||
})),
|
||||
})
|
||||
}
|
||||
56
apps/aris-backend/src/location/http.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Context, Hono } from "hono"
|
||||
|
||||
import { type } from "arktype"
|
||||
import { createMiddleware } from "hono/factory"
|
||||
|
||||
import type { UserSessionManager } from "../session/index.ts"
|
||||
|
||||
import { requireSession } from "../auth/session-middleware.ts"
|
||||
|
||||
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||
|
||||
const locationInput = type({
|
||||
lat: "number",
|
||||
lng: "number",
|
||||
accuracy: "number",
|
||||
timestamp: "string.date.iso",
|
||||
})
|
||||
|
||||
export function registerLocationHttpHandlers(
|
||||
app: Hono,
|
||||
{ sessionManager }: { sessionManager: UserSessionManager },
|
||||
) {
|
||||
const inject = createMiddleware<Env>(async (c, next) => {
|
||||
c.set("sessionManager", sessionManager)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.post("/api/location", inject, requireSession, handleUpdateLocation)
|
||||
}
|
||||
|
||||
async function handleUpdateLocation(c: Context<Env>) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON" }, 400)
|
||||
}
|
||||
|
||||
const result = locationInput(body)
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
return c.json({ error: result.summary }, 400)
|
||||
}
|
||||
|
||||
const user = c.get("user")!
|
||||
const sessionManager = c.get("sessionManager")
|
||||
const session = sessionManager.getOrCreate(user.id)
|
||||
await session.engine.executeAction("aris.location", "update-location", {
|
||||
lat: result.lat,
|
||||
lng: result.lng,
|
||||
accuracy: result.accuracy,
|
||||
timestamp: new Date(result.timestamp),
|
||||
})
|
||||
|
||||
return c.body(null, 204)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { UserNotFoundError } from "../lib/error.ts"
|
||||
import { LocationService } from "./service.ts"
|
||||
|
||||
describe("LocationService", () => {
|
||||
test("feedSourceForUser creates source on first call", () => {
|
||||
const service = new LocationService()
|
||||
const source = service.feedSourceForUser("user-1")
|
||||
|
||||
expect(source).toBeDefined()
|
||||
expect(source.id).toBe("location")
|
||||
})
|
||||
|
||||
test("feedSourceForUser returns same source for same user", () => {
|
||||
const service = new LocationService()
|
||||
const source1 = service.feedSourceForUser("user-1")
|
||||
const source2 = service.feedSourceForUser("user-1")
|
||||
|
||||
expect(source1).toBe(source2)
|
||||
})
|
||||
|
||||
test("feedSourceForUser returns different sources for different users", () => {
|
||||
const service = new LocationService()
|
||||
const source1 = service.feedSourceForUser("user-1")
|
||||
const source2 = service.feedSourceForUser("user-2")
|
||||
|
||||
expect(source1).not.toBe(source2)
|
||||
})
|
||||
|
||||
test("updateUserLocation updates the source", () => {
|
||||
const service = new LocationService()
|
||||
const source = service.feedSourceForUser("user-1")
|
||||
const location = {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
service.updateUserLocation("user-1", location)
|
||||
|
||||
expect(source.lastLocation).toEqual(location)
|
||||
})
|
||||
|
||||
test("updateUserLocation throws if source does not exist", () => {
|
||||
const service = new LocationService()
|
||||
const location = {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
expect(() => service.updateUserLocation("user-1", location)).toThrow(UserNotFoundError)
|
||||
})
|
||||
|
||||
test("lastUserLocation returns null for unknown user", () => {
|
||||
const service = new LocationService()
|
||||
|
||||
expect(service.lastUserLocation("unknown")).toBeNull()
|
||||
})
|
||||
|
||||
test("lastUserLocation returns last location", () => {
|
||||
const service = new LocationService()
|
||||
service.feedSourceForUser("user-1")
|
||||
const location1 = {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
const location2 = {
|
||||
lat: 52.0,
|
||||
lng: -0.2,
|
||||
accuracy: 5,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
service.updateUserLocation("user-1", location1)
|
||||
service.updateUserLocation("user-1", location2)
|
||||
|
||||
expect(service.lastUserLocation("user-1")).toEqual(location2)
|
||||
})
|
||||
|
||||
test("removeUser removes the source", () => {
|
||||
const service = new LocationService()
|
||||
service.feedSourceForUser("user-1")
|
||||
const location = {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
service.updateUserLocation("user-1", location)
|
||||
service.removeUser("user-1")
|
||||
|
||||
expect(service.lastUserLocation("user-1")).toBeNull()
|
||||
})
|
||||
|
||||
test("removeUser allows new source to be created", () => {
|
||||
const service = new LocationService()
|
||||
const source1 = service.feedSourceForUser("user-1")
|
||||
|
||||
service.removeUser("user-1")
|
||||
const source2 = service.feedSourceForUser("user-1")
|
||||
|
||||
expect(source1).not.toBe(source2)
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { LocationSource, type Location } from "@aris/source-location"
|
||||
|
||||
import { UserNotFoundError } from "../lib/error.ts"
|
||||
|
||||
/**
|
||||
* Manages LocationSource instances per user.
|
||||
*/
|
||||
export class LocationService {
|
||||
private sources = new Map<string, LocationSource>()
|
||||
|
||||
/**
|
||||
* Get or create a LocationSource for a user.
|
||||
* @param userId - The user's unique identifier
|
||||
* @returns The user's LocationSource instance
|
||||
*/
|
||||
feedSourceForUser(userId: string): LocationSource {
|
||||
let source = this.sources.get(userId)
|
||||
if (!source) {
|
||||
source = new LocationSource()
|
||||
this.sources.set(userId, source)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* Update location for a user.
|
||||
* @param userId - The user's unique identifier
|
||||
* @param location - The new location data
|
||||
* @throws {UserNotFoundError} If no source exists for the user
|
||||
*/
|
||||
updateUserLocation(userId: string, location: Location): void {
|
||||
const source = this.sources.get(userId)
|
||||
if (!source) {
|
||||
throw new UserNotFoundError(userId)
|
||||
}
|
||||
source.pushLocation(location)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last known location for a user.
|
||||
* @param userId - The user's unique identifier
|
||||
* @returns The last location, or null if none exists
|
||||
*/
|
||||
lastUserLocation(userId: string): Location | null {
|
||||
return this.sources.get(userId)?.lastLocation ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a user's LocationSource.
|
||||
* @param userId - The user's unique identifier
|
||||
*/
|
||||
removeUser(userId: string): void {
|
||||
this.sources.delete(userId)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,38 @@
|
||||
import { LocationSource } from "@aris/source-location"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { registerAuthHandlers } from "./auth/http.ts"
|
||||
import { requireSession } from "./auth/session-middleware.ts"
|
||||
import { registerFeedHttpHandlers } from "./feed/http.ts"
|
||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||
import { UserSessionManager } from "./session/index.ts"
|
||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||
|
||||
const app = new Hono()
|
||||
function main() {
|
||||
const sessionManager = new UserSessionManager([
|
||||
() => new LocationSource(),
|
||||
new WeatherSourceProvider({
|
||||
credentials: {
|
||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||
keyId: process.env.WEATHERKIT_KEY_ID!,
|
||||
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
||||
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||
const app = new Hono()
|
||||
|
||||
registerAuthHandlers(app)
|
||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||
|
||||
registerAuthHandlers(app)
|
||||
registerFeedHttpHandlers(app, { sessionManager, authSessionMiddleware: requireSession })
|
||||
registerLocationHttpHandlers(app, { sessionManager })
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
const app = main()
|
||||
|
||||
export default {
|
||||
port: 3000,
|
||||
|
||||
9
apps/aris-backend/src/session/feed-source-provider.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { FeedSource } from "@aris/core"
|
||||
|
||||
export interface FeedSourceProvider {
|
||||
feedSourceForUser(userId: string): FeedSource
|
||||
}
|
||||
|
||||
export type FeedSourceProviderFn = (userId: string) => FeedSource
|
||||
|
||||
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn
|
||||
7
apps/aris-backend/src/session/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
FeedSourceProvider,
|
||||
FeedSourceProviderFn,
|
||||
FeedSourceProviderInput,
|
||||
} from "./feed-source-provider.ts"
|
||||
export { UserSession } from "./user-session.ts"
|
||||
export { UserSessionManager } from "./user-session-manager.ts"
|
||||
166
apps/aris-backend/src/session/user-session-manager.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit"
|
||||
|
||||
import { LocationSource } from "@aris/source-location"
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { WeatherSourceProvider } from "../weather/provider.ts"
|
||||
import { UserSessionManager } from "./user-session-manager.ts"
|
||||
|
||||
const mockWeatherClient: WeatherKitClient = {
|
||||
fetch: async () => ({}) as WeatherKitResponse,
|
||||
}
|
||||
|
||||
describe("UserSessionManager", () => {
|
||||
test("getOrCreate creates session on first call", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session.engine).toBeDefined()
|
||||
})
|
||||
|
||||
test("getOrCreate returns same session for same user", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session1).toBe(session2)
|
||||
})
|
||||
|
||||
test("getOrCreate returns different sessions for different users", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-2")
|
||||
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
test("each user gets independent source instances", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
const session2 = manager.getOrCreate("user-2")
|
||||
|
||||
const source1 = session1.getSource<LocationSource>("aris.location")
|
||||
const source2 = session2.getSource<LocationSource>("aris.location")
|
||||
|
||||
expect(source1).not.toBe(source2)
|
||||
})
|
||||
|
||||
test("remove destroys session and allows re-creation", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session1 = manager.getOrCreate("user-1")
|
||||
manager.remove("user-1")
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
test("remove is no-op for unknown user", () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
expect(() => manager.remove("unknown")).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts function providers", async () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("accepts object providers", () => {
|
||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session.getSource("aris.weather")).toBeDefined()
|
||||
})
|
||||
|
||||
test("accepts mixed providers", () => {
|
||||
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||
const manager = new UserSessionManager([() => new LocationSource(), provider])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
|
||||
expect(session.getSource("aris.location")).toBeDefined()
|
||||
expect(session.getSource("aris.weather")).toBeDefined()
|
||||
})
|
||||
|
||||
test("refresh returns feed result through session", async () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
expect(result).toHaveProperty("context")
|
||||
expect(result).toHaveProperty("items")
|
||||
expect(result).toHaveProperty("errors")
|
||||
expect(result.context.time).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test("location update via executeAction works", async () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
await session.engine.executeAction("aris.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
const source = session.getSource<LocationSource>("aris.location")
|
||||
expect(source?.lastLocation?.lat).toBe(51.5074)
|
||||
})
|
||||
|
||||
test("subscribe receives updates after location push", async () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
const callback = mock()
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
session.engine.subscribe(callback)
|
||||
|
||||
await session.engine.executeAction("aris.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
// Wait for async update propagation
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("remove stops reactive updates", async () => {
|
||||
const manager = new UserSessionManager([() => new LocationSource()])
|
||||
const callback = mock()
|
||||
|
||||
const session = manager.getOrCreate("user-1")
|
||||
session.engine.subscribe(callback)
|
||||
|
||||
manager.remove("user-1")
|
||||
|
||||
// Create new session and push location — old callback should not fire
|
||||
const session2 = manager.getOrCreate("user-1")
|
||||
await session2.engine.executeAction("aris.location", "update-location", {
|
||||
lat: 51.5074,
|
||||
lng: -0.1278,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
32
apps/aris-backend/src/session/user-session-manager.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||
|
||||
import { UserSession } from "./user-session.ts"
|
||||
|
||||
export class UserSessionManager {
|
||||
private sessions = new Map<string, UserSession>()
|
||||
private readonly providers: FeedSourceProviderInput[]
|
||||
|
||||
constructor(providers: FeedSourceProviderInput[]) {
|
||||
this.providers = providers
|
||||
}
|
||||
|
||||
getOrCreate(userId: string): UserSession {
|
||||
let session = this.sessions.get(userId)
|
||||
if (!session) {
|
||||
const sources = this.providers.map((p) =>
|
||||
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
||||
)
|
||||
session = new UserSession(sources)
|
||||
this.sessions.set(userId, session)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
remove(userId: string): void {
|
||||
const session = this.sessions.get(userId)
|
||||
if (session) {
|
||||
session.destroy()
|
||||
this.sessions.delete(userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
72
apps/aris-backend/src/session/user-session.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ActionDefinition, Context, FeedSource } from "@aris/core"
|
||||
|
||||
import { LocationSource } from "@aris/source-location"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { UserSession } from "./user-session.ts"
|
||||
|
||||
function createStubSource(id: string): FeedSource {
|
||||
return {
|
||||
id,
|
||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||
return {}
|
||||
},
|
||||
async executeAction(): Promise<unknown> {
|
||||
return undefined
|
||||
},
|
||||
async fetchContext(): Promise<Partial<Context> | null> {
|
||||
return null
|
||||
},
|
||||
async fetchItems() {
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("UserSession", () => {
|
||||
test("registers sources and starts engine", async () => {
|
||||
const session = new UserSession([createStubSource("test-a"), createStubSource("test-b")])
|
||||
|
||||
const result = await session.engine.refresh()
|
||||
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("getSource returns registered source", () => {
|
||||
const location = new LocationSource()
|
||||
const session = new UserSession([location])
|
||||
|
||||
const result = session.getSource<LocationSource>("aris.location")
|
||||
|
||||
expect(result).toBe(location)
|
||||
})
|
||||
|
||||
test("getSource returns undefined for unknown source", () => {
|
||||
const session = new UserSession([createStubSource("test")])
|
||||
|
||||
expect(session.getSource("unknown")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("destroy stops engine and clears sources", () => {
|
||||
const session = new UserSession([createStubSource("test")])
|
||||
|
||||
session.destroy()
|
||||
|
||||
expect(session.getSource("test")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("engine.executeAction routes to correct source", async () => {
|
||||
const location = new LocationSource()
|
||||
const session = new UserSession([location])
|
||||
|
||||
await session.engine.executeAction("aris.location", "update-location", {
|
||||
lat: 51.5,
|
||||
lng: -0.1,
|
||||
accuracy: 10,
|
||||
timestamp: new Date(),
|
||||
})
|
||||
|
||||
expect(location.lastLocation).toBeDefined()
|
||||
expect(location.lastLocation!.lat).toBe(51.5)
|
||||
})
|
||||
})
|
||||
24
apps/aris-backend/src/session/user-session.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { FeedEngine, type FeedSource } from "@aris/core"
|
||||
|
||||
export class UserSession {
|
||||
readonly engine: FeedEngine
|
||||
private sources = new Map<string, FeedSource>()
|
||||
|
||||
constructor(sources: FeedSource[]) {
|
||||
this.engine = new FeedEngine()
|
||||
for (const source of sources) {
|
||||
this.sources.set(source.id, source)
|
||||
this.engine.register(source)
|
||||
}
|
||||
this.engine.start()
|
||||
}
|
||||
|
||||
getSource<T extends FeedSource>(sourceId: string): T | undefined {
|
||||
return this.sources.get(sourceId) as T | undefined
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.engine.stop()
|
||||
this.sources.clear()
|
||||
}
|
||||
}
|
||||
19
apps/aris-backend/src/tfl/provider.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TflSource, type ITflApi } from "@aris/source-tfl"
|
||||
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
export type TflSourceProviderOptions =
|
||||
| { apiKey: string; client?: never }
|
||||
| { apiKey?: never; client: ITflApi }
|
||||
|
||||
export class TflSourceProvider implements FeedSourceProvider {
|
||||
private readonly options: TflSourceProviderOptions
|
||||
|
||||
constructor(options: TflSourceProviderOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
feedSourceForUser(_userId: string): TflSource {
|
||||
return new TflSource(this.options)
|
||||
}
|
||||
}
|
||||
15
apps/aris-backend/src/weather/provider.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit"
|
||||
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
export class WeatherSourceProvider implements FeedSourceProvider {
|
||||
private readonly options: WeatherSourceOptions
|
||||
|
||||
constructor(options: WeatherSourceOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
feedSourceForUser(_userId: string): WeatherSource {
|
||||
return new WeatherSource(this.options)
|
||||
}
|
||||
}
|
||||
43
apps/aris-client/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
1
apps/aris-client/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
apps/aris-client/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
50
apps/aris-client/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Welcome to your Expo app 👋
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
|
||||
## Get started
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
152
apps/aris-client/app.json
Normal file
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Aris",
|
||||
"slug": "aris-client",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "aris",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"bundleIdentifier": "sh.nym.aris"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "sh.nym.aris"
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-font",
|
||||
{
|
||||
"android": {
|
||||
"fonts": [
|
||||
{
|
||||
"fontFamily": "Inter",
|
||||
"fontDefinitions": [
|
||||
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
|
||||
{ "path": "./assets/fonts/Inter_100Thin_Italic.ttf", "weight": 100, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
|
||||
{ "path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
|
||||
{ "path": "./assets/fonts/Inter_300Light_Italic.ttf", "weight": 300, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
|
||||
{ "path": "./assets/fonts/Inter_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
|
||||
{ "path": "./assets/fonts/Inter_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
|
||||
{ "path": "./assets/fonts/Inter_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
|
||||
{ "path": "./assets/fonts/Inter_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
|
||||
{ "path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
|
||||
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
|
||||
{ "path": "./assets/fonts/Inter_900Black_Italic.ttf", "weight": 900, "style": "italic" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fontFamily": "Source Serif 4",
|
||||
"fontDefinitions": [
|
||||
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf", "weight": 300, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
|
||||
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
|
||||
{ "path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf", "weight": 900, "style": "italic" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ios": {
|
||||
"fonts": [
|
||||
"./assets/fonts/Inter_100Thin.ttf",
|
||||
"./assets/fonts/Inter_100Thin_Italic.ttf",
|
||||
"./assets/fonts/Inter_200ExtraLight.ttf",
|
||||
"./assets/fonts/Inter_200ExtraLight_Italic.ttf",
|
||||
"./assets/fonts/Inter_300Light.ttf",
|
||||
"./assets/fonts/Inter_300Light_Italic.ttf",
|
||||
"./assets/fonts/Inter_400Regular.ttf",
|
||||
"./assets/fonts/Inter_400Regular_Italic.ttf",
|
||||
"./assets/fonts/Inter_500Medium.ttf",
|
||||
"./assets/fonts/Inter_500Medium_Italic.ttf",
|
||||
"./assets/fonts/Inter_600SemiBold.ttf",
|
||||
"./assets/fonts/Inter_600SemiBold_Italic.ttf",
|
||||
"./assets/fonts/Inter_700Bold.ttf",
|
||||
"./assets/fonts/Inter_700Bold_Italic.ttf",
|
||||
"./assets/fonts/Inter_800ExtraBold.ttf",
|
||||
"./assets/fonts/Inter_800ExtraBold_Italic.ttf",
|
||||
"./assets/fonts/Inter_900Black.ttf",
|
||||
"./assets/fonts/Inter_900Black_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_200ExtraLight.ttf",
|
||||
"./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_300Light.ttf",
|
||||
"./assets/fonts/SourceSerif4_300Light_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_400Regular.ttf",
|
||||
"./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_500Medium.ttf",
|
||||
"./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_600SemiBold.ttf",
|
||||
"./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_700Bold.ttf",
|
||||
"./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_800ExtraBold.ttf",
|
||||
"./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
|
||||
"./assets/fonts/SourceSerif4_900Black.ttf",
|
||||
"./assets/fonts/SourceSerif4_900Black_Italic.ttf"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "61092d23-36aa-418e-929d-ea40dc912e8f"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/aris-client/assets/fonts/Inter_100Thin.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_100Thin_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_200ExtraLight.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_200ExtraLight_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_300Light.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_300Light_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_400Regular.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_400Regular_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_500Medium.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_500Medium_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_600SemiBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_600SemiBold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_700Bold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_700Bold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_800ExtraBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_800ExtraBold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_900Black.ttf
Normal file
BIN
apps/aris-client/assets/fonts/Inter_900Black_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_200ExtraLight.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_300Light.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_300Light_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_400Regular.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_400Regular_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_500Medium.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_500Medium_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_600SemiBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_700Bold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_700Bold_Italic.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_800ExtraBold.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_900Black.ttf
Normal file
BIN
apps/aris-client/assets/fonts/SourceSerif4_900Black_Italic.ttf
Normal file
BIN
apps/aris-client/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/aris-client/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/aris-client/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/aris-client/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/aris-client/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/aris-client/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apps/aris-client/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/aris-client/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/aris-client/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/aris-client/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
27
apps/aris-client/eas.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 18.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"development-simulator": {
|
||||
"extends": "development",
|
||||
"ios": {
|
||||
"simulator": "true"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
10
apps/aris-client/eslint.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require("eslint/config")
|
||||
const expoConfig = require("eslint-config-expo/flat")
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ["dist/*"],
|
||||
},
|
||||
])
|
||||
57
apps/aris-client/package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "aris-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "./scripts/run-dev-server.sh",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"build:ios": "eas build --profile development --platform ios --non-interactive",
|
||||
"build:ios-simulator": "eas build --profile development-simulator --platform ios --non-interactive",
|
||||
"debugger": "bun run scripts/open-debugger.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.33",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-location": "~19.0.8",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"twrnc": "^4.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"eas-cli": "^18.0.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
127
apps/aris-client/scripts/dev-proxy.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// Reverse proxy that sits in front of Metro so that all requests
|
||||
// (including those arriving via Tailscale or Ona port-forwarding) reach
|
||||
// Metro as loopback connections. This satisfies the isLocalSocket check
|
||||
// in Expo's debug middleware, making /debugger-frontend, /json, and
|
||||
// /open-debugger accessible from a remote browser.
|
||||
|
||||
import type { ServerWebSocket } from "bun"
|
||||
|
||||
const PROXY_PORT = parseInt(process.env.PROXY_PORT || "8080", 10)
|
||||
const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10)
|
||||
const METRO_BASE = `http://127.0.0.1:${METRO_PORT}`
|
||||
|
||||
function forwardHeaders(headers: Headers): Headers {
|
||||
const result = new Headers(headers)
|
||||
result.delete("origin")
|
||||
result.delete("referer")
|
||||
result.set("host", `127.0.0.1:${METRO_PORT}`)
|
||||
return result
|
||||
}
|
||||
|
||||
interface WsData {
|
||||
upstream: WebSocket
|
||||
isDevice: boolean
|
||||
}
|
||||
|
||||
Bun.serve<WsData>({
|
||||
port: PROXY_PORT,
|
||||
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
// WebSocket upgrade — bridge to Metro's ws endpoint
|
||||
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
||||
const wsUrl = `ws://127.0.0.1:${METRO_PORT}${url.pathname}${url.search}`
|
||||
const upstream = new WebSocket(wsUrl)
|
||||
|
||||
// Wait for upstream to connect before upgrading the client
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstream.addEventListener("open", () => resolve())
|
||||
upstream.addEventListener("error", () => reject(new Error("upstream ws failed")))
|
||||
})
|
||||
} catch {
|
||||
return new Response("Upstream WebSocket unavailable", { status: 502 })
|
||||
}
|
||||
|
||||
const isDevice = url.pathname.startsWith("/inspector/device")
|
||||
const ok = server.upgrade(req, { data: { upstream, isDevice } })
|
||||
if (!ok) {
|
||||
upstream.close()
|
||||
return new Response("WebSocket upgrade failed", { status: 500 })
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// HTTP proxy
|
||||
const upstream = `${METRO_BASE}${url.pathname}${url.search}`
|
||||
const res = await fetch(upstream, {
|
||||
method: req.method,
|
||||
headers: forwardHeaders(req.headers),
|
||||
body: req.body,
|
||||
redirect: "manual",
|
||||
})
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
})
|
||||
},
|
||||
|
||||
websocket: {
|
||||
message(ws: ServerWebSocket<WsData>, msg) {
|
||||
ws.data.upstream.send(msg)
|
||||
},
|
||||
open(ws: ServerWebSocket<WsData>) {
|
||||
const { upstream } = ws.data
|
||||
upstream.addEventListener("message", (ev) => {
|
||||
if (typeof ev.data === "string") {
|
||||
ws.send(ev.data)
|
||||
} else if (ev.data instanceof ArrayBuffer) {
|
||||
ws.sendBinary(new Uint8Array(ev.data))
|
||||
}
|
||||
})
|
||||
upstream.addEventListener("close", () => ws.close())
|
||||
upstream.addEventListener("error", () => ws.close())
|
||||
|
||||
// Print debugger URL shortly after a device connects,
|
||||
// giving Metro time to register the target.
|
||||
if (ws.data.isDevice) {
|
||||
setTimeout(() => printDebuggerUrl(), 1000)
|
||||
}
|
||||
},
|
||||
close(ws: ServerWebSocket<WsData>) {
|
||||
ws.data.upstream.close()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const tsIp = await Bun.$`tailscale ip -4`.text().then((s) => s.trim())
|
||||
|
||||
async function printDebuggerUrl() {
|
||||
const base = `http://${tsIp}:${PROXY_PORT}`
|
||||
const res = await fetch(`${METRO_BASE}/json`)
|
||||
if (!res.ok) return
|
||||
|
||||
interface DebugTarget {
|
||||
webSocketDebuggerUrl: string
|
||||
reactNative?: {
|
||||
capabilities?: { prefersFuseboxFrontend?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
const targets: DebugTarget[] = await res.json()
|
||||
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
|
||||
if (!target) return
|
||||
|
||||
const wsPath = target.webSocketDebuggerUrl
|
||||
.replace(/^ws:\/\//, "")
|
||||
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
|
||||
|
||||
console.log(
|
||||
`\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[proxy] listening on :${PROXY_PORT}, forwarding to 127.0.0.1:${METRO_PORT}`)
|
||||
52
apps/aris-client/scripts/open-debugger.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Opens React Native DevTools in Chrome, connected to the first
|
||||
// available Hermes debug target. Requires Metro + proxy to be running.
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
const PROXY_PORT = process.env.PROXY_PORT || "8080"
|
||||
const METRO_PORT = process.env.METRO_PORT || "8081"
|
||||
const tsIp = (await $`tailscale ip -4`.text()).trim()
|
||||
const base = `http://${tsIp}:${PROXY_PORT}`
|
||||
|
||||
interface DebugTarget {
|
||||
devtoolsFrontendUrl: string
|
||||
webSocketDebuggerUrl: string
|
||||
reactNative?: {
|
||||
capabilities?: {
|
||||
prefersFuseboxFrontend?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/json`)
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch /json — is Metro running?")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const targets: DebugTarget[] = await res.json()
|
||||
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
|
||||
|
||||
if (!target) {
|
||||
console.error("No debug target found. Is the app connected?")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const wsUrl = target.webSocketDebuggerUrl
|
||||
.replace(/^ws:\/\//, "")
|
||||
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
|
||||
|
||||
const url = `${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsUrl)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true`
|
||||
|
||||
console.log(url)
|
||||
|
||||
// Open in Chrome app mode if on macOS
|
||||
try {
|
||||
await $`open -a "Google Chrome" --args --app=${url}`.quiet()
|
||||
} catch {
|
||||
try {
|
||||
await $`xdg-open ${url}`.quiet()
|
||||
} catch {
|
||||
console.log("Open the URL above in Chrome.")
|
||||
}
|
||||
}
|
||||
112
apps/aris-client/scripts/reset-project.js
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const readline = require("readline")
|
||||
|
||||
const root = process.cwd()
|
||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"]
|
||||
const exampleDir = "app-example"
|
||||
const newAppDir = "app"
|
||||
const exampleDirPath = path.join(root, exampleDir)
|
||||
|
||||
const indexContent = `import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true })
|
||||
console.log(`📁 /${exampleDir} directory created.`)
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir)
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir)
|
||||
await fs.promises.rename(oldDirPath, newDirPath)
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`)
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true })
|
||||
console.log(`❌ /${dir} deleted.`)
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /app directory
|
||||
const newAppDirPath = path.join(root, newAppDir)
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true })
|
||||
console.log("\n📁 New /app directory created.")
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx")
|
||||
await fs.promises.writeFile(indexPath, indexContent)
|
||||
console.log("📄 app/index.tsx created.")
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx")
|
||||
await fs.promises.writeFile(layoutPath, layoutContent)
|
||||
console.log("📄 app/_layout.tsx created.")
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:")
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y"
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close())
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.")
|
||||
rl.close()
|
||||
}
|
||||
},
|
||||
)
|
||||
15
apps/aris-client/scripts/run-dev-server.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROXY_PORT=8080
|
||||
METRO_PORT=8081
|
||||
|
||||
# Start a reverse proxy so Metro sees all requests as loopback.
|
||||
# This makes debugger endpoints (/debugger-frontend, /json, /open-debugger)
|
||||
# accessible through the Tailscale IP.
|
||||
PROXY_PORT=$PROXY_PORT METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts &
|
||||
PROXY_PID=$!
|
||||
trap "kill $PROXY_PID 2>/dev/null" EXIT
|
||||
|
||||
EXPO_PACKAGER_PROXY_URL=http://$(tailscale ip -4):$PROXY_PORT bunx expo start --localhost -p $METRO_PORT
|
||||
|
||||
36
apps/aris-client/src/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Tabs } from "expo-router"
|
||||
import React from "react"
|
||||
|
||||
import { HapticTab } from "@/components/haptic-tab"
|
||||
import { IconSymbol } from "@/components/ui/icon-symbol"
|
||||
import { Colors } from "@/constants/theme"
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme"
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
title: "Explore",
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
114
apps/aris-client/src/app/(tabs)/explore.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Image } from "expo-image"
|
||||
import { Platform, StyleSheet } from "react-native"
|
||||
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import ParallaxScrollView from "@/components/parallax-scroll-view"
|
||||
import { ThemedText } from "@/components/themed-text"
|
||||
import { ThemedView } from "@/components/themed-view"
|
||||
import { Collapsible } from "@/components/ui/collapsible"
|
||||
import { IconSymbol } from "@/components/ui/icon-symbol"
|
||||
import { Fonts } from "@/constants/theme"
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: "#D0D0D0", dark: "#353636" }}
|
||||
headerImage={
|
||||
<IconSymbol
|
||||
size={310}
|
||||
color="#808080"
|
||||
name="chevron.left.forwardslash.chevron.right"
|
||||
style={styles.headerImage}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText
|
||||
type="title"
|
||||
style={{
|
||||
fontFamily: Fonts.rounded,
|
||||
}}
|
||||
>
|
||||
Explore
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText>
|
||||
This app has two screens:{" "}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{" "}
|
||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText>
|
||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{" "}
|
||||
sets up the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedText>
|
||||
You can open this project on Android, iOS, and the web. To open the web version, press{" "}
|
||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
<Collapsible title="Images">
|
||||
<ThemedText>
|
||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{" "}
|
||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
||||
different screen densities
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require("@assets/images/react-logo.png")}
|
||||
style={{ width: 100, height: 100, alignSelf: "center" }}
|
||||
/>
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText>
|
||||
This template has light and dark mode support. The{" "}
|
||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="link">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText>
|
||||
This template includes an example of an animated component. The{" "}
|
||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
||||
the powerful{" "}
|
||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
||||
react-native-reanimated
|
||||
</ThemedText>{" "}
|
||||
library to create a waving hand animation.
|
||||
</ThemedText>
|
||||
{Platform.select({
|
||||
ios: (
|
||||
<ThemedText>
|
||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{" "}
|
||||
component provides a parallax effect for the header image.
|
||||
</ThemedText>
|
||||
),
|
||||
})}
|
||||
</Collapsible>
|
||||
</ParallaxScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerImage: {
|
||||
color: "#808080",
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: "absolute",
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
},
|
||||
})
|
||||
96
apps/aris-client/src/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Image } from "expo-image"
|
||||
import { Link } from "expo-router"
|
||||
import { Platform, StyleSheet } from "react-native"
|
||||
|
||||
import { HelloWave } from "@/components/hello-wave"
|
||||
import ParallaxScrollView from "@/components/parallax-scroll-view"
|
||||
import { ThemedText } from "@/components/themed-text"
|
||||
import { ThemedView } from "@/components/themed-view"
|
||||
|
||||
export default function HomeScreen() {
|
||||
return (
|
||||
<ParallaxScrollView
|
||||
headerBackgroundColor={{ light: "#A1CEDC", dark: "#1D3D47" }}
|
||||
headerImage={
|
||||
<Image source={require("@assets/images/partial-react-logo.png")} style={styles.reactLogo} />
|
||||
}
|
||||
>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="title">Welcome!</ThemedText>
|
||||
<HelloWave />
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
||||
<ThemedText>
|
||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
||||
Press{" "}
|
||||
<ThemedText type="defaultSemiBold">
|
||||
{Platform.select({
|
||||
ios: "cmd + d",
|
||||
android: "cmd + m",
|
||||
web: "F12",
|
||||
})}
|
||||
</ThemedText>{" "}
|
||||
to open developer tools.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<Link href="/modal">
|
||||
<Link.Trigger>
|
||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
||||
</Link.Trigger>
|
||||
<Link.Preview />
|
||||
<Link.Menu>
|
||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert("Action pressed")} />
|
||||
<Link.MenuAction
|
||||
title="Share"
|
||||
icon="square.and.arrow.up"
|
||||
onPress={() => alert("Share pressed")}
|
||||
/>
|
||||
<Link.Menu title="More" icon="ellipsis">
|
||||
<Link.MenuAction
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
destructive
|
||||
onPress={() => alert("Delete pressed")}
|
||||
/>
|
||||
</Link.Menu>
|
||||
</Link.Menu>
|
||||
</Link>
|
||||
|
||||
<ThemedText>
|
||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedView style={styles.stepContainer}>
|
||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
||||
<ThemedText>
|
||||
{`When you're ready, run `}
|
||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{" "}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{" "}
|
||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{" "}
|
||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ParallaxScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titleContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
},
|
||||
})
|
||||
23
apps/aris-client/src/app/_layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"
|
||||
import { Stack } from "expo-router"
|
||||
import { StatusBar } from "expo-status-bar"
|
||||
import "react-native-reanimated"
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme"
|
||||
|
||||
export const unstable_settings = {
|
||||
anchor: "(tabs)",
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme()
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: "modal", title: "Modal" }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
29
apps/aris-client/src/app/modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Link } from "expo-router"
|
||||
import { StyleSheet } from "react-native"
|
||||
|
||||
import { ThemedText } from "@/components/themed-text"
|
||||
import { ThemedView } from "@/components/themed-view"
|
||||
|
||||
export default function ModalScreen() {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This is a modal</ThemedText>
|
||||
<Link href="/" dismissTo style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen</ThemedText>
|
||||
</Link>
|
||||
</ThemedView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
})
|
||||
25
apps/aris-client/src/components/external-link.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Href, Link } from "expo-router"
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from "expo-web-browser"
|
||||
import { type ComponentProps } from "react"
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, "href"> & { href: Href & string }
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== "web") {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault()
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
18
apps/aris-client/src/components/haptic-tab.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BottomTabBarButtonProps } from "@react-navigation/bottom-tabs"
|
||||
import { PlatformPressable } from "@react-navigation/elements"
|
||||
import * as Haptics from "expo-haptics"
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === "ios") {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
|
||||
}
|
||||
props.onPressIn?.(ev)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
20
apps/aris-client/src/components/hello-wave.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import Animated from "react-native-reanimated"
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
"50%": { transform: [{ rotate: "25deg" }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: "300ms",
|
||||
}}
|
||||
>
|
||||
👋
|
||||
</Animated.Text>
|
||||
)
|
||||
}
|
||||
82
apps/aris-client/src/components/parallax-scroll-view.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { PropsWithChildren, ReactElement } from "react"
|
||||
|
||||
import { StyleSheet } from "react-native"
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from "react-native-reanimated"
|
||||
|
||||
import { ThemedView } from "@/components/themed-view"
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme"
|
||||
import { useThemeColor } from "@/hooks/use-theme-color"
|
||||
|
||||
const HEADER_HEIGHT = 250
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement
|
||||
headerBackgroundColor: { dark: string; light: string }
|
||||
}>
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, "background")
|
||||
const colorScheme = useColorScheme() ?? "light"
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>()
|
||||
const scrollOffset = useScrollOffset(scrollRef)
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: "hidden",
|
||||
},
|
||||
})
|
||||
60
apps/aris-client/src/components/themed-text.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { StyleSheet, Text, type TextProps } from "react-native"
|
||||
|
||||
import { useThemeColor } from "@/hooks/use-theme-color"
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string
|
||||
darkColor?: string
|
||||
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link"
|
||||
}
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = "default",
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, "text")
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === "default" ? styles.default : undefined,
|
||||
type === "title" ? styles.title : undefined,
|
||||
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
|
||||
type === "subtitle" ? styles.subtitle : undefined,
|
||||
type === "link" ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: "600",
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: "#0a7ea4",
|
||||
},
|
||||
})
|
||||
14
apps/aris-client/src/components/themed-view.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { View, type ViewProps } from "react-native"
|
||||
|
||||
import { useThemeColor } from "@/hooks/use-theme-color"
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string
|
||||
darkColor?: string
|
||||
}
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, "background")
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />
|
||||
}
|
||||
46
apps/aris-client/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { PropsWithChildren, useState } from "react"
|
||||
import { StyleSheet, TouchableOpacity } from "react-native"
|
||||
|
||||
import { ThemedText } from "@/components/themed-text"
|
||||
import { ThemedView } from "@/components/themed-view"
|
||||
import { IconSymbol } from "@/components/ui/icon-symbol"
|
||||
import { Colors } from "@/constants/theme"
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme"
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const theme = useColorScheme() ?? "light"
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === "light" ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? "90deg" : "0deg" }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
})
|
||||
32
apps/aris-client/src/components/ui/icon-symbol.ios.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols"
|
||||
import { StyleProp, ViewStyle } from "react-native"
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = "regular",
|
||||
}: {
|
||||
name: SymbolViewProps["name"]
|
||||
size?: number
|
||||
color: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
weight?: SymbolWeight
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
41
apps/aris-client/src/components/ui/icon-symbol.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from "@expo/vector-icons/MaterialIcons"
|
||||
import { SymbolWeight, SymbolViewProps } from "expo-symbols"
|
||||
import { ComponentProps } from "react"
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from "react-native"
|
||||
|
||||
type IconMapping = Record<SymbolViewProps["name"], ComponentProps<typeof MaterialIcons>["name"]>
|
||||
type IconSymbolName = keyof typeof MAPPING
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
"house.fill": "home",
|
||||
"paperplane.fill": "send",
|
||||
"chevron.left.forwardslash.chevron.right": "code",
|
||||
"chevron.right": "chevron-right",
|
||||
} as IconMapping
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName
|
||||
size?: number
|
||||
color: string | OpaqueColorValue
|
||||
style?: StyleProp<TextStyle>
|
||||
weight?: SymbolWeight
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />
|
||||
}
|
||||
53
apps/aris-client/src/constants/theme.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
import { Platform } from "react-native"
|
||||
|
||||
const tintColorLight = "#0a7ea4"
|
||||
const tintColorDark = "#fff"
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: "#11181C",
|
||||
background: "#fff",
|
||||
tint: tintColorLight,
|
||||
icon: "#687076",
|
||||
tabIconDefault: "#687076",
|
||||
tabIconSelected: tintColorLight,
|
||||
},
|
||||
dark: {
|
||||
text: "#ECEDEE",
|
||||
background: "#151718",
|
||||
tint: tintColorDark,
|
||||
icon: "#9BA1A6",
|
||||
tabIconDefault: "#9BA1A6",
|
||||
tabIconSelected: tintColorDark,
|
||||
},
|
||||
}
|
||||
|
||||
export const Fonts = Platform.select({
|
||||
ios: {
|
||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||
sans: "system-ui",
|
||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||
serif: "ui-serif",
|
||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||
rounded: "ui-rounded",
|
||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||
mono: "ui-monospace",
|
||||
},
|
||||
default: {
|
||||
sans: "normal",
|
||||
serif: "serif",
|
||||
rounded: "normal",
|
||||
mono: "monospace",
|
||||
},
|
||||
web: {
|
||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
||||
},
|
||||
})
|
||||
1
apps/aris-client/src/hooks/use-color-scheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useColorScheme } from "react-native"
|
||||
21
apps/aris-client/src/hooks/use-color-scheme.web.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useColorScheme as useRNColorScheme } from "react-native"
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true)
|
||||
}, [])
|
||||
|
||||
const colorScheme = useRNColorScheme()
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme
|
||||
}
|
||||
|
||||
return "light"
|
||||
}
|
||||
21
apps/aris-client/src/hooks/use-theme-color.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from "@/constants/theme"
|
||||
import { useColorScheme } from "@/hooks/use-color-scheme"
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
|
||||
) {
|
||||
const theme = useColorScheme() ?? "light"
|
||||
const colorFromProps = props[theme]
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps
|
||||
} else {
|
||||
return Colors[theme][colorName]
|
||||
}
|
||||
}
|
||||
11
apps/aris-client/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@assets/*": ["./assets/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
@@ -16,8 +16,8 @@ Examples of feed items:
|
||||
## Design Principles
|
||||
|
||||
1. **Extensibility**: The core must support different data sources, including third-party sources.
|
||||
2. **Separation of concerns**: Core handles data only. UI rendering is a separate system.
|
||||
3. **Parallel execution**: Sources run in parallel; no inter-source dependencies.
|
||||
2. **Separation of concerns**: Core handles data and UI description. The client is a thin renderer.
|
||||
3. **Dependency graph**: Sources declare dependencies on other sources. The engine resolves the graph and runs independent sources in parallel.
|
||||
4. **Graceful degradation**: Failed sources are skipped; partial results are returned.
|
||||
|
||||
## Architecture
|
||||
@@ -25,26 +25,28 @@ Examples of feed items:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Backend │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ aris-core │ │ Sources │ │ UI Registry │ │
|
||||
│ │ │ │ (plugins) │ │ (schemas from │ │
|
||||
│ │ - Reconciler│◄───│ - Calendar │ │ third parties)│ │
|
||||
│ │ - Context │ │ - Weather │ │ │ │
|
||||
│ │ - FeedItem │ │ - Spotify │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ Feed (data only) UI Schemas (JSON) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ aris-core │ │ Sources │ │
|
||||
│ │ │ │ (plugins) │ │
|
||||
│ │ - FeedEngine│◄───│ - Calendar │ │
|
||||
│ │ - Context │ │ - Weather │ │
|
||||
│ │ - FeedItem │ │ - TfL │ │
|
||||
│ │ - Actions │ │ - Spotify │ │
|
||||
│ └─────────────┘ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Feed items (data + ui trees + slots) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
│
|
||||
▼ (WebSocket / JSON-RPC)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
│ Client (React Native) │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Renderer │ │
|
||||
│ │ - Receives feed items │ │
|
||||
│ │ - Fetches UI schema by item type │ │
|
||||
│ │ - Renders using json-render or similar │ │
|
||||
│ │ json-render + twrnc component map │ │
|
||||
│ │ - Receives feed items with ui trees │ │
|
||||
│ │ - Renders using registered RN components + twrnc │ │
|
||||
│ │ - User interactions trigger source actions │ │
|
||||
│ │ - Bespoke native components for rich interactions │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -54,15 +56,16 @@ Examples of feed items:
|
||||
The core is responsible for:
|
||||
|
||||
- Defining the context and feed item interfaces
|
||||
- Providing a reconciler that orchestrates data sources
|
||||
- Providing a `FeedEngine` that orchestrates sources via a dependency graph
|
||||
- Returning a flat list of prioritized feed items
|
||||
- Routing action execution to the correct source
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Context**: Time and location (with accuracy) passed to all sources
|
||||
- **FeedItem**: Has an ID (source-generated, stable), type, priority, timestamp, and JSON-serializable data
|
||||
- **DataSource**: Interface that third parties implement to provide feed items
|
||||
- **Reconciler**: Orchestrates sources, runs them in parallel, returns items and any errors
|
||||
- **Context**: Time and location (with accuracy) passed to all sources. Sources can contribute to context (e.g., location source provides coordinates, weather source provides conditions).
|
||||
- **FeedItem**: Has an ID (source-generated, stable), type, timestamp, JSON-serializable data, optional actions, an optional `ui` tree, and optional `slots` for LLM-fillable content.
|
||||
- **FeedSource**: Interface that first and third parties implement to provide context, feed items, and actions. Uses reverse-domain IDs (e.g., `aris.weather`, `com.spotify`).
|
||||
- **FeedEngine**: Orchestrates sources respecting their dependency graph, runs independent sources in parallel, returns items and any errors. Routes action execution to the correct source.
|
||||
|
||||
## Data Sources
|
||||
|
||||
@@ -71,10 +74,13 @@ Key decisions:
|
||||
- Sources receive the full context and decide internally what to use
|
||||
- Each source returns a single item type (e.g., separate "Calendar Source" and "Location Suggestion Source" rather than a combined "Google Source")
|
||||
- Sources live in separate packages, not in the core
|
||||
- Sources declare dependencies on other sources (e.g., weather depends on location)
|
||||
- Sources are responsible for:
|
||||
- Transforming their domain data into feed items
|
||||
- Assigning priority based on domain logic (e.g., "event starting in 10 minutes" = high priority)
|
||||
- Returning empty arrays when nothing is relevant
|
||||
- Providing a `ui` tree for each feed item
|
||||
- Declaring and handling actions (e.g., RSVP, complete task, play/pause)
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -83,28 +89,323 @@ Configuration is passed at source registration time, not per reconcile call. Sou
|
||||
## Feed Output
|
||||
|
||||
- Flat list of `FeedItem` objects
|
||||
- No UI information (no icons, card types, etc.)
|
||||
- Items carry data, an optional `ui` field describing their layout, and optional `slots` for LLM enhancement
|
||||
- Items are a discriminated union by `type` field
|
||||
- Reconciler sorts by priority; can act as tiebreaker
|
||||
|
||||
## UI Rendering (Separate from Core)
|
||||
## UI Rendering: Server-Driven UI
|
||||
|
||||
The core does not handle UI. For extensible third-party UI:
|
||||
The UI for feed items is **server-driven**. Sources describe how their items look using a JSON tree (the `ui` field on `FeedItem`). The client renders these trees using [json-render](https://json-render.dev/) with a registered set of React Native components styled via [twrnc](https://github.com/jaredh159/tailwind-react-native-classnames).
|
||||
|
||||
1. Third-party apps register their UI schemas through the backend (UI Registry)
|
||||
2. Frontend fetches UI schemas from the backend
|
||||
3. Frontend matches feed items to schemas by `type` and renders accordingly
|
||||
### How it works
|
||||
|
||||
This approach:
|
||||
1. Sources return feed items with a `ui` field — a JSON tree describing the card layout using Tailwind class strings.
|
||||
2. The client passes a component map to json-render. Each component wraps a React Native primitive and resolves `className` via twrnc.
|
||||
3. json-render walks the tree and renders native components. twrnc parses Tailwind classes at runtime — no build step, arbitrary values work.
|
||||
4. User interactions (tap, etc.) map to source actions via the `actions` field on `FeedItem`. The client sends action requests to the backend, which routes them to the correct source via `FeedEngine.executeAction()`.
|
||||
|
||||
- Keeps the core focused on data
|
||||
- Works across platforms (web, React Native)
|
||||
- Avoids the need for third parties to inject code into the app
|
||||
- Uses a json-render style approach for declarative UI from JSON schemas
|
||||
### Styling
|
||||
|
||||
Reference: https://github.com/vercel-labs/json-render
|
||||
- Sources use Tailwind CSS class strings via the `className` prop (e.g., `"p-4 bg-white dark:bg-black rounded-xl"`).
|
||||
- twrnc resolves classes to React Native style objects at runtime. Supports arbitrary values (`mt-[31px]`, `bg-[#eaeaea]`), dark mode (`dark:bg-black`), and platform prefixes (`ios:pt-4 android:pt-2`).
|
||||
- Custom colors and spacing are configured via `tailwind.config.js` on the client.
|
||||
- No compile-time constraint — all styles resolve at runtime.
|
||||
|
||||
### Two tiers of UI
|
||||
|
||||
- **Server-driven (default):** Any source can return a `ui` tree. Covers most cards — weather, tasks, alerts, package tracking, news, etc. Simple interactions go through source actions. This is the default path for both first-party and third-party sources.
|
||||
- **Bespoke native:** For cards that need rich client interaction (gestures, animations, real-time updates), a native React Native component is registered in the json-render component map and referenced by type. Third parties that need this level of richness work with the ARIS team to get it integrated.
|
||||
|
||||
### Why server-driven
|
||||
|
||||
- Feed items are inherently server-driven — the data comes from sources on the backend. Attaching the layout alongside the data is a natural extension.
|
||||
- Card designs can be updated without shipping an app update.
|
||||
- Third-party sources can ship their own UI without bundling anything new into the app.
|
||||
|
||||
Reference: https://json-render.dev/
|
||||
|
||||
## Feed Items with UI and Slots
|
||||
|
||||
> Note: the codebase has evolved since the sections above. The engine now uses a dependency graph with topological ordering (`FeedEngine`, `FeedSource`), not the parallel reconciler described above. The `priority` field is being replaced by post-processing (see the ideas doc). This section describes the UI and enhancement architecture going forward.
|
||||
|
||||
Feed items carry an optional `ui` field containing a json-render tree, and an optional `slots` field for LLM-fillable content.
|
||||
|
||||
```typescript
|
||||
interface FeedItem<TType, TData> {
|
||||
id: string
|
||||
type: TType
|
||||
timestamp: Date
|
||||
data: TData
|
||||
ui?: JsonRenderNode
|
||||
slots?: Record<string, Slot>
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
/** Tells the LLM what this slot wants — the source writes this */
|
||||
description: string
|
||||
/** LLM-filled text content, null until enhanced */
|
||||
content: string | null
|
||||
}
|
||||
```
|
||||
|
||||
### How it works
|
||||
|
||||
The source produces the item with a UI tree and empty slots:
|
||||
|
||||
```typescript
|
||||
// Weather source produces:
|
||||
{
|
||||
id: "weather-current-123",
|
||||
type: "weather-current",
|
||||
data: { temperature: 18, condition: "cloudy" },
|
||||
ui: {
|
||||
component: "VStack",
|
||||
children: [
|
||||
{ component: "WeatherHeader", props: { temp: 18, condition: "cloudy" } },
|
||||
{ component: "Slot", props: { name: "insight" } },
|
||||
{ component: "HourlyChart", props: { hours: [...] } },
|
||||
{ component: "Slot", props: { name: "cross-source" } },
|
||||
]
|
||||
},
|
||||
slots: {
|
||||
"insight": {
|
||||
description: "A short contextual insight about the current weather and how it affects the user's day",
|
||||
content: null
|
||||
},
|
||||
"cross-source": {
|
||||
description: "Connection between weather and the user's calendar events or plans",
|
||||
content: null
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The LLM enhancement harness fills `content`:
|
||||
|
||||
```typescript
|
||||
slots: {
|
||||
"insight": {
|
||||
description: "...",
|
||||
content: "Rain after 3pm — grab a jacket before your walk"
|
||||
},
|
||||
"cross-source": {
|
||||
description: "...",
|
||||
content: "Should be dry by 7pm for your dinner at The Ivy"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The client renders the `ui` tree. When it hits a `Slot` node, it looks up `slots[name].content`. If non-null, render the text. If null, render nothing.
|
||||
|
||||
### Separation of concerns
|
||||
|
||||
- **Sources** own the UI layout and declare what slots exist with descriptions.
|
||||
- **The LLM** fills slot content. It doesn't know about layout or positioning.
|
||||
- **The client** renders the UI tree and resolves slots to their content.
|
||||
|
||||
Sources define the prompt for each slot via the `description` field. The harness doesn't need to know what slots any source type has — it reads them dynamically from the items.
|
||||
|
||||
Each source defines its own slots. The harness handles them automatically — no central registry needed.
|
||||
|
||||
## Enhancement Harness
|
||||
|
||||
The LLM enhancement harness fills slots and produces synthetic feed items. It runs reactively — triggered by context changes, not by a timer.
|
||||
|
||||
### Execution model
|
||||
|
||||
```
|
||||
FeedEngine.refresh()
|
||||
→ sources produce items with ui + empty slots
|
||||
↓
|
||||
Fast path (rule-based post-processors, <10ms)
|
||||
→ group, dedup, affinity, time-adjust
|
||||
→ merge LAST cached slot fills + synthetic items
|
||||
→ return feed to UI immediately
|
||||
↓
|
||||
Background: has context changed since last LLM run?
|
||||
(hash of: item IDs + data + slot descriptions + user memory)
|
||||
↓
|
||||
No → done, cache is still valid
|
||||
Yes → run LLM harness async
|
||||
→ fill slots + generate synthetic items
|
||||
→ cache result
|
||||
→ push updated feed to UI via WebSocket
|
||||
```
|
||||
|
||||
The user never waits for the LLM. They see the feed instantly with the previous enhancement applied. If the LLM produces new slot content or synthetic items, the feed updates in place.
|
||||
|
||||
### LLM input
|
||||
|
||||
The harness serializes items with their unfilled slots into a single prompt. Items without slots are excluded. The LLM sees everything at once and fills whatever slots are relevant.
|
||||
|
||||
```typescript
|
||||
function buildHarnessInput(
|
||||
items: FeedItem[],
|
||||
context: AgentContext,
|
||||
): HarnessInput {
|
||||
const itemsWithSlots = items
|
||||
.filter(item => item.slots && Object.keys(item.slots).length > 0)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
data: item.data,
|
||||
slots: Object.fromEntries(
|
||||
Object.entries(item.slots!).map(
|
||||
([name, slot]) => [name, slot.description]
|
||||
)
|
||||
),
|
||||
}))
|
||||
|
||||
return {
|
||||
items: itemsWithSlots,
|
||||
userMemory: context.preferences,
|
||||
currentTime: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The LLM sees:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "weather-current-123",
|
||||
"type": "weather-current",
|
||||
"data": { "temperature": 18, "condition": "cloudy" },
|
||||
"slots": {
|
||||
"insight": "A short contextual insight about the current weather and how it affects the user's day",
|
||||
"cross-source": "Connection between weather and the user's calendar events or plans"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "calendar-event-456",
|
||||
"type": "calendar-event",
|
||||
"data": { "title": "Dinner at The Ivy", "startTime": "19:00", "location": "The Ivy, West St" },
|
||||
"slots": {
|
||||
"context": "Background on this event, attendees, or previous meetings with these people",
|
||||
"logistics": "Travel time, parking, directions to the venue",
|
||||
"weather": "Weather conditions relevant to this event's time and location"
|
||||
}
|
||||
}
|
||||
],
|
||||
"userMemory": { "commute": "victoria-line", "preference.walking_distance": "1 mile" },
|
||||
"currentTime": "2025-02-26T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### LLM output
|
||||
|
||||
A flat map of item ID → slot name → text content. Slots left null are unfilled.
|
||||
|
||||
```json
|
||||
{
|
||||
"slotFills": {
|
||||
"weather-current-123": {
|
||||
"insight": "Rain after 3pm — grab a jacket before your walk",
|
||||
"cross-source": "Should be dry by 7pm for your dinner at The Ivy"
|
||||
},
|
||||
"calendar-event-456": {
|
||||
"context": null,
|
||||
"logistics": "20-minute walk from home — leave by 18:40",
|
||||
"weather": "Rain clears by evening, you'll be fine"
|
||||
}
|
||||
},
|
||||
"syntheticItems": [
|
||||
{
|
||||
"id": "briefing-morning",
|
||||
"type": "briefing",
|
||||
"data": {},
|
||||
"ui": { "component": "Text", "props": { "text": "Light afternoon — just your dinner at 7. Rain clears by then." } }
|
||||
}
|
||||
],
|
||||
"suppress": [],
|
||||
"rankingHints": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Enhancement manager
|
||||
|
||||
One per user, living in the `FeedEngineManager` on the backend:
|
||||
|
||||
```typescript
|
||||
class EnhancementManager {
|
||||
private cache: EnhancementResult | null = null
|
||||
private lastInputHash: string | null = null
|
||||
private running = false
|
||||
|
||||
async enhance(
|
||||
items: FeedItem[],
|
||||
context: AgentContext,
|
||||
): Promise<EnhancementResult> {
|
||||
const hash = computeHash(items, context)
|
||||
|
||||
if (hash === this.lastInputHash && this.cache) {
|
||||
return this.cache
|
||||
}
|
||||
|
||||
if (this.running) {
|
||||
return this.cache ?? emptyResult()
|
||||
}
|
||||
|
||||
this.running = true
|
||||
this.runHarness(items, context)
|
||||
.then(result => {
|
||||
this.cache = result
|
||||
this.lastInputHash = hash
|
||||
this.notifySubscribers(result)
|
||||
})
|
||||
.finally(() => { this.running = false })
|
||||
|
||||
return this.cache ?? emptyResult()
|
||||
}
|
||||
}
|
||||
|
||||
interface EnhancementResult {
|
||||
slotFills: Record<string, Record<string, string | null>>
|
||||
syntheticItems: FeedItem[]
|
||||
suppress: string[]
|
||||
rankingHints: Record<string, number>
|
||||
}
|
||||
```
|
||||
|
||||
### Merging
|
||||
|
||||
After the harness runs, the engine merges slot fills into items:
|
||||
|
||||
```typescript
|
||||
function mergeEnhancement(
|
||||
items: FeedItem[],
|
||||
result: EnhancementResult,
|
||||
): FeedItem[] {
|
||||
return items.map(item => {
|
||||
const fills = result.slotFills[item.id]
|
||||
if (!fills || !item.slots) return item
|
||||
|
||||
const mergedSlots = { ...item.slots }
|
||||
for (const [name, content] of Object.entries(fills)) {
|
||||
if (name in mergedSlots && content !== null) {
|
||||
mergedSlots[name] = { ...mergedSlots[name], content }
|
||||
}
|
||||
}
|
||||
|
||||
return { ...item, slots: mergedSlots }
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Cost control
|
||||
|
||||
- **Hash-based cache gate.** Most refreshes reuse the cached result.
|
||||
- **Debounce.** Rapid context changes (location updates) settle before triggering a run.
|
||||
- **Skip inactive users.** Don't run if the user hasn't opened the app in 2+ hours.
|
||||
- **Exclude slotless items.** Only items with slots are sent to the LLM.
|
||||
- **Text-only output.** Slots produce strings, not UI trees — fewer output tokens, less variance.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Exact schema format for UI registry
|
||||
- How third parties authenticate/register their sources and UI schemas
|
||||
- How third parties authenticate/register their sources
|
||||
- Exact set of React Native components exposed in the json-render component map
|
||||
- Validation/sandboxing of third-party ui trees
|
||||
- How synthetic items define their UI (full json-render tree vs. registered component)
|
||||
- Should slots support rich content (json-render nodes) in the future, or stay text-only?
|
||||
- How to handle slot content that references other items (e.g., "your dinner at The Ivy" linking to the calendar card)
|
||||
|
||||
306
docs/backend-service-architecture-spec.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Backend Service Architecture: Per-User Refactor
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current backend uses a **per-source service** pattern: each source type (Location, Weather, TFL) has its own `XxxService` class that manages a `Map<userId, SourceInstance>`. Adding a new source requires:
|
||||
|
||||
1. A new `XxxService` class with identical boilerplate (~30-40 lines: Map, get-or-create, removeUser)
|
||||
2. Wiring it into `server.ts` constructor
|
||||
3. Passing it to `FeedEngineService`
|
||||
4. Optionally adding source-specific tRPC routes
|
||||
|
||||
With 3 sources this is manageable. With 10+ (calendar, music, transit, news, etc.) it becomes:
|
||||
|
||||
- **Repetitive**: Every service class repeats the same Map + get-or-create + removeUser pattern
|
||||
- **Fragmented lifecycle**: User cleanup requires calling `removeUser` on every service independently
|
||||
- **No user-level config**: No unified place to store which sources a user has enabled or their per-source settings
|
||||
- **Hard to reason about**: User state is scattered across N independent Maps
|
||||
|
||||
### Current Flow
|
||||
|
||||
```
|
||||
server.ts
|
||||
├── new LocationService() ← owns Map<userId, LocationSource>
|
||||
├── new WeatherService(creds) ← owns Map<userId, WeatherSource>
|
||||
├── new TflService(api) ← owns Map<userId, TflSource>
|
||||
└── FeedEngineService([loc, weather, tfl])
|
||||
└── owns Map<userId, FeedEngine>
|
||||
└── on create: asks each service for feedSourceForUser(userId)
|
||||
```
|
||||
|
||||
4 independent Maps for 3 sources. Each user's state lives in 4 different places.
|
||||
|
||||
## Scope
|
||||
|
||||
**Backend only** (`apps/aris-backend`). No changes to `aris-core` or source packages (`packages/aris-source-*`). The `FeedSource` interface and source implementations remain unchanged.
|
||||
|
||||
## Architectural Options
|
||||
|
||||
### Option A: UserSession Object
|
||||
|
||||
A single `UserSession` class owns everything for one user. A `UserSessionManager` is the only top-level Map.
|
||||
|
||||
```typescript
|
||||
class UserSession {
|
||||
readonly userId: string
|
||||
readonly engine: FeedEngine
|
||||
private sources: Map<string, FeedSource>
|
||||
|
||||
constructor(userId: string, sourceFactories: SourceFactory[]) {
|
||||
this.engine = new FeedEngine()
|
||||
this.sources = new Map()
|
||||
for (const factory of sourceFactories) {
|
||||
const source = factory.create()
|
||||
this.sources.set(source.id, source)
|
||||
this.engine.register(source)
|
||||
}
|
||||
this.engine.start()
|
||||
}
|
||||
|
||||
getSource<T extends FeedSource>(id: string): T | undefined {
|
||||
return this.sources.get(id) as T | undefined
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.engine.stop()
|
||||
this.sources.clear()
|
||||
}
|
||||
}
|
||||
|
||||
class UserSessionManager {
|
||||
private sessions = new Map<string, UserSession>()
|
||||
|
||||
getOrCreate(userId: string): UserSession { ... }
|
||||
remove(userId: string): void { ... } // single cleanup point
|
||||
}
|
||||
```
|
||||
|
||||
**Source-specific operations** use typed accessors:
|
||||
|
||||
```typescript
|
||||
const session = manager.getOrCreate(userId)
|
||||
const location = session.getSource<LocationSource>("location")
|
||||
location?.pushLocation({ lat: 51.5, lng: -0.1, ... })
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Single Map, single cleanup point
|
||||
- All user state co-located
|
||||
- Easy to add TTL/eviction at one level
|
||||
- Source factories are simple functions, no service classes needed
|
||||
|
||||
**Cons:**
|
||||
|
||||
- `getSource<T>("id")` requires callers to know the source ID string and cast type
|
||||
- Shared resources (e.g., TFL API client) need to be passed through factories
|
||||
|
||||
### Option B: Source Registry with Factories
|
||||
|
||||
Keep `FeedEngineService` but replace per-source service classes with a registry of factory functions. No `XxxService` classes at all.
|
||||
|
||||
```typescript
|
||||
interface SourceFactory {
|
||||
readonly sourceId: string
|
||||
create(userId: string): FeedSource
|
||||
}
|
||||
|
||||
// Weather factory — closure over shared credentials
|
||||
function weatherSourceFactory(creds: WeatherKitCredentials): SourceFactory {
|
||||
return {
|
||||
sourceId: "weather",
|
||||
create: () => new WeatherSource({ credentials: creds }),
|
||||
}
|
||||
}
|
||||
|
||||
// TFL factory — closure over shared API client
|
||||
function tflSourceFactory(api: ITflApi): SourceFactory {
|
||||
return {
|
||||
sourceId: "tfl",
|
||||
create: () => new TflSource({ client: api }),
|
||||
}
|
||||
}
|
||||
|
||||
class FeedEngineService {
|
||||
private engines = new Map<string, FeedEngine>()
|
||||
private userSources = new Map<string, Map<string, FeedSource>>()
|
||||
|
||||
constructor(private readonly factories: SourceFactory[]) {}
|
||||
|
||||
engineForUser(userId: string): FeedEngine { ... }
|
||||
getSourceForUser<T extends FeedSource>(userId: string, sourceId: string): T | undefined { ... }
|
||||
removeUser(userId: string): void { ... } // cleans up engine + all sources
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Minimal change from current structure — `FeedEngineService` evolves, services disappear
|
||||
- Factory functions are 5-10 lines each, no classes
|
||||
- Shared resources handled naturally via closures
|
||||
|
||||
**Cons:**
|
||||
|
||||
- `FeedEngineService` grows in responsibility (engine + source tracking + source access)
|
||||
- Still two Maps (engines + userSources), though co-located
|
||||
|
||||
### Option C: UserSession + Typed Source Handles (Recommended)
|
||||
|
||||
Combines Option A's co-location with type-safe source access. `UserSession` owns everything. Source-specific operations go through **source handles** — thin typed wrappers registered at setup time.
|
||||
|
||||
```typescript
|
||||
// Source handle: typed wrapper for source-specific operations
|
||||
interface SourceHandle<T extends FeedSource = FeedSource> {
|
||||
readonly source: T
|
||||
}
|
||||
|
||||
class UserSession {
|
||||
readonly engine: FeedEngine
|
||||
private handles = new Map<string, SourceHandle>()
|
||||
|
||||
register<T extends FeedSource>(source: T): SourceHandle<T> {
|
||||
this.engine.register(source)
|
||||
const handle: SourceHandle<T> = { source }
|
||||
this.handles.set(source.id, handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.engine.stop()
|
||||
this.handles.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// In setup code — handles are typed at creation time
|
||||
function createSession(userId: string, deps: SessionDeps): UserSession {
|
||||
const session = new UserSession(userId)
|
||||
|
||||
const locationHandle = session.register(new LocationSource())
|
||||
const weatherHandle = session.register(new WeatherSource(deps.weatherCreds))
|
||||
const tflHandle = session.register(new TflSource({ client: deps.tflApi }))
|
||||
|
||||
return session
|
||||
}
|
||||
```
|
||||
|
||||
**Source-specific operations** use the typed handles returned at registration:
|
||||
|
||||
```typescript
|
||||
// In the tRPC router or wherever source-specific ops happen:
|
||||
// The handle is obtained during session setup and stored where needed
|
||||
locationHandle.source.pushLocation({ ... })
|
||||
tflHandle.source.setLinesOfInterest(["northern"])
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Single Map, single cleanup
|
||||
- Type-safe source access without string-based lookups or casts
|
||||
- No boilerplate service classes
|
||||
- Handles can be extended later (e.g., add per-source config, metrics)
|
||||
- Shared resources passed directly to constructors
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Handles need to be threaded to where they're used (tRPC routers, etc.)
|
||||
- Slightly more setup code in the factory function
|
||||
|
||||
## Source-Specific Operations: Approaches
|
||||
|
||||
Orthogonal to the session model, there are three ways to handle operations like `pushLocation` or `setLinesOfInterest`:
|
||||
|
||||
### Approach 1: Direct Source Access (Recommended)
|
||||
|
||||
Callers get a typed reference to the source and call methods directly. This is what all three options above use in different ways.
|
||||
|
||||
```typescript
|
||||
locationSource.pushLocation(location)
|
||||
tflSource.setLinesOfInterest(lines)
|
||||
```
|
||||
|
||||
**Why this works:** Source packages already define these methods. The backend just needs to expose the source instance to the right caller. No new abstraction needed.
|
||||
|
||||
### Approach 2: Command Dispatch
|
||||
|
||||
A generic `dispatch(command)` method on the session routes typed commands to sources.
|
||||
|
||||
```typescript
|
||||
session.dispatch({ type: "location.update", payload: { lat: 51.5, ... } })
|
||||
```
|
||||
|
||||
**Tradeoff:** Adds indirection and a command type registry. Useful if sources are dynamically loaded plugins, but over-engineered for the current case where sources are known at compile time.
|
||||
|
||||
### Approach 3: Context-Only
|
||||
|
||||
All input goes through `FeedEngine` context updates. Sources react to context changes.
|
||||
|
||||
```typescript
|
||||
engine.pushContext({ [LocationKey]: location })
|
||||
// LocationSource picks this up via onContextUpdate
|
||||
```
|
||||
|
||||
**Tradeoff:** Location already works this way (it's a context provider). But not all operations map to context — `setLinesOfInterest` is configuration, not context. Would require stretching the context concept.
|
||||
|
||||
## User Source Configuration (DB-Persisted)
|
||||
|
||||
Regardless of which option is chosen, user source config needs a storage model:
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_source_config (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
source_id TEXT NOT NULL, -- e.g., "weather", "tfl", "location"
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
config JSONB NOT NULL DEFAULT '{}', -- source-specific settings
|
||||
PRIMARY KEY (user_id, source_id)
|
||||
);
|
||||
```
|
||||
|
||||
On session creation:
|
||||
|
||||
1. Load `user_source_config` rows for the user
|
||||
2. Only create sources where `enabled = true`
|
||||
3. Pass `config` JSON to the source factory/constructor
|
||||
|
||||
New users get default config rows inserted on first login.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Option C (UserSession + Typed Source Handles)** with **Approach 1 (Direct Source Access)**.
|
||||
|
||||
Rationale:
|
||||
|
||||
- Eliminates all per-source service boilerplate
|
||||
- Single user lifecycle management point
|
||||
- Type-safe without string-based lookups in hot paths
|
||||
- Minimal new abstraction — `UserSession` is a thin container, not a framework
|
||||
- Handles are just typed references, not a new pattern to learn
|
||||
- Natural extension point for per-user config loading from DB
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **No per-source service classes**: `LocationService`, `WeatherService`, `TflService` are removed
|
||||
2. **Single user state container**: All per-user state (engine, sources) lives in one object
|
||||
3. **Single cleanup**: Removing a user requires one call, not N
|
||||
4. **Type-safe source access**: Source-specific operations don't require string-based lookups or unsafe casts at call sites
|
||||
5. **Existing tests pass**: `FeedEngineService` tests are migrated to the new structure
|
||||
6. **tRPC routes work**: Location update route works through the new architecture
|
||||
7. **DB config table**: `user_source_config` table exists; session creation reads from it
|
||||
8. **Default config**: New users get default source config on first session
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Create `user_source_config` DB table and migration
|
||||
2. Create `UserSession` class with `register()`, `destroy()`, typed handle return
|
||||
3. Create `UserSessionManager` with `getOrCreate()`, `remove()`, config loading
|
||||
4. Create `createSession()` factory that reads DB config and registers enabled sources
|
||||
5. Refactor `server.ts` to use `UserSessionManager` instead of individual services
|
||||
6. Refactor tRPC router to receive session/handles instead of individual services
|
||||
7. Delete `LocationService`, `WeatherService`, `TflService` classes
|
||||
8. Migrate existing tests to new structure
|
||||
9. Add tests for session lifecycle (create, destroy, config loading)
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **TTL/eviction**: Should `UserSessionManager` handle idle session cleanup? (Currently deferred in backend-spec.md)
|
||||
- **Hot reload config**: If a user changes their source config, should the session be recreated or patched in-place?
|
||||
- **Shared source instances**: Some sources (e.g., TFL) share an API client. Should the factory receive shared deps, or should there be a DI container?
|
||||