diff --git a/package-lock.json b/package-lock.json
index 3686338c..0e098213 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"idb-keyval": "^6.2.0",
"lit": "^3.3.2",
"marked": "^17.0.1",
+ "three": "^0.184.0",
"urlpattern-polyfill": "^10.1.0",
"web-router": "^0.5.0"
},
@@ -39,6 +40,7 @@
"@playwright/test": "^1.50.0",
"@rollup/plugin-typescript": "^12.3.0",
"@types/dom-navigation": "^1.0.6",
+ "@types/three": "^0.184.0",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@vitest/browser": "^4.0.16",
@@ -265,6 +267,13 @@
"@capacitor/core": ">=8.0.0"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/@emnapi/runtime": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
@@ -2627,6 +2636,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz",
@@ -2969,6 +2985,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/statuses": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
@@ -2976,12 +2999,34 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/three": {
+ "version": "0.184.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
+ "integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/ws": {
"version": "7.4.7",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
@@ -5115,6 +5160,13 @@
"node": "^12.20 || >= 14.13"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6685,6 +6737,13 @@
"node": ">= 8"
}
},
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -8795,6 +8854,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/three": {
+ "version": "0.184.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
+ "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
+ "license": "MIT"
+ },
"node_modules/through2": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
diff --git a/package.json b/package.json
index 370ed6eb..d4b05ed7 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
"idb-keyval": "^6.2.0",
"lit": "^3.3.2",
"marked": "^17.0.1",
+ "three": "^0.184.0",
"urlpattern-polyfill": "^10.1.0",
"web-router": "^0.5.0"
},
@@ -73,6 +74,7 @@
"@playwright/test": "^1.50.0",
"@rollup/plugin-typescript": "^12.3.0",
"@types/dom-navigation": "^1.0.6",
+ "@types/three": "^0.184.0",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@vitest/browser": "^4.0.16",
diff --git a/src/components/settings-drawer-content.ts b/src/components/settings-drawer-content.ts
index 8dd073b3..fb076937 100644
--- a/src/components/settings-drawer-content.ts
+++ b/src/components/settings-drawer-content.ts
@@ -39,6 +39,7 @@ export class SettingsDrawerContent extends LitElement {
@property({ type: Boolean }) wellnessMode = false;
@property({ type: Boolean }) dataSaverMode = false;
@property({ type: Boolean }) hapticsEnabled = true;
+ @property({ type: Boolean }) experimental3dTimelineEnabled = false;
@property({ type: Boolean }) appThemeLoaded = false;
@state() private isAndroid = false;
@@ -58,6 +59,10 @@ export class SettingsDrawerContent extends LitElement {
margin-top: 16px;
}
+ #warp-timeline-button {
+ margin-top: 16px;
+ }
+
md-card md-divider {
margin-top: 20px;
margin-bottom: 20px;
@@ -358,6 +363,10 @@ export class SettingsDrawerContent extends LitElement {
router.navigate('/announcements');
}
+ private _openWarpTimeline() {
+ router.navigate('/warp');
+ }
+
private _formatAnnouncementDate(iso: string): string {
try {
return new Intl.DateTimeFormat(undefined, {
@@ -400,6 +409,17 @@ export class SettingsDrawerContent extends LitElement {
);
}
+ private handleExperimental3dTimelineToggle(e: Event) {
+ const checked = (e.target as HTMLInputElement).checked;
+ this.dispatchEvent(
+ new CustomEvent('experimental-3d-timeline-change', {
+ detail: { checked },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
render() {
return html`
@@ -519,6 +539,31 @@ export class SettingsDrawerContent extends LitElement {
${msg('Vibrate on actions like likes, boosts, and publishing.')}
+
+
+
+
${msg('Experimental 3D Timeline')}
+
+ this.handleExperimental3dTimelineToggle(e)}"
+ ?checked="${this.experimental3dTimelineEnabled}"
+ >
+
+
+ ${msg('Try an experitment in exploring posts in 3D.')}
+
+
+ ${this.experimental3dTimelineEnabled
+ ? html`
+
this._openWarpTimeline()}"
+ id="warp-timeline-button"
+ >
+ ${msg('Open Warp Timeline')}
+
+ `
+ : nothing}
${this.isAndroid
? html`
diff --git a/src/generated/locales/de.ts b/src/generated/locales/de.ts
index bef137b5..ea597117 100644
--- a/src/generated/locales/de.ts
+++ b/src/generated/locales/de.ts
@@ -9,11 +9,9 @@ export const templates = {
s06e80274b5700572: `Option entfernen`,
s075ba6594b3909dc: `Direkt`,
s079a052719914d71: `Kategorie`,
- s07a00d850d958329: `Essen`,
s07c165a4537f8c77: `Noch keine Erwähnungen`,
s07e6118e838b2e23: `Navigation`,
s0843b2fcef33fcc8: `Veröffentlichen`,
- s08ce549fa4d15dfd: `Wissenschaft`,
s0a3a833266cb4585: `Beitrag bearbeiten`,
s0a5df04b5c6d2f9f: `Deine Umfrage`,
s0a75f9328a2fb427: `Scrolle und tippe auf "Zum Home-Bildschirm"`,
@@ -36,7 +34,6 @@ export const templates = {
s17fb3f4805f4f4b9: `Feld entfernen`,
s181de3190f4abb2c: `Teilen`,
s191badfb60321854: `Füge Coho zu deinem Startbildschirm hinzu für das beste Erlebnis`,
- s19617036a468a75f: `Mode`,
s1967faf9ed8d3504: `Avatar`,
s1bb6c905f66815eb: `App installieren`,
s1dc17af39243deee: `Für Mastodon-Konto registrieren`,
@@ -47,7 +44,6 @@ export const templates = {
s20a500234e838dda: `Favoriten`,
s216407f266691656: `Geteiltes Bild konnte nicht geladen werden. Bitte versuche es erneut.`,
s21f68e65dbf5b5a4: `Verarbeite...`,
- s233ae776268cd44b: `Wirtschaft`,
s251903053fa7a51e: `Folge...`,
s26c4bbcf4df6673b: `Thread erweitern`,
s275772c501fb3b2e: `Nicht jetzt`,
@@ -148,7 +144,6 @@ export const templates = {
s8946af4df69fe739: `Du folgst`,
s897531ca8a270ee9: `Vorgeschlagene Änderung`,
s8997c16cd0e93271: `Wellness-Modus`,
- s8a127bd255333eda: `Sport`,
s8af61807443f32a4: `Aktionen`,
s8bf481a0b5d8a689: `Generiere...`,
s8cdcbe65c5e46f7a: `Erneut versuchen`,
@@ -163,7 +158,6 @@ export const templates = {
s9a46ff9baa276602: `zurück`,
s9ac7f1d0c67c0118: `Bild bearbeiten`,
s9c1fe7b413b74ce5: `Benachrichtigungen öffnen`,
- s9ca372a6096ddd54: `Musik`,
s9d3248be9803bbe0: `Antworte auf diesen Beitrag...`,
s9d8b8aa2b404c2c8: `Einstellungen`,
s9dce1fb8226d0cdd: `Beitrag öffnen`,
@@ -180,7 +174,6 @@ export const templates = {
saccb9cc846d8abd3: `hat einen Beitrag bearbeitet`,
sadf9c2063e1497b7: `Dein moderner Mastodon-Client`,
sae77551833d7117b: `Der Wellness-Modus blendet Likes und Boosts aus.`,
- sb0a26af3eb6ab88e: `Politik`,
sb16c59bda3cb5c45: `Filter anwenden...`,
sb23eed07fcaf3095: `Abstimmen`,
sb32cad250448883a: `Aufnahme stoppen`,
@@ -190,20 +183,15 @@ export const templates = {
sb60bbe250aad3fa8: `Spam`,
sb72a4b091e8795b3: `Suche öffnen`,
sb78a66c8b3d23215: `Prüfe...`,
- sb8f030e19d93bb79: `Unterhaltung`,
sb8f855b49234b81b: `Anwenden`,
- sbaf0a7c417e388af: `Gesundheit`,
sbb15471af4d8dc52: `Erkenne...`,
- sbb87af74ab3706b9: `Reisen`,
sbba6a0f5864827d4: `Verfassen`,
sbd1a5d538201dae8: `Vom Dock oder Startbildschirm starten`,
sbd20a2111e25f4ea: `Verstanden`,
sbdeedc1c60306b35: `Nachrichten`,
- sbec848a6b81917c0: `Lifestyle`,
sc0894569c67f140e: `Kein Spoiler-Text angegeben`,
sc15ed31aec49dd18: `Trends`,
sc2b1e7582843b682: `Mehrfachauswahl erlauben`,
- sc565503c7584ed60: `Interessen`,
sc685d7705585c76f: `Zeitleiste aktualisieren`,
sc7e5b0355146889c: `Zu Nachrichten`,
sc8da3cc71de63832: `Anmelden`,
@@ -229,7 +217,6 @@ export const templates = {
sdec709236764aa73: `Vorschau schließen`,
sdee2dd4540cbfa57: str`${0} Beiträge heute`,
se06934af5f7f49eb: `Wähle eine oder mehrere`,
- se088dfba6684a16f: `Technologie`,
se09b308c799d5a44: `0 Follower`,
se13b1d97003596bc: `Beiträge`,
se1ec1972bbf775cb: `Du surfst als Gast. Melde dich an, um mit Beiträgen zu interagieren und alle Funktionen zu nutzen.`,
@@ -258,7 +245,6 @@ export const templates = {
sf886a1d8bd2479a9: `Spracheingabe`,
sf993bb199fefbe04: `Alle`,
sf9ca5cc93c622eb7: `Nicht gelistet`,
- sf9f8d719a044edca: `Kunst`,
sfa3341f85163e600: `Medien anhängen`,
sfa847f22cbd9bff5: `Übersetzen`,
sfb6d64d0e61d8dba: `An entfernten Server weiterleiten`,
@@ -531,6 +517,9 @@ export const templates = {
s2b5047d39b9baf3d: `Preferences`,
s399b2152de758495: `Haptic Feedback`,
s1210a6c6ad65d97b: `Vibrate on actions like likes, boosts, and publishing.`,
+ s89fa731572319ad5: `Experimental 3D Timeline`,
+ s540ee2367481ff18: `Try an experitment in exploring posts in 3D.`,
+ s889f72a85fa15194: `Open Warp Timeline`,
s5ddb4ae6884197c4: `Sync to Watch`,
s36c13bb456523146: `Synced ✓`,
s9cd4984f1a746b40: `Syncing…`,
@@ -562,6 +551,23 @@ export const templates = {
s2bcebb0b6a6517b6: `Search for your server to connect another Mastodon account.`,
s626c98f49c83a793: `Search for a Mastodon server`,
s6d83773bf37278f8: `Starting OAuth...`,
+ s1714a4b01daaca2b: `3D timeline prototype`,
+ s122c4e6c8ff0ed45: `Exit`,
+ sd655df7903434847: `Warp timeline is off`,
+ s12b5b398412ee0ae: `Enable the experimental 3D timeline from Settings to try this prototype.`,
+ sc16e00a7a8b2fde2: `Back`,
+ s9078ee819c18df6e: `Enable`,
+ sd808756d0ff4e16f: `Motion preference respected`,
+ s94cb988cb2673ab2: `The 3D timeline is paused because reduced motion is enabled on this device.`,
+ s2f79bb23775ba5c8: `This may be demanding`,
+ sfeabaa95f8aaf253: `This prototype uses WebGL and may be less smooth on this device.`,
+ s81ecf2d4386b8e84: `Continue`,
+ sd94680e71442a0d0: `Could not load the warp`,
+ s983be4a3a60636b1: `No posts to warp through`,
+ s06e3d2ab959d506f: `Your home timeline did not return any posts for this prototype run.`,
+ s690d3047465f4117: `End of warp`,
+ sd65c657d6e6ac4c8: `That is the end of the loaded prototype timeline.`,
+ s14bec4678c4e60cc: `Restart`,
se54e9d4d0fde357e: str`Followed by`,
se6f79719051ff286: str`and`,
s540f97efd95106d7: str`and ${0} others`,
diff --git a/src/generated/locales/es.ts b/src/generated/locales/es.ts
index 1b811db0..c0f34233 100644
--- a/src/generated/locales/es.ts
+++ b/src/generated/locales/es.ts
@@ -9,11 +9,9 @@ export const templates = {
s06e80274b5700572: `Eliminar opción`,
s075ba6594b3909dc: `Directo`,
s079a052719914d71: `Categoría`,
- s07a00d850d958329: `Comida`,
s07c165a4537f8c77: `Aún no hay menciones`,
s07e6118e838b2e23: `Navegación`,
s0843b2fcef33fcc8: `Publicar`,
- s08ce549fa4d15dfd: `Ciencia`,
s0a3a833266cb4585: `Editar Publicación`,
s0a5df04b5c6d2f9f: `Tu encuesta`,
s0a75f9328a2fb427: `Desplázate y toca "Agregar a Inicio"`,
@@ -36,7 +34,6 @@ export const templates = {
s17fb3f4805f4f4b9: `Eliminar campo`,
s181de3190f4abb2c: `Impulsar`,
s191badfb60321854: `Agrega Coho a tu pantalla de inicio para la mejor experiencia`,
- s19617036a468a75f: `Moda`,
s1967faf9ed8d3504: `Avatar`,
s1bb6c905f66815eb: `Instalar App`,
s1dc17af39243deee: `Crear Cuenta de Mastodon`,
@@ -47,7 +44,6 @@ export const templates = {
s20a500234e838dda: `Favoritos`,
s216407f266691656: `No se pudo cargar la imagen compartida. Por favor, intenta compartir de nuevo.`,
s21f68e65dbf5b5a4: `Procesando...`,
- s233ae776268cd44b: `Negocios`,
s251903053fa7a51e: `Siguiendo...`,
s26c4bbcf4df6673b: `Expandir hilo`,
s275772c501fb3b2e: `Ahora no`,
@@ -148,7 +144,6 @@ export const templates = {
s8946af4df69fe739: `Estás Siguiendo`,
s897531ca8a270ee9: `Revisión sugerida`,
s8997c16cd0e93271: `Modo Bienestar`,
- s8a127bd255333eda: `Deportes`,
s8af61807443f32a4: `Acciones`,
s8bf481a0b5d8a689: `Generando...`,
s8cdcbe65c5e46f7a: `Intentar de nuevo`,
@@ -163,7 +158,6 @@ export const templates = {
s9a46ff9baa276602: `atrás`,
s9ac7f1d0c67c0118: `Editar Imagen`,
s9c1fe7b413b74ce5: `Abrir Notificaciones`,
- s9ca372a6096ddd54: `Música`,
s9d3248be9803bbe0: `Responder a esta publicación...`,
s9d8b8aa2b404c2c8: `Configuración`,
s9dce1fb8226d0cdd: `Abrir publicación`,
@@ -180,7 +174,6 @@ export const templates = {
saccb9cc846d8abd3: `editó una publicación`,
sadf9c2063e1497b7: `Tu cliente moderno de Mastodon`,
sae77551833d7117b: `El Modo Bienestar oculta los me gusta e impulsos.`,
- sb0a26af3eb6ab88e: `Política`,
sb16c59bda3cb5c45: `Aplicando filtro...`,
sb23eed07fcaf3095: `Votar`,
sb32cad250448883a: `Detener grabación`,
@@ -190,20 +183,15 @@ export const templates = {
sb60bbe250aad3fa8: `Spam`,
sb72a4b091e8795b3: `Abrir Búsqueda`,
sb78a66c8b3d23215: `Comprobando...`,
- sb8f030e19d93bb79: `Entretenimiento`,
sb8f855b49234b81b: `Aplicar`,
- sbaf0a7c417e388af: `Salud`,
sbb15471af4d8dc52: `Reconociendo...`,
- sbb87af74ab3706b9: `Viajes`,
sbba6a0f5864827d4: `Redacción`,
sbd1a5d538201dae8: `Iniciar desde tu dock o pantalla de inicio`,
sbd20a2111e25f4ea: `Entendido`,
sbdeedc1c60306b35: `Mensajes`,
- sbec848a6b81917c0: `Estilo de Vida`,
sc0894569c67f140e: `Sin texto de spoiler`,
sc15ed31aec49dd18: `Tendencias`,
sc2b1e7582843b682: `Permitir múltiples opciones`,
- sc565503c7584ed60: `Intereses`,
sc685d7705585c76f: `Actualizar línea de tiempo`,
sc7e5b0355146889c: `Ir a Mensajes`,
sc8da3cc71de63832: `Iniciar Sesión`,
@@ -229,7 +217,6 @@ export const templates = {
sdec709236764aa73: `Cerrar vista previa`,
sdee2dd4540cbfa57: str`${0} publicaciones hoy`,
se06934af5f7f49eb: `Selecciona uno o más`,
- se088dfba6684a16f: `Tecnología`,
se09b308c799d5a44: `0 seguidores`,
se13b1d97003596bc: `Publicaciones`,
se1ec1972bbf775cb: `Estás navegando como invitado. Inicia sesión para interactuar con publicaciones y acceder a todas las funciones.`,
@@ -258,7 +245,6 @@ export const templates = {
sf886a1d8bd2479a9: `Entrada de voz`,
sf993bb199fefbe04: `Todo`,
sf9ca5cc93c622eb7: `No listado`,
- sf9f8d719a044edca: `Arte`,
sfa3341f85163e600: `Adjuntar Multimedia`,
sfa847f22cbd9bff5: `Traducir`,
sfb6d64d0e61d8dba: `Reenviar al servidor remoto`,
@@ -531,6 +517,9 @@ export const templates = {
s2b5047d39b9baf3d: `Preferences`,
s399b2152de758495: `Haptic Feedback`,
s1210a6c6ad65d97b: `Vibrate on actions like likes, boosts, and publishing.`,
+ s89fa731572319ad5: `Experimental 3D Timeline`,
+ s540ee2367481ff18: `Try an experitment in exploring posts in 3D.`,
+ s889f72a85fa15194: `Open Warp Timeline`,
s5ddb4ae6884197c4: `Sync to Watch`,
s36c13bb456523146: `Synced ✓`,
s9cd4984f1a746b40: `Syncing…`,
@@ -562,6 +551,23 @@ export const templates = {
s2bcebb0b6a6517b6: `Search for your server to connect another Mastodon account.`,
s626c98f49c83a793: `Search for a Mastodon server`,
s6d83773bf37278f8: `Starting OAuth...`,
+ s1714a4b01daaca2b: `3D timeline prototype`,
+ s122c4e6c8ff0ed45: `Exit`,
+ sd655df7903434847: `Warp timeline is off`,
+ s12b5b398412ee0ae: `Enable the experimental 3D timeline from Settings to try this prototype.`,
+ sc16e00a7a8b2fde2: `Back`,
+ s9078ee819c18df6e: `Enable`,
+ sd808756d0ff4e16f: `Motion preference respected`,
+ s94cb988cb2673ab2: `The 3D timeline is paused because reduced motion is enabled on this device.`,
+ s2f79bb23775ba5c8: `This may be demanding`,
+ sfeabaa95f8aaf253: `This prototype uses WebGL and may be less smooth on this device.`,
+ s81ecf2d4386b8e84: `Continue`,
+ sd94680e71442a0d0: `Could not load the warp`,
+ s983be4a3a60636b1: `No posts to warp through`,
+ s06e3d2ab959d506f: `Your home timeline did not return any posts for this prototype run.`,
+ s690d3047465f4117: `End of warp`,
+ sd65c657d6e6ac4c8: `That is the end of the loaded prototype timeline.`,
+ s14bec4678c4e60cc: `Restart`,
se54e9d4d0fde357e: str`Followed by`,
se6f79719051ff286: str`and`,
s540f97efd95106d7: str`and ${0} others`,
diff --git a/src/pages/app-home.ts b/src/pages/app-home.ts
index 6bd9510b..6bde4051 100644
--- a/src/pages/app-home.ts
+++ b/src/pages/app-home.ts
@@ -65,6 +65,7 @@ export class AppHome extends LitElement {
@state() wellnessMode: boolean = false;
@state() dataSaverMode: boolean = false;
@state() hapticsEnabled: boolean = true;
+ @state() experimental3dTimelineEnabled: boolean = false;
@state() summary: string = '';
@@ -265,6 +266,10 @@ export class AppHome extends LitElement {
this.handleDataSaverMode(settings.data_saver || false);
this.handleHapticsMode(settings.haptics !== false);
+
+ this.handleExperimental3dTimelineMode(
+ settings.experimental_3d_timeline || false
+ );
}
// Only check notifications for authenticated users
@@ -568,6 +573,13 @@ export class AppHome extends LitElement {
setSettings({ haptics: enabled });
}
+ async handleExperimental3dTimelineMode(enabled: boolean) {
+ this.experimental3dTimelineEnabled = enabled;
+
+ const { setSettings } = await import('../services/settings');
+ setSettings({ experimental_3d_timeline: enabled });
+ }
+
async handleTabChange(event: TabChangeEvent) {
// Determine the load callback:
// If we were passed a panel, we might need simple loading
@@ -1149,6 +1161,8 @@ export class AppHome extends LitElement {
.wellnessMode="${this.wellnessMode}"
.dataSaverMode="${this.dataSaverMode}"
.hapticsEnabled="${this.hapticsEnabled}"
+ .experimental3dTimelineEnabled="${this
+ .experimental3dTimelineEnabled}"
.appThemeLoaded="${this.appThemeLoaded}"
@wellness-change="${(e: CustomEvent<{ checked: boolean }>) =>
this.handleWellnessMode(e.detail.checked)}"
@@ -1156,6 +1170,9 @@ export class AppHome extends LitElement {
this.handleDataSaverMode(e.detail.checked)}"
@haptics-change="${(e: CustomEvent<{ checked: boolean }>) =>
this.handleHapticsMode(e.detail.checked)}"
+ @experimental-3d-timeline-change="${(
+ e: CustomEvent<{ checked: boolean }>
+ ) => this.handleExperimental3dTimelineMode(e.detail.checked)}"
@open-filters="${() => this.openFiltersDialog()}"
@open-scheduled-statuses="${() =>
this.openScheduledStatusesDialog()}"
diff --git a/src/pages/app-warp.ts b/src/pages/app-warp.ts
new file mode 100644
index 00000000..601095dd
--- /dev/null
+++ b/src/pages/app-warp.ts
@@ -0,0 +1,972 @@
+import { LitElement, css, html, nothing } from 'lit';
+import { customElement, query, state } from 'lit/decorators.js';
+import { localized, msg } from '@lit/localize';
+
+import '../components/md/md-button';
+import '../components/md/md-skeleton';
+
+import { router } from '../router/routes';
+import type { Post } from '../interfaces/Post';
+import type { WarpCardPool } from '../utils/warp/card-pool';
+import type { WarpScene } from '../utils/warp/scene';
+import type { WarpTheme } from '../utils/warp/post-card-texture';
+
+@localized()
+@customElement('app-warp')
+export class AppWarp extends LitElement {
+ @query('canvas') private canvas?: HTMLCanvasElement;
+
+ @state() private posts: Post[] = [];
+ @state() private loading = true;
+ @state() private error = '';
+ @state() private featureEnabled = false;
+ @state() private reducedMotion = false;
+ @state() private lowPowerWarning = false;
+ @state() private sceneReady = false;
+ @state() private reachedEnd = false;
+ @state() private loadingMore = false;
+
+ private scene: WarpScene | null = null;
+ private cardPool: WarpCardPool | null = null;
+ private animationFrame = 0;
+ private lastFrameTime = 0;
+ private progress = 0;
+ private travelVelocity = 0;
+ private inputDirection = 0;
+ private lookYaw = 0;
+ private lookPitch = 0;
+ private targetLookYaw = 0;
+ private targetLookPitch = 0;
+ private pointerLocked = false;
+ private lastInputAt = 0;
+ private lastLookAt = 0;
+ private readonly pressedKeys = new Set
();
+ private readonly keyboardTravelSpeed = 4.8;
+ private readonly travelAcceleration = 10;
+ private readonly travelFriction = 4.6;
+ private readonly maxTravelVelocity = 10;
+ private readonly wheelImpulse = 0.018;
+ private readonly mouseLookSensitivity = 0.0018;
+ private readonly maxLookYaw = 0.86;
+ private readonly maxLookPitch = 0.42;
+ private touchStartY = 0;
+ private touchStartX = 0;
+ private pointerStartX = 0;
+ private pointerStartY = 0;
+ private pointerStartAt = 0;
+ private pendingStart = false;
+ private hasMorePosts = true;
+
+ static styles = css`
+ :host {
+ display: block;
+ min-height: 100vh;
+ background: #090a0f;
+ color: #f7f2ff;
+ }
+
+ .warp-shell {
+ position: fixed;
+ inset: 0;
+ overflow: hidden;
+ background: #090a0f;
+ touch-action: none;
+ }
+
+ canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+ }
+
+ .top-bar,
+ .status-panel {
+ position: fixed;
+ z-index: 2;
+ display: flex;
+ gap: 8px;
+ }
+
+ .top-bar {
+ top: max(18px, env(safe-area-inset-top));
+ left: max(18px, env(safe-area-inset-left));
+ right: max(18px, env(safe-area-inset-right));
+ justify-content: space-between;
+ align-items: center;
+ pointer-events: none;
+ }
+
+ .top-bar md-button {
+ pointer-events: auto;
+ }
+
+ .status-panel {
+ left: 50%;
+ top: 50%;
+ width: min(420px, calc(100vw - 32px));
+ transform: translate(-50%, -50%);
+ flex-direction: column;
+ align-items: stretch;
+ padding: 20px;
+ border-radius: 8px;
+ background: var(--md-sys-color-surface-container-high, #25252b);
+ color: var(--md-sys-color-on-surface, #f5f5f5);
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
+ }
+
+ .status-panel h2,
+ .status-panel p {
+ margin: 0;
+ }
+
+ .status-panel h2 {
+ font-size: var(--md-sys-typescale-title-large-font-size, 1.375rem);
+ }
+
+ .status-panel p {
+ color: var(--md-sys-color-on-surface-variant, #c8c5cc);
+ line-height: 1.45;
+ }
+
+ .actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ margin-top: 8px;
+ }
+
+ .loader {
+ position: fixed;
+ left: 50%;
+ bottom: max(26px, env(safe-area-inset-bottom));
+ z-index: 2;
+ width: min(300px, calc(100vw - 48px));
+ transform: translateX(-50%);
+ }
+
+ .reticle {
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ z-index: 1;
+ width: 18px;
+ height: 18px;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ opacity: 0.54;
+ }
+
+ .reticle::before,
+ .reticle::after {
+ position: absolute;
+ content: '';
+ background: rgba(255, 255, 255, 0.72);
+ border-radius: 999px;
+ }
+
+ .reticle::before {
+ left: 8px;
+ top: 1px;
+ width: 2px;
+ height: 16px;
+ }
+
+ .reticle::after {
+ left: 1px;
+ top: 8px;
+ width: 16px;
+ height: 2px;
+ }
+ `;
+
+ connectedCallback() {
+ super.connectedCallback();
+ window.addEventListener('keydown', this.handleKeyDown);
+ window.addEventListener('keyup', this.handleKeyUp);
+ document.addEventListener('visibilitychange', this.handleVisibilityChange);
+ document.addEventListener(
+ 'pointerlockchange',
+ this.handlePointerLockChange
+ );
+ document.addEventListener('mousemove', this.handleMouseMove);
+ this.initialize();
+ }
+
+ disconnectedCallback() {
+ window.removeEventListener('keydown', this.handleKeyDown);
+ window.removeEventListener('keyup', this.handleKeyUp);
+ document.removeEventListener(
+ 'visibilitychange',
+ this.handleVisibilityChange
+ );
+ document.removeEventListener(
+ 'pointerlockchange',
+ this.handlePointerLockChange
+ );
+ document.removeEventListener('mousemove', this.handleMouseMove);
+ this.disposeScene();
+ super.disconnectedCallback();
+ }
+
+ protected firstUpdated() {
+ if (this.pendingStart) {
+ this.pendingStart = false;
+ this.startScene();
+ }
+ }
+
+ private async initialize() {
+ try {
+ const { getSettings } = await import('../services/settings');
+ const settings = await getSettings();
+ this.featureEnabled = settings.experimental_3d_timeline === true;
+ this.reducedMotion = window.matchMedia(
+ '(prefers-reduced-motion: reduce)'
+ ).matches;
+
+ if (!this.featureEnabled || this.reducedMotion) {
+ this.loading = false;
+ return;
+ }
+
+ this.lowPowerWarning = this.shouldWarnLowPower();
+ const { getPaginatedHomeTimeline } = await import('../services/timeline');
+ this.posts = await getPaginatedHomeTimeline('home');
+ this.loading = false;
+
+ if (this.posts.length > 0 && !this.lowPowerWarning) {
+ this.pendingStart = true;
+ await this.updateComplete;
+ this.startScene();
+ }
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : String(error);
+ this.loading = false;
+ }
+ }
+
+ private async startScene() {
+ if (!this.canvas || this.scene || this.posts.length === 0) {
+ return;
+ }
+
+ const [{ WarpScene }, { WarpCardPool }] = await Promise.all([
+ import('../utils/warp/scene'),
+ import('../utils/warp/card-pool'),
+ ]);
+
+ const theme = this.readTheme();
+ this.scene = new WarpScene(this.canvas, this, theme);
+ this.cardPool = new WarpCardPool(
+ this.posts,
+ this.scene.scene,
+ this.scene.curve,
+ theme
+ );
+ this.cardPool.update(this.progress, this.scene.camera);
+ this.scene.render();
+ this.sceneReady = true;
+ this.lastInputAt = performance.now();
+ this.startLoop();
+ }
+
+ private readTheme(): WarpTheme {
+ const styles = getComputedStyle(this);
+ const value = (name: string, fallback: string) =>
+ styles.getPropertyValue(name).trim() || fallback;
+
+ return {
+ surface: value('--md-sys-color-surface-container-high', '#25252b'),
+ onSurface: value('--md-sys-color-on-surface', '#f5f5f5'),
+ onSurfaceVariant: value('--md-sys-color-on-surface-variant', '#c8c5cc'),
+ primary: value('--md-sys-color-primary', '#9ecaff'),
+ outline: value('--md-sys-color-outline', '#8f8d96'),
+ };
+ }
+
+ private shouldWarnLowPower(): boolean {
+ const hardwareConcurrency = navigator.hardwareConcurrency || 8;
+ const deviceMemory = (navigator as Navigator & { deviceMemory?: number })
+ .deviceMemory;
+
+ return (
+ hardwareConcurrency < 4 ||
+ (deviceMemory !== undefined && deviceMemory < 4)
+ );
+ }
+
+ private startLoop() {
+ if (this.animationFrame || !this.scene || !this.cardPool) {
+ return;
+ }
+
+ this.lastFrameTime = performance.now();
+ this.animationFrame = requestAnimationFrame(this.tick);
+ }
+
+ private tick = (time: number) => {
+ this.animationFrame = 0;
+
+ if (!this.scene || !this.cardPool || document.hidden) {
+ return;
+ }
+
+ const delta = Math.min((time - this.lastFrameTime) / 1000, 0.05);
+ this.lastFrameTime = time;
+
+ const maxProgress = Math.max(0, this.posts.length - 1);
+ const targetVelocity = this.inputDirection * this.keyboardTravelSpeed;
+
+ if (this.inputDirection !== 0) {
+ const acceleration = Math.min(1, this.travelAcceleration * delta);
+ this.travelVelocity +=
+ (targetVelocity - this.travelVelocity) * acceleration;
+ } else {
+ this.travelVelocity *= Math.max(0, 1 - this.travelFriction * delta);
+ }
+
+ if (Math.abs(this.travelVelocity) < 0.01) {
+ this.travelVelocity = 0;
+ }
+
+ if (!this.hasMorePosts && this.travelVelocity > 0) {
+ const distanceToEnd = maxProgress - this.progress;
+ if (distanceToEnd < 2.2) {
+ this.travelVelocity *= Math.max(0.08, distanceToEnd / 2.2);
+ }
+ }
+
+ this.travelVelocity = clamp(
+ this.travelVelocity,
+ -this.maxTravelVelocity,
+ this.maxTravelVelocity
+ );
+ this.progress += this.travelVelocity * delta;
+ if (this.progress < 0) {
+ this.progress = 0;
+ this.travelVelocity = Math.max(0, this.travelVelocity);
+ }
+ if (this.progress > maxProgress) {
+ this.progress = maxProgress;
+ this.travelVelocity = Math.min(0, this.travelVelocity);
+ }
+
+ if (!this.pointerLocked && time - this.lastLookAt > 1200) {
+ const returnEase = Math.max(0, 1 - delta * 1.8);
+ this.targetLookYaw *= returnEase;
+ this.targetLookPitch *= returnEase;
+ }
+
+ const lookEase = Math.min(1, delta * 9);
+ this.lookYaw += (this.targetLookYaw - this.lookYaw) * lookEase;
+ this.lookPitch += (this.targetLookPitch - this.lookPitch) * lookEase;
+
+ this.loadMorePostsIfNeeded();
+ this.reachedEnd =
+ !this.hasMorePosts &&
+ !this.loadingMore &&
+ this.progress >= maxProgress - 0.04 &&
+ Math.abs(this.travelVelocity) < 0.04;
+
+ this.scene.setCameraProgressWithLook(
+ this.progress,
+ this.lookYaw,
+ this.lookPitch
+ );
+ this.cardPool.update(this.progress, this.scene.camera);
+ this.scene.render();
+
+ const isRecentlyActive = time - this.lastInputAt < 2000;
+ const isMoving = Math.abs(this.travelVelocity) > 0.01;
+ const isLooking =
+ Math.abs(this.targetLookYaw - this.lookYaw) > 0.001 ||
+ Math.abs(this.targetLookPitch - this.lookPitch) > 0.001;
+ if (
+ isMoving ||
+ isLooking ||
+ this.inputDirection !== 0 ||
+ isRecentlyActive
+ ) {
+ this.animationFrame = requestAnimationFrame(this.tick);
+ }
+ };
+
+ private handleWheel(event: WheelEvent) {
+ event.preventDefault();
+ this.travelVelocity = clamp(
+ this.travelVelocity + event.deltaY * this.wheelImpulse,
+ -this.maxTravelVelocity,
+ this.maxTravelVelocity
+ );
+
+ if (Math.abs(event.deltaX) > 0.5) {
+ this.targetLookYaw = clamp(
+ this.targetLookYaw + event.deltaX * 0.003,
+ -this.maxLookYaw,
+ this.maxLookYaw
+ );
+ this.lastLookAt = performance.now();
+ }
+
+ this.lastInputAt = performance.now();
+ this.startLoop();
+ }
+
+ private handleTouchStart(event: TouchEvent) {
+ this.touchStartY = event.touches[0]?.clientY ?? 0;
+ this.touchStartX = event.touches[0]?.clientX ?? 0;
+ }
+
+ private handleTouchMove(event: TouchEvent) {
+ event.preventDefault();
+ const currentY = event.touches[0]?.clientY ?? this.touchStartY;
+ const currentX = event.touches[0]?.clientX ?? this.touchStartX;
+ const deltaY = this.touchStartY - currentY;
+ const deltaX = currentX - this.touchStartX;
+
+ this.travelVelocity = clamp(
+ this.travelVelocity + deltaY * 0.045,
+ -this.maxTravelVelocity,
+ this.maxTravelVelocity
+ );
+ this.targetLookYaw = clamp(
+ this.targetLookYaw - deltaX * 0.004,
+ -this.maxLookYaw,
+ this.maxLookYaw
+ );
+
+ this.touchStartY = currentY;
+ this.touchStartX = currentX;
+ this.lastInputAt = performance.now();
+ this.lastLookAt = performance.now();
+ this.startLoop();
+ }
+
+ private handlePointerMove(event: PointerEvent) {
+ if (
+ !this.canvas ||
+ this.pointerLocked ||
+ this.shouldIgnorePointerEvent(event)
+ ) {
+ return;
+ }
+
+ const bounds = this.canvas.getBoundingClientRect();
+ const normalizedX = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
+ const normalizedY = ((event.clientY - bounds.top) / bounds.height) * 2 - 1;
+ this.targetLookYaw = clamp(
+ normalizedX * this.maxLookYaw,
+ -this.maxLookYaw,
+ this.maxLookYaw
+ );
+ this.targetLookPitch = clamp(
+ -normalizedY * this.maxLookPitch,
+ -this.maxLookPitch,
+ this.maxLookPitch
+ );
+ this.lastInputAt = performance.now();
+ this.lastLookAt = performance.now();
+ this.startLoop();
+ }
+
+ private handlePointerLeave() {
+ if (this.pointerLocked) {
+ return;
+ }
+
+ this.targetLookYaw = 0;
+ this.targetLookPitch = 0;
+ this.lastInputAt = performance.now();
+ this.startLoop();
+ }
+
+ private handlePointerLockChange = () => {
+ this.pointerLocked = document.pointerLockElement !== null;
+ this.lastInputAt = performance.now();
+ this.lastLookAt = performance.now();
+ this.startLoop();
+ };
+
+ private handleMouseMove = (event: MouseEvent) => {
+ if (document.pointerLockElement === null) {
+ return;
+ }
+
+ this.pointerLocked = true;
+ this.targetLookYaw = clamp(
+ this.targetLookYaw + event.movementX * this.mouseLookSensitivity,
+ -this.maxLookYaw,
+ this.maxLookYaw
+ );
+ this.targetLookPitch = clamp(
+ this.targetLookPitch - event.movementY * this.mouseLookSensitivity,
+ -this.maxLookPitch,
+ this.maxLookPitch
+ );
+ this.lookYaw = this.targetLookYaw;
+ this.lookPitch = this.targetLookPitch;
+
+ if (this.scene && this.cardPool) {
+ this.scene.setCameraProgressWithLook(
+ this.progress,
+ this.lookYaw,
+ this.lookPitch
+ );
+ this.cardPool.update(this.progress, this.scene.camera);
+ this.scene.render();
+ }
+
+ this.lastInputAt = performance.now();
+ this.lastLookAt = performance.now();
+ this.startLoop();
+ };
+
+ private requestCanvasPointerLock() {
+ this.canvas?.requestPointerLock?.();
+ }
+
+ private handlePointerDown(event: PointerEvent) {
+ if (this.shouldIgnorePointerEvent(event)) {
+ return;
+ }
+
+ this.pointerStartX = event.clientX;
+ this.pointerStartY = event.clientY;
+ this.pointerStartAt = performance.now();
+ }
+
+ private handlePointerUp(event: PointerEvent) {
+ if (this.shouldIgnorePointerEvent(event)) {
+ return;
+ }
+
+ const deltaX = event.clientX - this.pointerStartX;
+ const deltaY = event.clientY - this.pointerStartY;
+ const moved = !this.pointerLocked && Math.hypot(deltaX, deltaY) > 14;
+ const held = performance.now() - this.pointerStartAt > 650;
+ if (moved || held) {
+ return;
+ }
+
+ if (!this.canvas || !this.scene || !this.cardPool) {
+ return;
+ }
+
+ if (!this.pointerLocked) {
+ this.requestCanvasPointerLock();
+ return;
+ }
+
+ const bounds = this.canvas.getBoundingClientRect();
+ const pickX = bounds.left + bounds.width / 2;
+ const pickY = bounds.top + bounds.height / 2;
+ const result = this.cardPool.pick(pickX, pickY, bounds, this.scene.camera);
+ if (!result) {
+ return;
+ }
+
+ if (!result.action) {
+ this.travelVelocity *= 0.35;
+ this.lastInputAt = performance.now();
+ this.startLoop();
+ return;
+ }
+
+ if (result.action === 'open' || result.action === 'reply') {
+ router.navigate(`/home/post/${result.post.id}`, {
+ state: { post: result.post },
+ });
+ return;
+ }
+
+ this.handlePostAction(result.postIndex, result.action);
+ }
+
+ private shouldIgnorePointerEvent(event: PointerEvent) {
+ return event
+ .composedPath()
+ .some(
+ (target) =>
+ target instanceof HTMLElement &&
+ (target.closest('.top-bar') || target.closest('.status-panel'))
+ );
+ }
+
+ private async handlePostAction(postIndex: number, action: 'boost' | 'like') {
+ const post = this.posts[postIndex];
+ if (!post) {
+ return;
+ }
+
+ const previousPost = { ...post };
+ const nextPost = { ...post };
+ const targetId = post.reblog?.id || post.id;
+
+ if (action === 'like') {
+ nextPost.favourited = !post.favourited;
+ nextPost.favourites_count = Math.max(
+ 0,
+ post.favourites_count + (nextPost.favourited ? 1 : -1)
+ );
+ } else {
+ nextPost.reblogged = !post.reblogged;
+ nextPost.reblogs_count = Math.max(
+ 0,
+ post.reblogs_count + (nextPost.reblogged ? 1 : -1)
+ );
+ }
+
+ this.replacePost(postIndex, nextPost);
+
+ try {
+ const timeline = await import('../services/timeline');
+ if (action === 'like') {
+ if (nextPost.favourited) {
+ await timeline.boostPost(targetId);
+ } else {
+ await timeline.unboostPost(targetId);
+ }
+ return;
+ }
+
+ if (nextPost.reblogged) {
+ await timeline.reblogPost(targetId);
+ } else {
+ await timeline.unreblogPost(targetId);
+ }
+ } catch (error) {
+ console.error(`Failed to ${action} warp post`, error);
+ this.replacePost(postIndex, previousPost);
+ }
+ }
+
+ private replacePost(postIndex: number, post: Post) {
+ this.posts = this.posts.map((candidate, index) =>
+ index === postIndex ? post : candidate
+ );
+ this.cardPool?.updatePost(postIndex, post);
+ this.lastInputAt = performance.now();
+ this.startLoop();
+ }
+
+ private handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ router.navigate('/home');
+ return;
+ }
+
+ if (this.isTravelKey(event.key)) {
+ event.preventDefault();
+ this.pressedKeys.add(event.key.toLowerCase());
+ this.updateInputDirection();
+ this.lastInputAt = performance.now();
+ this.startLoop();
+ }
+ };
+
+ private handleKeyUp = (event: KeyboardEvent) => {
+ if (this.isTravelKey(event.key)) {
+ event.preventDefault();
+ this.pressedKeys.delete(event.key.toLowerCase());
+ this.updateInputDirection();
+ this.lastInputAt = performance.now();
+ this.startLoop();
+ }
+ };
+
+ private isTravelKey(key: string): boolean {
+ const normalizedKey = key.toLowerCase();
+ return (
+ normalizedKey === 'arrowup' ||
+ normalizedKey === 'arrowdown' ||
+ normalizedKey === 'w' ||
+ normalizedKey === 's' ||
+ normalizedKey === ' '
+ );
+ }
+
+ private updateInputDirection() {
+ const forward =
+ this.pressedKeys.has('arrowup') ||
+ this.pressedKeys.has('w') ||
+ this.pressedKeys.has(' ');
+ const backward =
+ this.pressedKeys.has('arrowdown') || this.pressedKeys.has('s');
+
+ this.inputDirection = forward === backward ? 0 : forward ? 1 : -1;
+ }
+
+ private handleVisibilityChange = () => {
+ if (document.hidden) {
+ if (this.animationFrame) {
+ cancelAnimationFrame(this.animationFrame);
+ this.animationFrame = 0;
+ }
+ return;
+ }
+
+ this.lastInputAt = performance.now();
+ this.startLoop();
+ };
+
+ private async loadMorePostsIfNeeded(force = false) {
+ if (this.loadingMore || !this.hasMorePosts || this.posts.length === 0) {
+ return;
+ }
+
+ const remainingPosts = this.posts.length - 1 - Math.floor(this.progress);
+ if (!force && remainingPosts > 8) {
+ return;
+ }
+
+ this.loadingMore = true;
+
+ try {
+ const { getPaginatedHomeTimeline } = await import('../services/timeline');
+ const lastPost = this.posts[this.posts.length - 1];
+ const nextPosts = await getPaginatedHomeTimeline('home', lastPost.id);
+ const existingIds = new Set(this.posts.map((post) => post.id));
+ const uniqueNextPosts = nextPosts.filter(
+ (post) => !existingIds.has(post.id)
+ );
+
+ if (uniqueNextPosts.length === 0) {
+ this.hasMorePosts = false;
+ return;
+ }
+
+ this.posts = [...this.posts, ...uniqueNextPosts];
+ this.cardPool?.updatePosts(this.posts);
+ this.reachedEnd = false;
+ this.lastInputAt = performance.now();
+ this.startLoop();
+ } catch (error) {
+ console.error('Failed to load more warp timeline posts', error);
+ this.hasMorePosts = false;
+ } finally {
+ this.loadingMore = false;
+ }
+ }
+
+ private disposeScene() {
+ if (this.animationFrame) {
+ cancelAnimationFrame(this.animationFrame);
+ this.animationFrame = 0;
+ }
+ this.cardPool?.dispose();
+ this.cardPool = null;
+ this.scene?.dispose();
+ this.scene = null;
+ }
+
+ private async enableFeatureAndGoHome() {
+ const { setSettings } = await import('../services/settings');
+ await setSettings({ experimental_3d_timeline: true });
+ router.navigate('/home');
+ }
+
+ render() {
+ return html`
+
+
+
+
+
+ router.navigate('/home')}">
+ ${msg('Exit')}
+
+
+
+ ${this.loading
+ ? html`
+
+
`
+ : nothing}
+ ${this.loadingMore
+ ? html`
+
+
`
+ : nothing}
+ ${this.renderStatusPanel()}
+
+ `;
+ }
+
+ private renderStatusPanel() {
+ if (this.loading || (this.sceneReady && !this.reachedEnd)) {
+ return nothing;
+ }
+
+ if (!this.featureEnabled) {
+ return html`
+
+ ${msg('Warp timeline is off')}
+
+ ${msg(
+ 'Enable the experimental 3D timeline from Settings to try this prototype.'
+ )}
+
+
+ router.navigate('/home')}"
+ >
+ ${msg('Back')}
+
+ this.enableFeatureAndGoHome()}"
+ >
+ ${msg('Enable')}
+
+
+
+ `;
+ }
+
+ if (this.reducedMotion) {
+ return html`
+
+ ${msg('Motion preference respected')}
+
+ ${msg(
+ 'The 3D timeline is paused because reduced motion is enabled on this device.'
+ )}
+
+
+ router.navigate('/home')}"
+ >
+ ${msg('Open Home')}
+
+
+
+ `;
+ }
+
+ if (this.lowPowerWarning && !this.sceneReady) {
+ return html`
+
+ ${msg('This may be demanding')}
+
+ ${msg(
+ 'This prototype uses WebGL and may be less smooth on this device.'
+ )}
+
+
+ router.navigate('/home')}"
+ >
+ ${msg('Back')}
+
+ {
+ this.lowPowerWarning = false;
+ this.startScene();
+ }}"
+ >
+ ${msg('Continue')}
+
+
+
+ `;
+ }
+
+ if (this.error) {
+ return html`
+
+ ${msg('Could not load the warp')}
+ ${this.error}
+
+ router.navigate('/home')}"
+ >
+ ${msg('Back')}
+
+
+
+ `;
+ }
+
+ if (this.posts.length === 0) {
+ return html`
+
+ ${msg('No posts to warp through')}
+
+ ${msg(
+ 'Your home timeline did not return any posts for this prototype run.'
+ )}
+
+
+ router.navigate('/home')}"
+ >
+ ${msg('Back')}
+
+
+
+ `;
+ }
+
+ if (this.reachedEnd) {
+ return html`
+
+ ${msg('End of warp')}
+ ${msg('That is the end of the loaded prototype timeline.')}
+
+ {
+ this.progress = 0;
+ this.travelVelocity = 0;
+ this.lookYaw = 0;
+ this.lookPitch = 0;
+ this.targetLookYaw = 0;
+ this.targetLookPitch = 0;
+ this.reachedEnd = false;
+ this.lastInputAt = performance.now();
+ this.startLoop();
+ }}"
+ >
+ ${msg('Restart')}
+
+ router.navigate('/home')}"
+ >
+ ${msg('Exit')}
+
+
+
+ `;
+ }
+
+ return nothing;
+ }
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'app-warp': AppWarp;
+ }
+}
diff --git a/src/router/routes.ts b/src/router/routes.ts
index da6f8813..76de1bce 100644
--- a/src/router/routes.ts
+++ b/src/router/routes.ts
@@ -42,6 +42,12 @@ const routes: Route[] = [
plugins: [lazy(() => import('../pages/app-home.js'))],
render: () => html``,
},
+ {
+ path: '/warp',
+ title: 'warp',
+ plugins: [lazy(() => import('../pages/app-warp.js'))],
+ render: () => html``,
+ },
{
path: '/search',
title: 'search',
diff --git a/src/services/settings.ts b/src/services/settings.ts
index b07a3282..ea3b184b 100644
--- a/src/services/settings.ts
+++ b/src/services/settings.ts
@@ -6,6 +6,7 @@ export interface Settings {
focus?: boolean;
sensitive?: boolean;
haptics?: boolean;
+ experimental_3d_timeline?: boolean;
}
export const SETTINGS_CHANGED_EVENT = 'coho-settings-changed';
@@ -18,6 +19,7 @@ const defaultSettings = {
focus: false,
sensitive: false,
haptics: true,
+ experimental_3d_timeline: false,
};
export async function getSettings(): Promise {
@@ -48,6 +50,11 @@ export async function setSettings(settings: Settings) {
haptics: Object.keys(settings).includes('haptics')
? settings.haptics
: currentSettings.haptics,
+ experimental_3d_timeline: Object.keys(settings).includes(
+ 'experimental_3d_timeline'
+ )
+ ? settings.experimental_3d_timeline
+ : currentSettings.experimental_3d_timeline,
};
// Also store theme color in localStorage for instant access on page load
diff --git a/src/utils/warp/card-pool.ts b/src/utils/warp/card-pool.ts
new file mode 100644
index 00000000..6bad2665
--- /dev/null
+++ b/src/utils/warp/card-pool.ts
@@ -0,0 +1,339 @@
+import * as THREE from 'three';
+import type { Post } from '../../interfaces/Post';
+import {
+ createPlaceholderTexture,
+ createPostCardTexture,
+ WARP_CARD_TEXTURE_HEIGHT,
+ WARP_CARD_TEXTURE_WIDTH,
+ WARP_ACTION_HITBOXES,
+ type WarpPostAction,
+ type WarpTheme,
+} from './post-card-texture';
+import { WARP_POST_LEAD, WARP_POST_SPACING } from './scene';
+
+export interface WarpCardPickResult {
+ post: Post;
+ postIndex: number;
+ action: WarpPostAction | null;
+}
+
+const poolSize = 20;
+const fullTextureSlots = 10;
+const nearTextureDistance = 24;
+const focusedCardWidth = 3.35;
+const maxConcurrentTextureBuilds = 3;
+const postsBehindCamera = 4;
+
+interface WarpCard {
+ mesh: THREE.Mesh;
+ postIndex: number;
+ hasFullTexture: boolean;
+ loadingTexture: boolean;
+}
+
+export class WarpCardPool {
+ private readonly cards: WarpCard[] = [];
+ private readonly textureCache = new Map();
+ private readonly curveLength: number;
+ private readonly raycaster = new THREE.Raycaster();
+ private readonly pointer = new THREE.Vector2();
+ private textureBuildsInFlight = 0;
+
+ constructor(
+ private posts: Post[],
+ private readonly scene: THREE.Scene,
+ private readonly curve: THREE.CatmullRomCurve3,
+ private readonly theme: WarpTheme
+ ) {
+ this.curveLength = curve.getLength();
+
+ const visibleCount = Math.min(poolSize, posts.length);
+ for (let index = 0; index < visibleCount; index += 1) {
+ const material = new THREE.MeshBasicMaterial({
+ map: createPlaceholderTexture(theme),
+ transparent: true,
+ side: THREE.DoubleSide,
+ });
+ const cardWidth = focusedCardWidth;
+ const cardHeight =
+ cardWidth * (WARP_CARD_TEXTURE_HEIGHT / WARP_CARD_TEXTURE_WIDTH);
+ const mesh = new THREE.Mesh(
+ new THREE.PlaneGeometry(cardWidth, cardHeight),
+ material
+ );
+ mesh.renderOrder = 2;
+ this.scene.add(mesh);
+ this.cards.push({
+ mesh,
+ postIndex: -1,
+ hasFullTexture: false,
+ loadingTexture: false,
+ });
+ }
+ }
+
+ updatePosts(posts: Post[]) {
+ this.posts = posts;
+ }
+
+ update(progress: number, camera: THREE.PerspectiveCamera) {
+ const baseIndex = Math.max(0, Math.floor(progress) - postsBehindCamera);
+ const availableTextureBuilds = Math.max(
+ 0,
+ maxConcurrentTextureBuilds - this.textureBuildsInFlight
+ );
+ let textureBuildsStarted = 0;
+
+ this.cards.forEach((card, slotIndex) => {
+ const postIndex = baseIndex + slotIndex;
+ if (postIndex < 0 || postIndex >= this.posts.length) {
+ card.mesh.visible = false;
+ return;
+ }
+
+ card.mesh.visible = true;
+ if (card.postIndex !== postIndex) {
+ this.assignPost(card, postIndex);
+ }
+
+ const t =
+ ((postIndex + WARP_POST_LEAD) * WARP_POST_SPACING) / this.curveLength;
+ const curveT = Math.min(t, 0.985);
+ const position = this.curve.getPointAt(curveT);
+ const tangent = this.curve.getTangentAt(curveT).normalize();
+ const viewportScale = getViewportCardScale(camera.aspect);
+ const lane = getAnchoredCardPlacement(
+ postIndex - progress,
+ postIndex,
+ tangent,
+ viewportScale
+ );
+ card.mesh.position.set(
+ position.x + lane.x,
+ position.y + lane.y,
+ position.z
+ );
+ card.mesh.scale.setScalar(lane.scale);
+ card.mesh.material.opacity = 1;
+ card.mesh.lookAt(camera.position);
+
+ const distance = camera.position.distanceTo(card.mesh.position);
+ const distanceFromTravel = Math.abs(postIndex - progress);
+ card.mesh.material.opacity = THREE.MathUtils.clamp(
+ 1 - (distanceFromTravel - 9) * 0.12,
+ 0.42,
+ 1
+ );
+ if (
+ (distanceFromTravel < fullTextureSlots ||
+ distance < nearTextureDistance) &&
+ !card.hasFullTexture &&
+ !card.loadingTexture &&
+ textureBuildsStarted < availableTextureBuilds
+ ) {
+ textureBuildsStarted += 1;
+ this.promoteTexture(card);
+ }
+ });
+ }
+
+ pick(
+ clientX: number,
+ clientY: number,
+ bounds: DOMRect,
+ camera: THREE.PerspectiveCamera
+ ): WarpCardPickResult | null {
+ this.pointer.x = ((clientX - bounds.left) / bounds.width) * 2 - 1;
+ this.pointer.y = -((clientY - bounds.top) / bounds.height) * 2 + 1;
+ this.raycaster.setFromCamera(this.pointer, camera);
+
+ const intersections = this.raycaster.intersectObjects(
+ this.cards.filter((card) => card.mesh.visible).map((card) => card.mesh)
+ );
+ const intersection = intersections[0];
+ const mesh = intersection?.object;
+ const card = this.cards.find((candidate) => candidate.mesh === mesh);
+ if (!card) {
+ return null;
+ }
+
+ return {
+ post: this.posts[card.postIndex],
+ postIndex: card.postIndex,
+ action: intersection?.uv ? getActionFromUv(intersection.uv) : null,
+ };
+ }
+
+ updatePost(postIndex: number, post: Post) {
+ this.posts = this.posts.map((candidate, index) =>
+ index === postIndex ? post : candidate
+ );
+
+ const card = this.cards.find(
+ (candidate) => candidate.postIndex === postIndex
+ );
+ if (!card) {
+ const cachedTexture = this.textureCache.get(postIndex);
+ cachedTexture?.dispose();
+ this.textureCache.delete(postIndex);
+ return;
+ }
+
+ this.refreshTexture(card, postIndex);
+ }
+
+ dispose() {
+ this.cards.forEach((card) => {
+ this.scene.remove(card.mesh);
+ card.mesh.geometry.dispose();
+ this.disposeTextureIfUncached(card.mesh.material.map);
+ card.mesh.material.dispose();
+ });
+ this.cards.length = 0;
+ this.textureCache.forEach((texture) => texture.dispose());
+ this.textureCache.clear();
+ }
+
+ private assignPost(card: WarpCard, postIndex: number) {
+ card.postIndex = postIndex;
+ card.loadingTexture = false;
+ this.disposeTextureIfUncached(card.mesh.material.map);
+
+ const cachedTexture = this.textureCache.get(postIndex);
+ card.hasFullTexture = cachedTexture !== undefined;
+ card.mesh.material.map =
+ cachedTexture ?? createPlaceholderTexture(this.theme);
+ card.mesh.material.needsUpdate = true;
+ }
+
+ private async promoteTexture(card: WarpCard) {
+ card.loadingTexture = true;
+ this.textureBuildsInFlight += 1;
+ const post = this.posts[card.postIndex];
+ const postIndex = card.postIndex;
+
+ try {
+ const cachedTexture = this.textureCache.get(postIndex);
+ if (cachedTexture) {
+ this.disposeTextureIfUncached(card.mesh.material.map);
+ card.mesh.material.map = cachedTexture;
+ card.mesh.material.needsUpdate = true;
+ card.hasFullTexture = true;
+ return;
+ }
+
+ const texture = await createPostCardTexture(post, this.theme);
+ if (card.postIndex !== postIndex) {
+ texture.dispose();
+ return;
+ }
+
+ this.textureCache.set(postIndex, texture);
+ this.disposeTextureIfUncached(card.mesh.material.map);
+ card.mesh.material.map = texture;
+ card.mesh.material.needsUpdate = true;
+ card.hasFullTexture = true;
+ } finally {
+ this.textureBuildsInFlight = Math.max(0, this.textureBuildsInFlight - 1);
+ card.loadingTexture = false;
+ }
+ }
+
+ private async refreshTexture(card: WarpCard, postIndex: number) {
+ if (card.loadingTexture) {
+ return;
+ }
+
+ card.loadingTexture = true;
+ const previousTexture = this.textureCache.get(postIndex);
+
+ try {
+ const texture = await createPostCardTexture(
+ this.posts[postIndex],
+ this.theme
+ );
+ if (card.postIndex !== postIndex) {
+ texture.dispose();
+ return;
+ }
+
+ this.textureCache.set(postIndex, texture);
+ card.mesh.material.map = texture;
+ card.mesh.material.needsUpdate = true;
+ card.hasFullTexture = true;
+
+ if (previousTexture && previousTexture !== texture) {
+ previousTexture.dispose();
+ }
+ } finally {
+ card.loadingTexture = false;
+ }
+ }
+
+ private disposeTextureIfUncached(texture: THREE.Texture | null) {
+ if (!texture) {
+ return;
+ }
+
+ if (
+ ![...this.textureCache.values()].includes(texture as THREE.CanvasTexture)
+ ) {
+ texture.dispose();
+ }
+ }
+}
+
+function getActionFromUv(uv: THREE.Vector2): WarpPostAction | null {
+ const x = uv.x * WARP_CARD_TEXTURE_WIDTH;
+ const y = (1 - uv.y) * WARP_CARD_TEXTURE_HEIGHT;
+ const hitbox = WARP_ACTION_HITBOXES.find(
+ (candidate) =>
+ x >= candidate.x &&
+ x <= candidate.x + candidate.width &&
+ y >= candidate.y &&
+ y <= candidate.y + candidate.height
+ );
+
+ return hitbox?.action ?? null;
+}
+
+function getAnchoredCardPlacement(
+ distanceFromFocus: number,
+ postIndex: number,
+ tangent: THREE.Vector3,
+ viewportScale: number
+): { x: number; y: number; scale: number } {
+ const worldUp = new THREE.Vector3(0, 1, 0);
+ const right = new THREE.Vector3().crossVectors(tangent, worldUp).normalize();
+
+ if (right.lengthSq() < 0.001) {
+ right.set(1, 0, 0);
+ }
+
+ const up = new THREE.Vector3().crossVectors(right, tangent).normalize();
+ const side = postIndex % 2 === 0 ? -1 : 1;
+ const distance = Math.abs(distanceFromFocus);
+ const clampedDistance = Math.min(distance, 6);
+ const sideOffset = 1.45 * side * viewportScale;
+ const verticalPattern =
+ postIndex % 4 === 0 ? 0.22 : postIndex % 4 === 3 ? -0.16 : 0.04;
+ const scaleBoost = 1 + (1 - Math.min(clampedDistance, 1)) * 0.12;
+ const depthScale = Math.max(0.72, 1 - clampedDistance * 0.045);
+ const offset = right
+ .multiplyScalar(sideOffset)
+ .add(up.multiplyScalar(verticalPattern * viewportScale));
+
+ return {
+ x: offset.x,
+ y: offset.y,
+ scale: depthScale * scaleBoost * viewportScale,
+ };
+}
+
+function getViewportCardScale(aspect: number): number {
+ if (aspect >= 0.82) {
+ return 1;
+ }
+
+ return THREE.MathUtils.clamp(aspect / 0.82, 0.58, 1);
+}
diff --git a/src/utils/warp/post-card-texture.ts b/src/utils/warp/post-card-texture.ts
new file mode 100644
index 00000000..16f4d9e1
--- /dev/null
+++ b/src/utils/warp/post-card-texture.ts
@@ -0,0 +1,624 @@
+import * as THREE from 'three';
+import type { Post } from '../../interfaces/Post';
+import type { MediaAttachment } from '../../mastodon/types/media';
+
+export interface WarpTheme {
+ surface: string;
+ onSurface: string;
+ onSurfaceVariant: string;
+ primary: string;
+ outline: string;
+}
+
+export type WarpPostAction = 'reply' | 'boost' | 'like' | 'open';
+
+export interface WarpActionHitbox {
+ action: WarpPostAction;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export const WARP_CARD_TEXTURE_WIDTH = 512;
+export const WARP_CARD_TEXTURE_HEIGHT = 390;
+
+export const WARP_ACTION_HITBOXES: WarpActionHitbox[] = [
+ { action: 'reply', x: 32, y: 340, width: 88, height: 34 },
+ { action: 'boost', x: 138, y: 340, width: 92, height: 34 },
+ { action: 'like', x: 248, y: 340, width: 88, height: 34 },
+ { action: 'open', x: 392, y: 340, width: 88, height: 34 },
+];
+
+const maxCachedImages = 96;
+const imageCache = new Map>();
+
+export async function createPostCardTexture(
+ post: Post,
+ theme: WarpTheme
+): Promise {
+ const canvas = document.createElement('canvas');
+ canvas.width = WARP_CARD_TEXTURE_WIDTH;
+ canvas.height = WARP_CARD_TEXTURE_HEIGHT;
+
+ const context = canvas.getContext('2d');
+ if (!context) {
+ throw new Error('Unable to create post card canvas context');
+ }
+
+ drawCardBackground(context, theme);
+ await drawAvatar(context, post, theme);
+ drawText(context, post, theme);
+ await drawMediaGrid(context, post, theme);
+ drawActions(context, post, theme);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.anisotropy = 2;
+ texture.needsUpdate = true;
+ return texture;
+}
+
+export function createPlaceholderTexture(
+ theme: WarpTheme
+): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas');
+ canvas.width = WARP_CARD_TEXTURE_WIDTH;
+ canvas.height = WARP_CARD_TEXTURE_HEIGHT;
+
+ const context = canvas.getContext('2d');
+ if (!context) {
+ throw new Error('Unable to create placeholder canvas context');
+ }
+
+ drawCardBackground(context, theme);
+ context.fillStyle = withAlpha(theme.primary, 0.18);
+ roundedRect(context, 32, 28, 50, 50, 25);
+ context.fill();
+ context.fillStyle = withAlpha(theme.onSurfaceVariant, 0.18);
+ roundedRect(context, 98, 34, 210, 18, 9);
+ context.fill();
+ roundedRect(context, 98, 62, 260, 14, 7);
+ context.fill();
+ roundedRect(context, 32, 116, 448, 16, 8);
+ context.fill();
+ roundedRect(context, 32, 146, 390, 16, 8);
+ context.fill();
+ roundedRect(context, 32, 166, 448, 156, 18);
+ context.fill();
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.needsUpdate = true;
+ return texture;
+}
+
+function drawCardBackground(
+ context: CanvasRenderingContext2D,
+ theme: WarpTheme
+) {
+ context.clearRect(0, 0, WARP_CARD_TEXTURE_WIDTH, WARP_CARD_TEXTURE_HEIGHT);
+ context.fillStyle = theme.surface;
+ roundedRect(
+ context,
+ 0,
+ 0,
+ WARP_CARD_TEXTURE_WIDTH,
+ WARP_CARD_TEXTURE_HEIGHT,
+ 18
+ );
+ context.fill();
+ context.lineWidth = 1;
+ context.strokeStyle = withAlpha(theme.outline, 0.2);
+ context.stroke();
+}
+
+async function drawAvatar(
+ context: CanvasRenderingContext2D,
+ post: Post,
+ theme: WarpTheme
+) {
+ const avatarX = 32;
+ const avatarY = 28;
+ const avatarSize = 50;
+ const avatarUrl = post.reblog?.account.avatar || post.account.avatar;
+
+ context.save();
+ context.beginPath();
+ context.arc(
+ avatarX + avatarSize / 2,
+ avatarY + avatarSize / 2,
+ avatarSize / 2,
+ 0,
+ Math.PI * 2
+ );
+ context.clip();
+
+ const image = avatarUrl ? await loadImage(avatarUrl) : null;
+ if (image) {
+ context.drawImage(image, avatarX, avatarY, avatarSize, avatarSize);
+ } else {
+ context.fillStyle = withAlpha(theme.primary, 0.26);
+ context.fillRect(avatarX, avatarY, avatarSize, avatarSize);
+ context.fillStyle = theme.onSurface;
+ context.font =
+ '700 28px system-ui, -apple-system, BlinkMacSystemFont, sans-serif';
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText(
+ getDisplayName(post).slice(0, 1).toUpperCase() || '@',
+ avatarX + avatarSize / 2,
+ avatarY + avatarSize / 2 + 1
+ );
+ }
+
+ context.restore();
+}
+
+function drawText(
+ context: CanvasRenderingContext2D,
+ post: Post,
+ theme: WarpTheme
+) {
+ const displayName = getDisplayName(post);
+ const account = post.reblog?.account || post.account;
+ const handle = `@${account.acct || account.username}`;
+ const content = stripHtml(post.reblog?.content || post.content);
+ const hasMedia = getVisualMediaAttachments(post).length > 0;
+
+ context.textAlign = 'left';
+ context.textBaseline = 'alphabetic';
+
+ context.fillStyle = theme.onSurface;
+ context.font =
+ '700 22px system-ui, -apple-system, BlinkMacSystemFont, sans-serif';
+ context.fillText(truncateText(context, displayName, 340), 98, 50);
+
+ context.fillStyle = theme.onSurfaceVariant;
+ context.font =
+ '500 16px system-ui, -apple-system, BlinkMacSystemFont, sans-serif';
+ context.fillText(truncateText(context, handle, 340), 98, 74);
+
+ context.fillStyle = theme.onSurface;
+ context.font =
+ '500 20px system-ui, -apple-system, BlinkMacSystemFont, sans-serif';
+ wrapText(
+ context,
+ content || 'No text content',
+ 32,
+ 116,
+ 448,
+ 25,
+ hasMedia ? 2 : 7
+ );
+}
+
+async function drawMediaGrid(
+ context: CanvasRenderingContext2D,
+ post: Post,
+ theme: WarpTheme
+) {
+ const mediaAttachments = getVisualMediaAttachments(post);
+ if (mediaAttachments.length === 0) {
+ return;
+ }
+
+ const gridX = 32;
+ const gridY = 166;
+ const gridWidth = 448;
+ const gridHeight = 156;
+ const gap = 3;
+ const visibleMedia = mediaAttachments.slice(0, 4);
+ const cells = getMediaGridCells(
+ visibleMedia.length,
+ gridX,
+ gridY,
+ gridWidth,
+ gridHeight,
+ gap
+ );
+
+ context.save();
+ roundedRect(context, gridX, gridY, gridWidth, gridHeight, 14);
+ context.clip();
+
+ for (const [index, media] of visibleMedia.entries()) {
+ const cell = cells[index];
+ await drawMediaCell(context, media, cell, theme);
+ }
+
+ context.restore();
+
+ context.lineWidth = 2;
+ context.strokeStyle = withAlpha(theme.outline, 0.26);
+ roundedRect(context, gridX, gridY, gridWidth, gridHeight, 14);
+ context.stroke();
+
+ if (mediaAttachments.length > visibleMedia.length) {
+ context.fillStyle = withAlpha(theme.surface, 0.82);
+ roundedRect(
+ context,
+ gridX + gridWidth - 54,
+ gridY + gridHeight - 34,
+ 38,
+ 22,
+ 11
+ );
+ context.fill();
+ context.fillStyle = theme.onSurface;
+ context.font =
+ '700 12px system-ui, -apple-system, BlinkMacSystemFont, sans-serif';
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText(
+ `+${mediaAttachments.length - visibleMedia.length}`,
+ gridX + gridWidth - 35,
+ gridY + gridHeight - 23
+ );
+ }
+}
+
+async function drawMediaCell(
+ context: CanvasRenderingContext2D,
+ media: MediaAttachment,
+ cell: MediaGridCell,
+ theme: WarpTheme
+) {
+ const previewUrl = media.preview_url || media.url;
+ const image = previewUrl ? await loadImage(previewUrl) : null;
+
+ context.fillStyle = withAlpha(theme.onSurfaceVariant, 0.14);
+ context.fillRect(cell.x, cell.y, cell.width, cell.height);
+
+ if (image) {
+ drawImageContain(context, image, cell.x, cell.y, cell.width, cell.height);
+ } else {
+ context.fillStyle = withAlpha(theme.primary, 0.18);
+ context.fillRect(cell.x, cell.y, cell.width, cell.height);
+ context.fillStyle = theme.onSurfaceVariant;
+ context.font =
+ '700 15px system-ui, -apple-system, BlinkMacSystemFont, sans-serif';
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText(
+ 'Media',
+ cell.x + cell.width / 2,
+ cell.y + cell.height / 2
+ );
+ }
+
+ if (media.type === 'video' || media.type === 'gifv') {
+ context.fillStyle = withAlpha(theme.surface, 0.82);
+ roundedRect(context, cell.x + 8, cell.y + 8, 42, 22, 11);
+ context.fill();
+ context.fillStyle = theme.onSurface;
+ context.font =
+ '700 12px system-ui, -apple-system, BlinkMacSystemFont, sans-serif';
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText(
+ media.type === 'gifv' ? 'GIF' : 'Video',
+ cell.x + 29,
+ cell.y + 19
+ );
+ }
+}
+
+function drawActions(
+ context: CanvasRenderingContext2D,
+ post: Post,
+ theme: WarpTheme
+) {
+ drawActionButton(
+ context,
+ theme,
+ WARP_ACTION_HITBOXES[0],
+ formatActionLabel('Reply', post.replies_count),
+ false
+ );
+ drawActionButton(
+ context,
+ theme,
+ WARP_ACTION_HITBOXES[1],
+ formatActionLabel(
+ post.reblogged ? 'Reposted' : 'Repost',
+ post.reblogs_count
+ ),
+ post.reblogged
+ );
+ drawActionButton(
+ context,
+ theme,
+ WARP_ACTION_HITBOXES[2],
+ formatActionLabel(
+ post.favourited ? 'Liked' : 'Like',
+ post.favourites_count
+ ),
+ post.favourited
+ );
+ drawActionButton(context, theme, WARP_ACTION_HITBOXES[3], 'Open', false);
+}
+
+function drawActionButton(
+ context: CanvasRenderingContext2D,
+ theme: WarpTheme,
+ hitbox: WarpActionHitbox,
+ label: string,
+ active: boolean
+) {
+ context.fillStyle = active ? withAlpha(theme.primary, 0.28) : 'transparent';
+ roundedRect(context, hitbox.x, hitbox.y, hitbox.width, hitbox.height, 15);
+ context.fill();
+ context.lineWidth = 2;
+ context.strokeStyle = active
+ ? withAlpha(theme.primary, 0.64)
+ : withAlpha(theme.outline, 0.34);
+ context.stroke();
+ context.fillStyle = active ? theme.primary : theme.onSurfaceVariant;
+ context.font =
+ '700 14px system-ui, -apple-system, BlinkMacSystemFont, sans-serif';
+ context.textAlign = 'center';
+ context.textBaseline = 'middle';
+ context.fillText(
+ truncateText(context, label, hitbox.width - 18),
+ hitbox.x + hitbox.width / 2,
+ hitbox.y + hitbox.height / 2 + 1
+ );
+}
+
+function formatActionLabel(label: string, count: number): string {
+ return count > 0 ? `${label} ${count}` : label;
+}
+
+function getDisplayName(post: Post): string {
+ const account = post.reblog?.account || post.account;
+ return stripHtml(account.display_name || account.username || account.acct);
+}
+
+function getVisualMediaAttachments(post: Post): MediaAttachment[] {
+ const sourcePost = post.reblog ?? post;
+ if (sourcePost.sensitive) {
+ return [];
+ }
+
+ return (sourcePost.media_attachments ?? []).filter(
+ (media) =>
+ media.type === 'image' || media.type === 'gifv' || media.type === 'video'
+ );
+}
+
+interface MediaGridCell {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+function getMediaGridCells(
+ count: number,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ gap: number
+): MediaGridCell[] {
+ if (count === 1) {
+ return [{ x, y, width, height }];
+ }
+
+ if (count === 2) {
+ const cellWidth = (width - gap) / 2;
+ return [
+ { x, y, width: cellWidth, height },
+ { x: x + cellWidth + gap, y, width: cellWidth, height },
+ ];
+ }
+
+ if (count === 3) {
+ const leftWidth = (width - gap) / 2;
+ const rightHeight = (height - gap) / 2;
+ return [
+ { x, y, width: leftWidth, height },
+ { x: x + leftWidth + gap, y, width: leftWidth, height: rightHeight },
+ {
+ x: x + leftWidth + gap,
+ y: y + rightHeight + gap,
+ width: leftWidth,
+ height: rightHeight,
+ },
+ ];
+ }
+
+ const cellWidth = (width - gap) / 2;
+ const cellHeight = (height - gap) / 2;
+ return [
+ { x, y, width: cellWidth, height: cellHeight },
+ { x: x + cellWidth + gap, y, width: cellWidth, height: cellHeight },
+ { x, y: y + cellHeight + gap, width: cellWidth, height: cellHeight },
+ {
+ x: x + cellWidth + gap,
+ y: y + cellHeight + gap,
+ width: cellWidth,
+ height: cellHeight,
+ },
+ ];
+}
+
+function stripHtml(value: string): string {
+ const parser = new DOMParser();
+ const document = parser.parseFromString(value, 'text/html');
+ return (document.body.textContent || '')
+ .replace(/\s+/g, ' ')
+ .replace(/ /g, ' ')
+ .trim();
+}
+
+function wrapText(
+ context: CanvasRenderingContext2D,
+ text: string,
+ x: number,
+ y: number,
+ maxWidth: number,
+ lineHeight: number,
+ maxLines: number
+) {
+ const normalizedText = text.replace(/\s+/g, ' ').trim();
+ const lines: string[] = [];
+ let lineStart = 0;
+
+ while (lineStart < normalizedText.length && lines.length < maxLines) {
+ let lineEnd = lineStart;
+ let lastBreak = -1;
+
+ while (lineEnd < normalizedText.length) {
+ const candidate = normalizedText.slice(lineStart, lineEnd + 1);
+ if (context.measureText(candidate).width > maxWidth) {
+ break;
+ }
+
+ if (isSoftBreakCharacter(normalizedText[lineEnd])) {
+ lastBreak = lineEnd + 1;
+ }
+ lineEnd += 1;
+ }
+
+ if (lineEnd >= normalizedText.length) {
+ lines.push(normalizedText.slice(lineStart).trim());
+ break;
+ }
+
+ const breakAt =
+ lastBreak > lineStart ? lastBreak : Math.max(lineStart + 1, lineEnd);
+ lines.push(normalizedText.slice(lineStart, breakAt).trim());
+ lineStart = breakAt;
+
+ while (normalizedText[lineStart] === ' ') {
+ lineStart += 1;
+ }
+ }
+
+ lines.slice(0, maxLines).forEach((lineText, index) => {
+ const finalLine =
+ index === maxLines - 1 && lineStart < normalizedText.length
+ ? truncateText(context, lineText, maxWidth)
+ : lineText;
+ context.fillText(finalLine, x, y + index * lineHeight);
+ });
+}
+
+function isSoftBreakCharacter(character: string): boolean {
+ return (
+ character === ' ' ||
+ character === '/' ||
+ character === '-' ||
+ character === '_'
+ );
+}
+
+function truncateText(
+ context: CanvasRenderingContext2D,
+ text: string,
+ maxWidth: number
+): string {
+ if (context.measureText(text).width <= maxWidth) {
+ return text;
+ }
+
+ let truncated = text;
+ while (
+ truncated.length > 1 &&
+ context.measureText(`${truncated}...`).width > maxWidth
+ ) {
+ truncated = truncated.slice(0, -1);
+ }
+ return `${truncated}...`;
+}
+
+function roundedRect(
+ context: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ radius: number
+) {
+ context.beginPath();
+ context.moveTo(x + radius, y);
+ context.arcTo(x + width, y, x + width, y + height, radius);
+ context.arcTo(x + width, y + height, x, y + height, radius);
+ context.arcTo(x, y + height, x, y, radius);
+ context.arcTo(x, y, x + width, y, radius);
+ context.closePath();
+}
+
+function withAlpha(color: string, alpha: number): string {
+ if (color.startsWith('#')) {
+ const hex = color.slice(1);
+ const bigint = Number.parseInt(
+ hex.length === 3
+ ? hex
+ .split('')
+ .map((part) => `${part}${part}`)
+ .join('')
+ : hex,
+ 16
+ );
+ const red = (bigint >> 16) & 255;
+ const green = (bigint >> 8) & 255;
+ const blue = bigint & 255;
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
+ }
+
+ return color.startsWith('rgb(')
+ ? color.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`)
+ : color;
+}
+
+function drawImageContain(
+ context: CanvasRenderingContext2D,
+ image: HTMLImageElement,
+ x: number,
+ y: number,
+ width: number,
+ height: number
+) {
+ const scale = Math.min(
+ width / image.naturalWidth,
+ height / image.naturalHeight
+ );
+ const fittedWidth = image.naturalWidth * scale;
+ const fittedHeight = image.naturalHeight * scale;
+ const fittedX = x + (width - fittedWidth) / 2;
+ const fittedY = y + (height - fittedHeight) / 2;
+
+ context.drawImage(image, fittedX, fittedY, fittedWidth, fittedHeight);
+}
+
+function loadImage(src: string): Promise {
+ const cachedImage = imageCache.get(src);
+ if (cachedImage) {
+ return cachedImage;
+ }
+
+ const imagePromise = new Promise((resolve) => {
+ const image = new Image();
+ image.crossOrigin = 'anonymous';
+ image.decoding = 'async';
+ image.onload = () => resolve(image);
+ image.onerror = () => {
+ imageCache.delete(src);
+ resolve(null);
+ };
+ image.src = src;
+ });
+
+ imageCache.set(src, imagePromise);
+ if (imageCache.size > maxCachedImages) {
+ const oldestKey = imageCache.keys().next().value;
+ if (oldestKey) {
+ imageCache.delete(oldestKey);
+ }
+ }
+
+ return imagePromise;
+}
diff --git a/src/utils/warp/scene.ts b/src/utils/warp/scene.ts
new file mode 100644
index 00000000..b819dcce
--- /dev/null
+++ b/src/utils/warp/scene.ts
@@ -0,0 +1,131 @@
+import * as THREE from 'three';
+import type { WarpTheme } from './post-card-texture';
+
+export const WARP_POST_SPACING = 7.5;
+export const WARP_POST_LEAD = 0.72;
+
+export class WarpScene {
+ readonly scene = new THREE.Scene();
+ readonly camera = new THREE.PerspectiveCamera(58, 1, 0.1, 120);
+ readonly renderer: THREE.WebGLRenderer;
+ readonly curve: THREE.CatmullRomCurve3;
+
+ private readonly tube: THREE.Mesh;
+ private readonly curveLength: number;
+ private readonly resizeObserver: ResizeObserver;
+
+ constructor(
+ canvas: HTMLCanvasElement,
+ private readonly host: HTMLElement,
+ theme: WarpTheme
+ ) {
+ this.renderer = new THREE.WebGLRenderer({
+ canvas,
+ antialias: false,
+ alpha: true,
+ powerPreference: 'low-power',
+ });
+ this.renderer.setClearColor(0x000000, 0);
+ this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
+
+ const points = Array.from(
+ { length: 80 },
+ (_, index) =>
+ new THREE.Vector3(
+ Math.sin(index * 0.72) * 1.45,
+ Math.cos(index * 0.43) * 0.48,
+ index * -8
+ )
+ );
+ points[0].set(0, 0, 0);
+ this.curve = new THREE.CatmullRomCurve3(points);
+ this.curveLength = this.curve.getLength();
+
+ const tubeGeometry = new THREE.TubeGeometry(
+ this.curve,
+ 512,
+ 4.4,
+ 24,
+ false
+ );
+ const tubeMaterial = new THREE.MeshBasicMaterial({
+ color: new THREE.Color(theme.primary),
+ wireframe: true,
+ transparent: true,
+ opacity: 0.28,
+ depthTest: false,
+ depthWrite: false,
+ side: THREE.DoubleSide,
+ });
+ this.tube = new THREE.Mesh(tubeGeometry, tubeMaterial);
+ this.tube.renderOrder = 0;
+ this.scene.add(this.tube);
+
+ this.scene.add(new THREE.AmbientLight(0xffffff, 1));
+ this.setCameraT(0);
+
+ this.resizeObserver = new ResizeObserver(() => this.resize());
+ this.resizeObserver.observe(host);
+ this.resize();
+ }
+
+ setCameraT(t: number) {
+ const clampedT = THREE.MathUtils.clamp(t, 0, 0.985);
+ const position = this.curve.getPointAt(clampedT);
+ const lookAt = this.curve.getPointAt(Math.min(clampedT + 0.025, 1));
+
+ this.camera.position.copy(position);
+ this.camera.lookAt(lookAt);
+ }
+
+ setCameraProgressWithLook(progress: number, yaw: number, pitch: number) {
+ const t = (progress * WARP_POST_SPACING) / this.curveLength;
+ const clampedT = THREE.MathUtils.clamp(t, 0, 0.985);
+ const position = this.curve.getPointAt(clampedT);
+ const forward = this.curve.getTangentAt(clampedT).normalize();
+ const worldUp = new THREE.Vector3(0, 1, 0);
+ const right = new THREE.Vector3()
+ .crossVectors(forward, worldUp)
+ .normalize();
+
+ if (right.lengthSq() < 0.001) {
+ right.set(1, 0, 0);
+ }
+
+ const lookDirection = forward
+ .clone()
+ .add(right.multiplyScalar(yaw))
+ .add(worldUp.multiplyScalar(pitch))
+ .normalize();
+
+ this.camera.position.copy(position);
+ this.camera.up.set(0, 1, 0);
+ this.camera.lookAt(position.clone().add(lookDirection));
+ }
+
+ setCameraProgress(progress: number) {
+ this.setCameraProgressWithLook(progress, 0, 0);
+ }
+
+ render() {
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ dispose() {
+ this.resizeObserver.disconnect();
+ this.scene.remove(this.tube);
+ this.tube.geometry.dispose();
+ (this.tube.material as THREE.Material).dispose();
+ this.renderer.dispose();
+ }
+
+ private resize() {
+ const rect = this.host.getBoundingClientRect();
+ const width = Math.max(1, Math.floor(rect.width));
+ const height = Math.max(1, Math.floor(rect.height));
+
+ this.renderer.setSize(width, height, false);
+ this.camera.aspect = width / height;
+ this.camera.updateProjectionMatrix();
+ }
+}