diff --git a/packages/experiments/.env.change b/packages/experiments/.env.change new file mode 100644 index 000000000..c05b556cf --- /dev/null +++ b/packages/experiments/.env.change @@ -0,0 +1,9 @@ +VITE_GA_TRACKING_ID="Google Analytics Tracking ID" +VITE_FIREBASE_API_KEY="Firebase" +VITE_FIREBASE_AUTH_DOMAIN="Firebase" +VITE_FIREBASE_PROJECT_ID="Firebase" +VITE_FIREBASE_STORAGE_BUCKET="Firebase" +VITE_FIREBASE_MESSAGING_SENDER_ID="Firebase" +VITE_FIREBASE_APP_ID="Firebase" +VITE_FIREBASE_MEASUREMENT_ID="Firebase" +VITE_CLOUDFLARE_WORKERS_API_HOST="Workers deployment URL" \ No newline at end of file diff --git a/packages/experiments/.gitignore b/packages/experiments/.gitignore new file mode 100644 index 000000000..aded297df --- /dev/null +++ b/packages/experiments/.gitignore @@ -0,0 +1,8 @@ +node_modules +build +.svelte-kit +.env +/test-results/ +/playwright-report/ +/playwright/.cache/ +/cypress/videos diff --git a/packages/experiments/hooks/server.hooks.ts b/packages/experiments/hooks/server.hooks.ts new file mode 100644 index 000000000..8406b6f9e --- /dev/null +++ b/packages/experiments/hooks/server.hooks.ts @@ -0,0 +1,14 @@ +import type { Handle } from '@sveltejs/kit' + +export const handle: Handle = async ({ event, resolve }) => { + const response = await resolve(event) + response.headers.set('X-Frame-Options', 'DENY') + response.headers.set('X-Content-Type-Options', 'nosniff') + response.headers.set('Referrer-Policy', 'no-referrer') + response.headers.set( + 'Permissions-Policy', + 'autoplay=*, camera=*, microphone=*', + ) + + return response +} diff --git a/packages/experiments/package.json b/packages/experiments/package.json new file mode 100644 index 000000000..320261743 --- /dev/null +++ b/packages/experiments/package.json @@ -0,0 +1,54 @@ +{ + "name": "@hnn/experiments", + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev --host", + "build": "vite build", + "cypress": "npx cypress open", + "preview": "vite preview", + "test": "npx cypress run", + "test:record": "npx cypress run --record", + "test:vi": "npm run test:vi:components && npm run test:vi:e2e", + "test:vi:components": "npx vitest --watch false --coverage --config vitest.config.components.ts", + "test:vi:e2e": "npx vitest --watch false --config vitest.config.e2e.ts", + "build:static": "BUILD_MODE=static vite build", + "preview:static": "BUILD_MODE=static vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json --ignore .svelte-kit,build,tests --threshold warning --diagnostic-sources \"js,ts,svelte\"", + "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", + "tsc": "tsc -b", + "sync": "svelte-kit sync", + "lint": "prettier --check --plugin-search-dir=. . && eslint .", + "format": "prettier --write --plugin-search-dir=. ." + }, + "dependencies": { + "clsx": "^2.0.0", + "hls.js": "^1.4.11", + "idb": "^7.1.1", + "svelte-local-storage-store": "^0.6.0", + "swiper": "^8.4.7", + "throttle-debounce": "^5.0.0" + }, + "devDependencies": { + "@sveltejs/adapter-cloudflare": "^2.3.3", + "@sveltejs/adapter-static": "^2.0.3", + "@sveltejs/kit": "^1.24.0", + "@tailwindcss/forms": "^0.5.6", + "@types/gtag.js": "^0.0.13", + "@types/throttle-debounce": "^5.0.0", + "@types/w3c-image-capture": "^1.0.7", + "autoprefixer": "^10.4.15", + "date-fns": "^2.30.0", + "dotenv": "^16.3.1", + "isomorphic-fetch": "^3.0.0", + "postcss": "^8.4.29", + "svelte": "^3.59.2", + "svelte-check": "^3.5.1", + "svelte-easy-crop": "^2.0.1", + "svelte-preprocess": "^5.0.4", + "tailwindcss": "^3.3.3", + "tslib": "^2.6.2", + "typescript": "^5.2.2", + "vite": "^4.4.9" + } +} diff --git a/packages/experiments/postcss.config.cjs b/packages/experiments/postcss.config.cjs new file mode 100644 index 000000000..33ad091d2 --- /dev/null +++ b/packages/experiments/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/experiments/src/app.d.ts b/packages/experiments/src/app.d.ts new file mode 100644 index 000000000..4f2bd003b --- /dev/null +++ b/packages/experiments/src/app.d.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/// + +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare namespace App { + // interface Locals {} + // interface Platform {} + // interface Session {} + // interface Stuff {} +} + +namespace svelte.JSX { + interface SvelteWindowProps { + onbeforeinstallprompt?: + | EventHandler + | undefined + | null + onappinstalled?: EventHandler | undefined | null + } + + interface HTMLProps { + 'disableremoteplayback'?: boolean + 'disablepictureinpicture'?: boolean + 'x-webkit-airplay'?: 'deny' | 'allow' + } +} + +interface BeforeInstallPromptEvent extends Event { + readonly platforms: string[] + readonly userChoice: Promise<{ + outcome: 'accepted' | 'dismissed' + platform: string + }> + prompt(): Promise +} diff --git a/packages/experiments/src/app.html b/packages/experiments/src/app.html new file mode 100644 index 000000000..0d93d5317 --- /dev/null +++ b/packages/experiments/src/app.html @@ -0,0 +1,25 @@ + + + + + + + + Hot or Not + + + + + %sveltekit.head% + + + %sveltekit.body% + + diff --git a/packages/experiments/src/assets/coins-stash.webp b/packages/experiments/src/assets/coins-stash.webp new file mode 100644 index 000000000..e35b011ad Binary files /dev/null and b/packages/experiments/src/assets/coins-stash.webp differ diff --git a/packages/experiments/src/assets/confetti-background.webp b/packages/experiments/src/assets/confetti-background.webp new file mode 100644 index 000000000..e6d49a2a0 Binary files /dev/null and b/packages/experiments/src/assets/confetti-background.webp differ diff --git a/packages/experiments/src/assets/decore-left.png b/packages/experiments/src/assets/decore-left.png new file mode 100644 index 000000000..2e8a946c9 Binary files /dev/null and b/packages/experiments/src/assets/decore-left.png differ diff --git a/packages/experiments/src/assets/decore-right.png b/packages/experiments/src/assets/decore-right.png new file mode 100644 index 000000000..db16b49ed Binary files /dev/null and b/packages/experiments/src/assets/decore-right.png differ diff --git a/packages/experiments/src/components/avatar/Avatar.svelte b/packages/experiments/src/components/avatar/Avatar.svelte new file mode 100644 index 000000000..2ef1850ce --- /dev/null +++ b/packages/experiments/src/components/avatar/Avatar.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/experiments/src/components/button/Button.svelte b/packages/experiments/src/components/button/Button.svelte new file mode 100644 index 000000000..5d8e0e8ca --- /dev/null +++ b/packages/experiments/src/components/button/Button.svelte @@ -0,0 +1,38 @@ + + +{#if href && !preload} + + + +{:else if href && preload} + + + +{:else} + + + +{/if} diff --git a/packages/experiments/src/components/button/IconButton.svelte b/packages/experiments/src/components/button/IconButton.svelte new file mode 100644 index 000000000..4a3e8ed3a --- /dev/null +++ b/packages/experiments/src/components/button/IconButton.svelte @@ -0,0 +1,53 @@ + + +{#if href && !preload && !disabled} + + + + +{:else if href && preload && !disabled} + + + + +{:else} + + + + +{/if} diff --git a/packages/experiments/src/components/home/Selector.svelte b/packages/experiments/src/components/home/Selector.svelte new file mode 100644 index 000000000..7c377b535 --- /dev/null +++ b/packages/experiments/src/components/home/Selector.svelte @@ -0,0 +1,48 @@ + + + + + + {#if showDot === 'hot-or-not'} + + {:else if showDot === 'videos'} + + {/if} + + (selected = 'hot-or-not')} + class="z-[2]"> + Hot or Not + + (selected = 'videos')} + class="z-[2] flex items-center space-x-2"> + + Home + + + diff --git a/packages/experiments/src/components/layout/CameraLayout.svelte b/packages/experiments/src/components/layout/CameraLayout.svelte new file mode 100644 index 000000000..f3869cd89 --- /dev/null +++ b/packages/experiments/src/components/layout/CameraLayout.svelte @@ -0,0 +1,24 @@ + + + (innerHeight = window?.innerHeight)} /> + + + + + + + + + + + + + + + + diff --git a/packages/experiments/src/components/layout/DotSeparator.svelte b/packages/experiments/src/components/layout/DotSeparator.svelte new file mode 100644 index 000000000..8e2d08ed4 --- /dev/null +++ b/packages/experiments/src/components/layout/DotSeparator.svelte @@ -0,0 +1,5 @@ + + {#each Array(6) as _} + + {/each} + diff --git a/packages/experiments/src/components/layout/HomeLayout.svelte b/packages/experiments/src/components/layout/HomeLayout.svelte new file mode 100644 index 000000000..21be5bab6 --- /dev/null +++ b/packages/experiments/src/components/layout/HomeLayout.svelte @@ -0,0 +1,24 @@ + + + (innerHeight = window?.innerHeight)} /> + + + + + + + + + + diff --git a/packages/experiments/src/components/layout/HotOrNotLayout.svelte b/packages/experiments/src/components/layout/HotOrNotLayout.svelte new file mode 100644 index 000000000..73799752c --- /dev/null +++ b/packages/experiments/src/components/layout/HotOrNotLayout.svelte @@ -0,0 +1,24 @@ + + + (innerHeight = window?.innerHeight)} /> + + + + + + + + + + diff --git a/packages/experiments/src/components/layout/PlayerLayout.svelte b/packages/experiments/src/components/layout/PlayerLayout.svelte new file mode 100644 index 000000000..f70cd5d69 --- /dev/null +++ b/packages/experiments/src/components/layout/PlayerLayout.svelte @@ -0,0 +1,356 @@ + + +{#if showReportPopup} + +{/if} + + + {#if !unavailable} + + {/if} + + + + + + + + + + + {displayName || + generateRandomName('name', post.created_by_user_principal_id)} + + + + {Number(post.total_view_count)} + + + + {#if showDescription} + { + showTruncatedDescription = !showTruncatedDescription + }} + class="pointer-events-auto truncate text-ellipsis whitespace-normal text-left text-sm"> + {post.description} + + {/if} + + + + {#if showReportButton} + { + e.stopImmediatePropagation() + showReportPopup = true + }} /> + {/if} + + {#if showReferAndEarnLink} + + {/if} + {#if showLikeButton} + + { + e.stopImmediatePropagation() + handleLike() + }} /> + + {getShortNumber(Number(post.like_count))} + + + {/if} + {#if showWalletLink} + + {/if} + {#if showShareButton} + { + e.stopImmediatePropagation() + handleShare() + }} /> + {/if} + {#if showHotOrNotButton} + + {/if} + + + {#if $$slots.hotOrNot} + + + + {/if} + + + + + diff --git a/packages/experiments/src/components/layout/ProfileLayout.svelte b/packages/experiments/src/components/layout/ProfileLayout.svelte new file mode 100644 index 000000000..fd72c0d3c --- /dev/null +++ b/packages/experiments/src/components/layout/ProfileLayout.svelte @@ -0,0 +1,26 @@ + + + (innerHeight = window?.innerHeight)} /> + + + + + + + + + + + + + diff --git a/packages/experiments/src/components/layout/SplashScreen.svelte b/packages/experiments/src/components/layout/SplashScreen.svelte new file mode 100644 index 000000000..5d57f45be --- /dev/null +++ b/packages/experiments/src/components/layout/SplashScreen.svelte @@ -0,0 +1,19 @@ + + +{#if $splashScreenPopup.show} + + + + +{/if} diff --git a/packages/experiments/src/components/layout/UploadLayout.svelte b/packages/experiments/src/components/layout/UploadLayout.svelte new file mode 100644 index 000000000..99ab8734f --- /dev/null +++ b/packages/experiments/src/components/layout/UploadLayout.svelte @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/packages/experiments/src/components/navigation/BottomNavigation.svelte b/packages/experiments/src/components/navigation/BottomNavigation.svelte new file mode 100644 index 000000000..241fbf9bc --- /dev/null +++ b/packages/experiments/src/components/navigation/BottomNavigation.svelte @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/experiments/src/components/network-status/NetworkStatus.svelte b/packages/experiments/src/components/network-status/NetworkStatus.svelte new file mode 100644 index 000000000..7920bd091 --- /dev/null +++ b/packages/experiments/src/components/network-status/NetworkStatus.svelte @@ -0,0 +1,30 @@ + + + { + networkStatus = 'offline' + offline = true + }} + on:online={() => { + offline = false + setTimeout(() => (networkStatus = 'online'), 500) + }} /> + +{#if networkStatus === 'offline'} + + {#if offline} + Offline. Please check your internet connection. + {:else} + Online + {/if} + +{/if} diff --git a/packages/experiments/src/components/seo/SEOMetaHeader.svelte b/packages/experiments/src/components/seo/SEOMetaHeader.svelte new file mode 100644 index 000000000..12e8f377a --- /dev/null +++ b/packages/experiments/src/components/seo/SEOMetaHeader.svelte @@ -0,0 +1,18 @@ + + + + {siteTitle} + + + + + + + + + diff --git a/packages/experiments/src/components/switch/Switch.svelte b/packages/experiments/src/components/switch/Switch.svelte new file mode 100644 index 000000000..0c4e3fb13 --- /dev/null +++ b/packages/experiments/src/components/switch/Switch.svelte @@ -0,0 +1,14 @@ + + + (checked = !checked)} + class="flex h-[1.4rem] w-10 items-center justify-start rounded-full transition-all duration-500 {checked + ? 'bg-white' + : 'bg-transparent ring-1 ring-white'}"> + + diff --git a/packages/experiments/src/components/tabs/DotTabs.svelte b/packages/experiments/src/components/tabs/DotTabs.svelte new file mode 100644 index 000000000..74af3b93b --- /dev/null +++ b/packages/experiments/src/components/tabs/DotTabs.svelte @@ -0,0 +1,23 @@ + + + + {#each tabs as tab, i} + {@const active = selectedIndex == i} + (selectedIndex = i)} + on:click + class="relative transition-colors duration-200 {active + ? 'text-white' + : 'text-white/50'}"> + {tab} + {#if active} + + {/if} + + {/each} + diff --git a/packages/experiments/src/components/tabs/ProfileTabs.svelte b/packages/experiments/src/components/tabs/ProfileTabs.svelte new file mode 100644 index 000000000..ed4f5f2e2 --- /dev/null +++ b/packages/experiments/src/components/tabs/ProfileTabs.svelte @@ -0,0 +1,38 @@ + + + + { + history.replaceState(history.state, '', '') + selectedTab = 'posts' + }} + class="flex flex-1 items-center justify-center"> + + + { + history.replaceState(history.state, '', '?tab=speculations') + selectedTab = 'speculations' + }} + class="flex flex-1 items-center justify-center"> + + + + diff --git a/packages/experiments/src/components/tags-input/TagsInput.svelte b/packages/experiments/src/components/tags-input/TagsInput.svelte new file mode 100644 index 000000000..bc251cf0d --- /dev/null +++ b/packages/experiments/src/components/tags-input/TagsInput.svelte @@ -0,0 +1,88 @@ + + + + {#each tags as tag} + + + #{tag} + + removeTag(tag)} /> + + {/each} + {#if showError} + + Max hashtags number reached + + {:else} + + {/if} + diff --git a/packages/experiments/src/components/tooltip/Tooltip.svelte b/packages/experiments/src/components/tooltip/Tooltip.svelte new file mode 100644 index 000000000..6683a496a --- /dev/null +++ b/packages/experiments/src/components/tooltip/Tooltip.svelte @@ -0,0 +1,47 @@ + + + (show = false)} + class="relative flex w-min cursor-pointer"> + {#if show} + + + {text} + + + + {/if} + (show = !show)}> + + + diff --git a/packages/experiments/src/components/up-down/UpDownVote.svelte b/packages/experiments/src/components/up-down/UpDownVote.svelte new file mode 100644 index 000000000..6f5c393dc --- /dev/null +++ b/packages/experiments/src/components/up-down/UpDownVote.svelte @@ -0,0 +1,22 @@ + + + + + + {#if voteDetails} + + {:else} + (voteDetails = detail)} /> + {/if} + diff --git a/packages/experiments/src/components/up-down/UpDownVoteControls.svelte b/packages/experiments/src/components/up-down/UpDownVoteControls.svelte new file mode 100644 index 000000000..eb1839e5b --- /dev/null +++ b/packages/experiments/src/components/up-down/UpDownVoteControls.svelte @@ -0,0 +1,102 @@ + + + + + (selectedCoins = 10)} + class:bg-primary={selectedCoins === 10} + class="flex flex-nowrap items-center gap-1 rounded-lg p-3"> + + 10 COYNs + + (selectedCoins = 50)} + class:bg-primary={selectedCoins === 50} + class="flex flex-nowrap items-center gap-1 rounded-lg p-3"> + + 50 COYNs + + (selectedCoins = 100)} + class:bg-primary={selectedCoins === 100} + class="flex flex-nowrap items-center gap-1 rounded-lg p-3"> + + 100 COYNs + + + + placeVote('down', selectedCoins)} + class="flex w-24 flex-col items-center justify-center gap-1 self-stretch rounded-md bg-red-500"> + {#if vote.loading && vote.direction === 'down'} + + {:else} + + + + Down + {/if} + + + + 100 + + + Current scrore + + + placeVote('up', selectedCoins)} + class="flex w-24 flex-col items-center justify-center gap-1 self-stretch rounded-md bg-green-500 py-3"> + {#if vote.loading && vote.direction === 'up'} + + {:else} + + + + Up + {/if} + + + diff --git a/packages/experiments/src/components/up-down/UpDownVoteOutcome.svelte b/packages/experiments/src/components/up-down/UpDownVoteOutcome.svelte new file mode 100644 index 000000000..7474abee7 --- /dev/null +++ b/packages/experiments/src/components/up-down/UpDownVoteOutcome.svelte @@ -0,0 +1,85 @@ + + + + + + + + {voteDetails.direction} + + + + + + {voteDetails.coins} + + + + {#if $timeLeft} + + + + {$timeLeft} + + Your vote has been placed + + {/if} + diff --git a/packages/experiments/src/components/upload/UploadStep.svelte b/packages/experiments/src/components/upload/UploadStep.svelte new file mode 100644 index 000000000..5a07b7ba1 --- /dev/null +++ b/packages/experiments/src/components/upload/UploadStep.svelte @@ -0,0 +1,23 @@ + + + + {#if status !== 'finished'} + {step} + {:else} + + {/if} + diff --git a/packages/experiments/src/components/upload/UploadTypes.ts b/packages/experiments/src/components/upload/UploadTypes.ts new file mode 100644 index 000000000..b8d3fa136 --- /dev/null +++ b/packages/experiments/src/components/upload/UploadTypes.ts @@ -0,0 +1,12 @@ +import type { FacingMode } from '$lib/helpers/camera' + +export interface CameraControls { + flash: 'flash-fill' | 'flash' | 'flash-not-available' | 'hide' + flip: { + facingMode: FacingMode + show: boolean + } + timer: 'off' | '5s' | '10s' +} + +export type UploadStatus = 'to-upload' | 'uploading' | 'uploaded' diff --git a/packages/experiments/src/components/video/VideoPlayer.svelte b/packages/experiments/src/components/video/VideoPlayer.svelte new file mode 100644 index 000000000..6021ff3b8 --- /dev/null +++ b/packages/experiments/src/components/video/VideoPlayer.svelte @@ -0,0 +1,268 @@ + + + { + waiting = true + }} + on:playing={() => { + waiting = false + loaded = true + }} + on:canplay={() => { + loaded = true + }} + on:pause={() => { + inView && play() + }} + bind:this={videoEl} + loop + data-index={index} + muted={$playerState.muted} + disablepictureinpicture + disableremoteplayback + playsinline + bind:currentTime + bind:duration + preload={ios ? 'metadata' : 'auto'} + poster={thumbnail} + class="object-fit absolute z-[3] h-full w-full" /> + +{#if videoUnavailable} + + + Video unavailable + + This video was removed due to content policy ToS + + +{:else if $playerState.muted || !playing} + + + {#if !playing} + + {:else if $playerState.muted} + + {/if} + + +{/if} + +{#if !loaded || waiting} + +{/if} diff --git a/packages/experiments/src/components/vote-result/VoteLost.svelte b/packages/experiments/src/components/vote-result/VoteLost.svelte new file mode 100644 index 000000000..f35f6fe95 --- /dev/null +++ b/packages/experiments/src/components/vote-result/VoteLost.svelte @@ -0,0 +1,25 @@ + + + + + Uh-Oh! You lost + + You lost 50 tokens on the vote you placed on video “Dance with me” + + + Your rank + 220 + + + Place a new vote + Check balance + + diff --git a/packages/experiments/src/components/vote-result/VoteWon.svelte b/packages/experiments/src/components/vote-result/VoteWon.svelte new file mode 100644 index 000000000..fb8355b76 --- /dev/null +++ b/packages/experiments/src/components/vote-result/VoteWon.svelte @@ -0,0 +1,29 @@ + + + + + + + + Congrats! You win + + You won 50 tokens on the vote you placed on video “Dance with me” + + + Your rank + 220 + + + Place a new vote + Check balance + + diff --git a/packages/experiments/src/components/wallet/TransactionItem.svelte b/packages/experiments/src/components/wallet/TransactionItem.svelte new file mode 100644 index 000000000..c58e9a1e9 --- /dev/null +++ b/packages/experiments/src/components/wallet/TransactionItem.svelte @@ -0,0 +1,88 @@ + + + + + + + + + + + {eventName} + + {#if hrefTypeEl} + View Post + {:else} + {item.token} Coins + {/if} + • + {timeDiff} + + + + {#if item.eventOutcome !== 'Lost'} + + {item.eventOutcome === 'Draw' ? '←' : deducted ? '-' : '+'} + {item.token} + + {/if} + diff --git a/packages/experiments/src/css/app.css b/packages/experiments/src/css/app.css new file mode 100644 index 000000000..bdf2d367e --- /dev/null +++ b/packages/experiments/src/css/app.css @@ -0,0 +1,55 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.hide-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.hide-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +.breathe { + animation-name: breathe; + animation-duration: 2s; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + animation-play-state: running; +} + +@keyframes breathe { + 0% { + transform: scale(1); + } + + 50% { + transform: scale(0.75); + } + 100% { + transform: scale(1); + } +} + +.fade-in { + animation: fadeIn 200ms; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.safe-bottom { + padding-bottom: env(safe-area-inset-bottom); +} + +button { + -webkit-tap-highlight-color: transparent; +} diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.eot b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.eot new file mode 100644 index 000000000..6c4c21f0b Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.eot differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.svg b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.svg new file mode 100644 index 000000000..9efe8abf6 --- /dev/null +++ b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.svg @@ -0,0 +1,334 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.ttf b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.ttf new file mode 100644 index 000000000..8ea60a07f Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.ttf differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.woff b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.woff new file mode 100644 index 000000000..7aebae262 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.woff differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.woff2 b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.woff2 new file mode 100644 index 000000000..757a28fa5 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-500.woff2 differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.eot b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.eot new file mode 100644 index 000000000..81046dd46 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.eot differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.svg b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.svg new file mode 100644 index 000000000..6f3520687 --- /dev/null +++ b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.svg @@ -0,0 +1,332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.ttf b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.ttf new file mode 100644 index 000000000..561769959 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.ttf differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.woff b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.woff new file mode 100644 index 000000000..7b5ee3cb8 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.woff differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.woff2 b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.woff2 new file mode 100644 index 000000000..04145e370 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-600.woff2 differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.eot b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.eot new file mode 100644 index 000000000..b5b674ac6 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.eot differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.svg b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.svg new file mode 100644 index 000000000..c63e6e6c5 --- /dev/null +++ b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.svg @@ -0,0 +1,330 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.ttf b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.ttf new file mode 100644 index 000000000..e1f80a2de Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.ttf differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.woff b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.woff new file mode 100644 index 000000000..3ce021209 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.woff differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.woff2 b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.woff2 new file mode 100644 index 000000000..9ad542247 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-700.woff2 differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.eot b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.eot new file mode 100644 index 000000000..8b2188b76 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.eot differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.svg b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.svg new file mode 100644 index 000000000..b52c94b05 --- /dev/null +++ b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.svg @@ -0,0 +1,335 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.ttf b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.ttf new file mode 100644 index 000000000..3d0bb15a0 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.ttf differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.woff b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.woff new file mode 100644 index 000000000..8b5d75656 Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.woff differ diff --git a/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.woff2 b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.woff2 new file mode 100644 index 000000000..407a66b8a Binary files /dev/null and b/packages/experiments/src/css/fonts/kumbh-sans-v12-latin-regular.woff2 differ diff --git a/packages/experiments/src/env.d.ts b/packages/experiments/src/env.d.ts new file mode 100644 index 000000000..5ff544183 --- /dev/null +++ b/packages/experiments/src/env.d.ts @@ -0,0 +1,3 @@ +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/packages/experiments/src/lib/helpers/airdrop.ts b/packages/experiments/src/lib/helpers/airdrop.ts new file mode 100644 index 000000000..7ba6d9090 --- /dev/null +++ b/packages/experiments/src/lib/helpers/airdrop.ts @@ -0,0 +1,46 @@ +export async function airdropEntryDetails(principalId: string) { + try { + const res = await fetch( + `https://getairdropentrydetails-5nps3y6y6a-uc.a.run.app?principalId=${principalId}`, + { + mode: 'cors', + }, + ) + + const body = await res.json() + + if (body.success && body.exists) { + return body.doc as { + FinalCOYNWalletBalance: string + FinalHotTokens: number + splitNeuronId?: string + nnsId?: string + } + } + return false + } catch (e) { + console.error('Error adding document: ', e) + return false + } +} + +export async function isNNSIdRegistered( + principalId: string, +): Promise { + try { + const res = await fetch( + `https://isregisteredfornns-5nps3y6y6a-uc.a.run.app?principalId=${principalId}`, + { + mode: 'cors', + }, + ) + + const body = await res.json() + + if (body.success && body.exists) return body.exists as string + return false + } catch (e) { + console.error('Error adding document: ', e) + return false + } +} diff --git a/packages/experiments/src/lib/helpers/auth.ts b/packages/experiments/src/lib/helpers/auth.ts new file mode 100644 index 000000000..7a9230e1a --- /dev/null +++ b/packages/experiments/src/lib/helpers/auth.ts @@ -0,0 +1,178 @@ +import Log from '$lib/utils/Log' +import { AuthClient } from '@dfinity/auth-client' +import { get } from 'svelte/store' +import { authState, authHelper, referralId } from '$stores/auth' +import { updateProfile } from './profile' +import { loadingAuthStatus } from '$stores/loading' +import { Principal } from '@dfinity/principal' +import { userIndex } from './backend' +import { checkSignupStatusCanister } from './signup' + +async function logout() { + const authHelperState = get(authHelper) + await authHelperState.client?.logout() + const identity = authHelperState.client?.getIdentity() + const principal = await identity?.getPrincipal() + authState.set({ + isLoggedIn: false, + idString: principal?.toText(), + showLogin: false, + }) +} + +async function updateUserIndexCanister(): Promise<{ + error: boolean + new_user: boolean + referral?: string + error_details?: 'SIGNUP_NOT_ALLOWED' | 'OTHER' +}> { + try { + let new_user = false + let userCanisterPrincipal: Principal + + const authStateData = get(authState) + const referralStore = get(referralId) + + const res = await userIndex().get_user_canister_id_from_user_principal_id( + Principal.from(authStateData.idString), + ) + + if (res[0]) { + //existing user + userCanisterPrincipal = res[0] + new_user = false + } else { + // new user + const isSignupAllowed = await checkSignupStatusCanister() + if (!isSignupAllowed) { + return { + error: true, + error_details: 'SIGNUP_NOT_ALLOWED', + + new_user: true, + } + } else { + new_user = true + const referral: [] | [Principal] = referralStore.principalId + ? [Principal.from(referralStore.principalId)] + : [] + userCanisterPrincipal = + await userIndex().get_requester_principals_canister_id_create_if_not_exists_and_optionally_allow_referrer( + referral, + ) + } + } + + Log('info', 'Update user index canister info', { + userCanisterPrincipal: userCanisterPrincipal?.toText(), + from: 'auth.updateUserIndexCanister', + }) + + const authHelperData = get(authHelper) + authHelper.set({ + ...authHelperData, + userCanisterPrincipal, + }) + authState.set({ + ...authStateData, + userCanisterId: userCanisterPrincipal?.toText(), + }) + if ( + authStateData.isLoggedIn && + authStateData.idString && + userCanisterPrincipal + ) { + try { + const { idb } = await import('$lib/idb') + idb.set( + 'canisters', + authStateData.idString, + userCanisterPrincipal.toText(), + ) + } catch (e) { + Log('error', 'Error while accessing IDB', { + error: e, + source: 'auth.updateUserIndexCanister', + type: 'idb', + }) + return { error: false, new_user, referral: referralStore.principalId } + } + } + + return { error: false, new_user, referral: referralStore.principalId } + } catch (e) { + const authFailed = (e as any)?.message?.includes?.('Failed to authenticate') + if (authFailed) { + await logout() + } else { + Log('error', 'Failed to authenticate', { + error: e, + from: 'auth.updateUserIndexCanister', + }) + } + return { error: true, error_details: 'OTHER', new_user: false } + } +} + +export async function initializeAuthClient(): Promise<{ + error: boolean + new_user: boolean + referral?: string +} | void> { + loadingAuthStatus.set(true) + const authStateData = get(authState) + const authHelperData = get(authHelper) + let client: AuthClient | undefined = undefined + if (!authHelperData.client) { + client = await AuthClient.create({ + idleOptions: { + disableIdle: true, + disableDefaultIdleCallback: true, + }, + }) + } else { + client = authHelperData.client + } + const identity = client?.getIdentity() + const principal = await identity?.getPrincipal() + if (await client?.isAuthenticated()) { + authState.set({ + userCanisterId: authStateData.userCanisterId, + isLoggedIn: true, + idString: principal?.toText(), + showLogin: authStateData.showLogin, + }) + + authHelper.set({ + client, + userCanisterPrincipal: authHelperData.userCanisterPrincipal, + identity, + idPrincipal: principal, + }) + + const res = await updateUserIndexCanister() + if (res.error && res.error_details === 'SIGNUP_NOT_ALLOWED') { + loadingAuthStatus.set(false) + return { error: true, new_user: true } + } + await updateProfile() + loadingAuthStatus.set(false) + + return { error: false, new_user: res.new_user, referral: res.referral } + } else { + authState.set({ + isLoggedIn: false, + idString: principal?.toText(), + showLogin: authStateData.showLogin, + }) + + authHelper.set({ + client, + identity, + idPrincipal: principal, + }) + + await updateProfile() + loadingAuthStatus.set(false) + } +} diff --git a/packages/experiments/src/lib/helpers/backend.ts b/packages/experiments/src/lib/helpers/backend.ts new file mode 100644 index 000000000..bf6ac720f --- /dev/null +++ b/packages/experiments/src/lib/helpers/backend.ts @@ -0,0 +1,70 @@ +import { + createActor as createUserIndexActor, + canisterId as userIndexCanisterId, +} from '$canisters/user_index' +import { createActor as createIndividualUserActor } from '$canisters/individual_user_template' +import { + createActor as createPostCacheActor, + canisterId as postCacheCanisterId, +} from '$canisters/post_cache' + +import { + createActor as createConfigurationActor, + canisterId as configurationCanisterId, +} from '$canisters/configuration' + +import type { _SERVICE as _USER_INDEX_SERVICE } from '$canisters/user_index/user_index.did' +import type { _SERVICE as _INDIVIDUAL_USER_SERVICE } from '$canisters/individual_user_template/individual_user_template.did' +import type { _SERVICE as _POST_CACHE_SERVICE } from '$canisters/post_cache/post_cache.did' +import type { _SERVICE as _CONFIGURATION_SERVICE } from '$canisters/configuration/configuration.did' +import { authHelper, authState } from '$stores/auth' +import type { ActorSubclass } from '@dfinity/agent' +import { get } from 'svelte/store' +import { Principal } from '@dfinity/principal' + +export const host = + import.meta.env.NODE_ENV === 'development' + ? 'http://localhost:4943' + : 'https://ic0.app' + +export type UserIndexActor = ActorSubclass<_USER_INDEX_SERVICE> +export type IndividualUserActor = ActorSubclass<_INDIVIDUAL_USER_SERVICE> +export type PostCacheActor = ActorSubclass<_POST_CACHE_SERVICE> +export type ConfigurationActor = ActorSubclass<_CONFIGURATION_SERVICE> + +export function userIndex(fetch?: any): UserIndexActor { + const authHelperData = get(authHelper) + return createUserIndexActor(userIndexCanisterId as string, { + agentOptions: { identity: authHelperData?.identity, host, fetch }, + }) as UserIndexActor +} + +export function individualUser( + principal?: Principal | string, + fetch?: any, +): IndividualUserActor { + const authHelperData = get(authHelper) + const authStateData = get(authState) + const canisterId = principal + ? principal instanceof Principal + ? principal + : Principal.from(principal) + : (authStateData.userCanisterId as string) + if (!canisterId) throw "Can't find canisterId" + return createIndividualUserActor(canisterId, { + agentOptions: { identity: authHelperData?.identity, host, fetch }, + }) as IndividualUserActor +} + +export function postCache(fetch?: any): PostCacheActor { + const authHelperData = get(authHelper) + return createPostCacheActor(postCacheCanisterId as string, { + agentOptions: { identity: authHelperData?.identity, host, fetch }, + }) as PostCacheActor +} + +export function configuration(fetch?: any): ConfigurationActor { + return createConfigurationActor(configurationCanisterId, { + agentOptions: { host, fetch }, + }) as ConfigurationActor +} diff --git a/packages/experiments/src/lib/helpers/camera.ts b/packages/experiments/src/lib/helpers/camera.ts new file mode 100644 index 000000000..ba0c6ace5 --- /dev/null +++ b/packages/experiments/src/lib/helpers/camera.ts @@ -0,0 +1,67 @@ +import { browser } from '$app/environment' + +type CameraPermissionRequest = { + stream?: MediaStream + error: 'none' | 'denied' | 'no-stream' +} + +type DevicesListRequest = { + videoDevices?: MediaDeviceInfo[] + error: 'none' | 'denied' | 'no-stream' +} + +export type FacingMode = 'user' | 'environment' + +export async function getMediaStream( + facingMode: FacingMode, +): Promise { + if ( + browser && + 'mediaDevices' in navigator && + 'getUserMedia' in navigator.mediaDevices + ) { + try { + let stream: MediaStream | undefined = undefined + stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: { facingMode }, + }) + return { stream, error: 'none' } + } catch (err) { + return { error: 'denied' } + } + } else return { error: 'no-stream' } +} + +export async function getDevicesList(): Promise { + if ( + browser && + 'mediaDevices' in navigator && + 'getUserMedia' in navigator.mediaDevices + ) { + try { + const devices = await navigator.mediaDevices.enumerateDevices() + const videoDevices = devices.filter( + (device) => device.kind === 'videoinput', + ) + return { videoDevices, error: 'none' } + } catch (err) { + return { error: 'denied' } + } + } else { + return { error: 'no-stream' } + } +} + +export async function applyConstraintsOnVideoStream( + stream: MediaStream, + constraints: MediaTrackConstraints, +) { + try { + const track = stream.getVideoTracks()[0] + await track.applyConstraints(constraints) + return true + } catch (_) { + return false + } +} diff --git a/packages/experiments/src/lib/helpers/canisterId.ts b/packages/experiments/src/lib/helpers/canisterId.ts new file mode 100644 index 000000000..7a89d1fb1 --- /dev/null +++ b/packages/experiments/src/lib/helpers/canisterId.ts @@ -0,0 +1,49 @@ +import Log from '$lib/utils/Log' +import { Principal } from '@dfinity/principal' +import { isPrincipal } from '$lib/utils/isPrincipal' +import { userIndex } from './backend' +import type { IDB } from '$lib/idb' + +export async function getCanisterId(id: string): Promise { + try { + let canId: string | undefined = undefined + let idb: IDB | undefined = undefined + try { + idb = await (await import('$lib/idb')).idb + canId = await idb.get('canisters', id) + } catch (e) { + Log('error', 'Error while accessing IDB', { + error: e, + from: 'canisterId.getCanisterId', + type: 'idb', + }) + return + } + if (canId) return canId + else { + if (isPrincipal(id)) { + const res = + await userIndex().get_user_canister_id_from_user_principal_id( + Principal.from(id), + ) + if (res[0]) { + idb?.set('canisters', id, res[0].toString()) + return res[0].toString() + } + } else { + const res = + await userIndex().get_user_canister_id_from_unique_user_name(id) + if (res[0]) { + idb?.set('canisters', id, res[0].toString()) + return res[0].toString() + } + } + } + } catch (e) { + Log('error', 'Error while fetching canisterId', { + error: e, + from: 'canisterId.getCanisterId', + }) + return + } +} diff --git a/packages/experiments/src/lib/helpers/feed.ts b/packages/experiments/src/lib/helpers/feed.ts new file mode 100644 index 000000000..e060d0ee0 --- /dev/null +++ b/packages/experiments/src/lib/helpers/feed.ts @@ -0,0 +1,335 @@ +import type { PostDetailsForFrontend } from '$canisters/individual_user_template/individual_user_template.did' +import type { + PostScoreIndexItem, + TopPostsFetchError, +} from '$canisters/post_cache/post_cache.did' +import type { IDB } from '$lib/idb' +import Log from '$lib/utils/Log' +import { Principal } from '@dfinity/principal' +import { individualUser, postCache } from './backend' + +export interface PostPopulated + extends Omit, + Omit { + created_by_user_principal_id: string + publisher_canister_id: string +} + +let idb: IDB | null = null + +export interface PostPopulatedHistory extends PostPopulated { + watched_at: number +} + +export type FeedResponse = + | { + posts: PostPopulated[] + error: false + from: number + noMorePosts: boolean + } + | { + error: true + } + +async function filterPosts( + posts: PostScoreIndexItem[], + dbStore: 'watch' | 'watch-hon', +): Promise { + try { + if (!idb) { + idb = (await import('$lib/idb')).idb + } + const keys = (await idb.keys(dbStore)) as string[] + if (!keys?.length) return posts + const filtered = posts.filter( + (o) => !keys.includes(o.publisher_canister_id.toText() + '@' + o.post_id), + ) + return filtered + } catch (e) { + Log('error', 'Error while accessing IDB', { + error: e, + from: 'feed.filterPosts', + type: 'idb', + }) + return posts + } +} + +async function filterReportedPosts(posts: PostScoreIndexItem[]) { + try { + if (!idb) { + idb = (await import('$lib/idb')).idb + } + const keys = (await idb.keys('reported')) as string[] + if (!keys.length) return posts + const filtered = posts.filter( + (o) => !keys.includes(o.publisher_canister_id.toText() + '@' + o.post_id), + ) + return filtered + } catch (e) { + Log('error', 'Error while accessing IDB', { + error: e, + from: 'feed.filterReportedPosts', + type: 'idb', + }) + return posts + } +} + +export async function getWatchedVideosFromCache( + dbStore: 'watch' | 'watch-hon', +): Promise { + try { + if (!idb) { + idb = (await import('$lib/idb')).idb + } + const values = ((await idb.values(dbStore)) || []).slice( + 50, + ) as PostPopulatedHistory[] + if (!values.length) return [] + const sorted = values.sort((a, b) => a.watched_at - b.watched_at) + return sorted + } catch (e) { + Log('error', 'Error while accessing IDB', { + error: e, + from: 'feed.getWatchedVideosFromCache', + type: 'idb', + }) + return [] + } +} + +export async function getTopPosts( + from: number, + numberOfPosts: number = 10, + filterViewed = false, +): Promise { + try { + const res = + await postCache().get_top_posts_aggregated_from_canisters_on_this_network_for_home_feed( + BigInt(from), + BigInt(from + numberOfPosts), + ) + if ('Ok' in res) { + const filteredReportedPosts = await filterReportedPosts(res.Ok) + const filteredPosts = await filterPosts(filteredReportedPosts, 'watch') + const populatedRes = await populatePosts( + filterViewed ? filteredPosts : filteredReportedPosts, + ) + if (populatedRes.error) { + throw new Error( + `Error while populating, ${JSON.stringify(populatedRes)}`, + ) + } + return { + error: false, + from: from + res.Ok.length, + posts: populatedRes.posts, + noMorePosts: res.Ok.length < numberOfPosts, + } + } else if ('Err' in res) { + type UnionKeyOf = U extends U ? keyof U : never + type errors = UnionKeyOf + const err = Object.keys(res.Err)[0] as errors + switch (err) { + case 'InvalidBoundsPassed': + case 'ExceededMaxNumberOfItemsAllowedInOneRequest': + return { error: true } + case 'ReachedEndOfItemsList': + return { error: false, noMorePosts: true, from, posts: [] } + } + } else throw new Error(`Unknown response, ${JSON.stringify(res)}`) + } catch (e) { + Log('error', 'Error while loading posts', { + error: e, + from: 'feed.getTopPosts', + }) + return { error: true } + } +} + +async function filterBets( + posts: PostScoreIndexItem[], +): Promise { + try { + if (!idb) { + idb = (await import('$lib/idb')).idb + } + const keys = (await idb.keys('bets')) as string[] + if (!keys?.length) return posts + const filtered = posts.filter( + (o) => !keys.includes(o.publisher_canister_id.toText() + '@' + o.post_id), + ) + return filtered + } catch (e) { + Log('error', 'Error while accessing IDB', { + error: e, + from: 'feed.filterPosts', + type: 'idb', + }) + return posts + } +} + +export async function getHotOrNotPosts( + from: number, + numberOfPosts: number = 10, +): Promise { + try { + const res = + await postCache().get_top_posts_aggregated_from_canisters_on_this_network_for_hot_or_not_feed( + BigInt(from), + BigInt(from + numberOfPosts), + ) + if ('Ok' in res) { + const filteredNonBetPosts = await filterBets(res.Ok) + const filteredReportedPosts = await filterReportedPosts( + filteredNonBetPosts, + ) + // const filteredPosts = await filterPosts(filteredNonBetPosts, 'watch-hon') + const populatedRes = await populatePosts(filteredReportedPosts, true) + if (populatedRes.error) { + throw new Error( + `Error while populating, ${JSON.stringify(populatedRes)}`, + ) + } + return { + error: false, + from: from + res.Ok.length, + posts: populatedRes.posts, + noMorePosts: res.Ok.length < numberOfPosts, + } + } else if ('Err' in res) { + type UnionKeyOf = U extends U ? keyof U : never + type errors = UnionKeyOf + const err = Object.keys(res.Err)[0] as errors + switch (err) { + case 'InvalidBoundsPassed': + case 'ExceededMaxNumberOfItemsAllowedInOneRequest': + return { error: true } + case 'ReachedEndOfItemsList': + return { error: false, noMorePosts: true, from, posts: [] } + } + } else throw new Error(`Unknown response, ${JSON.stringify(res)}`) + } catch (e) { + Log('error', 'Error while loading posts', { + error: e, + from: 'feed.getHotOrNotPosts', + }) + return { error: true } + } +} + +export function isBettingClosed(post: PostDetailsForFrontend) { + const bettingStatus = post.hot_or_not_betting_status?.[0] + const bettingStatusValue = Object.values(bettingStatus || {})?.[0] + if (!bettingStatusValue) { + return true + } + const betWillCloseAt = new Date( + Number(bettingStatusValue.started_at.secs_since_epoch) * 1000, + ) + betWillCloseAt.setHours(betWillCloseAt.getHours() + 48) + if (betWillCloseAt.getTime() - new Date().getTime() > 0) { + return false + } + return true +} + +function hasUserBetOnPost(post: PostDetailsForFrontend) { + const bettingStatus = post.hot_or_not_betting_status?.[0] + const bettingStatusValue = Object.values(bettingStatus || {})?.[0] + + if (!bettingStatusValue) { + return true + } + if (bettingStatusValue.has_this_user_participated_in_this_post[0]) { + return true + } + return false +} + +async function populatePosts( + posts: PostScoreIndexItem[], + filterBetPosts = false, +) { + try { + if (!posts.length) { + return { posts: [], error: false } + } + + const res = await Promise.all( + posts.map(async (post) => { + try { + const r = await individualUser( + Principal.from(post.publisher_canister_id), + ).get_individual_post_details_by_id(post.post_id) + if (filterBetPosts && (hasUserBetOnPost(r) || isBettingClosed(r))) { + return undefined + } + return { + ...r, + ...post, + created_by_user_principal_id: + r.created_by_user_principal_id.toText(), + publisher_canister_id: post.publisher_canister_id.toText(), + } + } catch (_) { + return undefined + } + }), + ) + return { posts: res.filter((o) => !!o) as PostPopulated[], error: false } + } catch (e) { + Log('error', 'Error while loading posts', { + error: e, + from: 'feed.populatePosts', + }) + return { error: true, posts: [] } + } +} + +export async function updatePostInWatchHistory( + store: 'watch-hon' | 'watch', + post: PostPopulated, + update?: Partial, +) { + if (!post) return + const postHistory: PostPopulatedHistory = { + ...post, + ...update, + watched_at: Date.now(), + } + try { + if (!idb) { + idb = (await import('$lib/idb')).idb + } + await idb.set( + store, + post.publisher_canister_id + '@' + post.post_id, + postHistory, + ) + } catch (e) { + Log('error', 'Error while accessing IDB', { + error: e, + from: 'feed.updatePostInWatchHistory', + type: 'idb', + }) + } +} + +export async function saveReportedPostInDb(postId: string, reason: string) { + try { + if (!idb) { + idb = (await import('$lib/idb')).idb + } + await idb.set('reported', postId, reason) + } catch (e) { + Log('error', 'Error while accessing IDB', { + error: e, + from: 'feed.saveReportedPostInDb', + type: 'idb', + }) + } +} diff --git a/packages/experiments/src/lib/helpers/image.ts b/packages/experiments/src/lib/helpers/image.ts new file mode 100644 index 000000000..0c5bfc247 --- /dev/null +++ b/packages/experiments/src/lib/helpers/image.ts @@ -0,0 +1,63 @@ +import Log from '$lib/utils/Log' +import { authState } from '$stores/auth' +import { get } from 'svelte/store' + +const cfWorkerHost = import.meta.env.VITE_CLOUDFLARE_WORKERS_API_HOST + +async function generateUrl() { + try { + const authStateData = get(authState) + const res = await fetch(`${cfWorkerHost}/image/getImageUploadURL`, { + method: 'POST', + body: JSON.stringify({ + principalId: authStateData.idString || '', + fileName: Date.now().toString(), + }), + }) + const body = await res.json() + Log('info', 'Generating video upload URL', { + body, + from: 'image.generateUrl', + }) + if (body.success) { + return body.result as { uploadURL: string; id: string } + } else { + return + } + } catch (e) { + Log('error', 'Could not generate video upload URL', { + error: e, + from: 'image.generateUrl', + }) + return + } +} + +export async function uploadProfilePicture(file: Blob | File) { + const uploadRes = await generateUrl() + if (!uploadRes || !uploadRes.uploadURL) { + return + } + const formData = new FormData() + formData.append('file', file) + try { + const res = await fetch(uploadRes.uploadURL, { + method: 'POST', + body: formData, + }) + const body = await res.json() + Log('info', 'Uploading profile picture', { + body, + from: 'image.uploadProfilePicture', + }) + if (body.success) { + return body.result.variants[0] as string + } + } catch (e) { + Log('error', 'Could not upload profile picture', { + error: e, + from: 'image.uploadProfilePicture', + }) + return + } +} diff --git a/packages/experiments/src/lib/helpers/profile.ts b/packages/experiments/src/lib/helpers/profile.ts new file mode 100644 index 000000000..fa832e5df --- /dev/null +++ b/packages/experiments/src/lib/helpers/profile.ts @@ -0,0 +1,630 @@ +import type { + FollowEntryDetail, + BetOutcomeForBetMaker, + GetPostsOfUserProfileError, + MintEvent, + PlacedBetDetail, + PostDetailsForFrontend, + SystemTime, + TokenEvent, + UserProfileDetailsForFrontend, +} from '$canisters/individual_user_template/individual_user_template.did' +import { setUserProperties } from '$components/analytics/GA.svelte' +import getDefaultImageUrl from '$lib/utils/getDefaultImageUrl' +import Log from '$lib/utils/Log' +import { generateRandomName } from '$lib/utils/randomUsername' +import { authState } from '$stores/auth' +import userProfile, { + emptyProfileValues, + type UserProfile, +} from '$stores/userProfile' +import { Principal } from '@dfinity/principal' +import { get } from 'svelte/store' +import { individualUser } from './backend' +import { getCanisterId } from './canisterId' +import type { PostPopulated } from './feed' +import { setUser } from './sentry' +import { isPrincipal } from '$lib/utils/isPrincipal' + +export interface UserProfileFollows extends UserProfile { + i_follow: boolean + index_id: bigint +} + +export interface PostPopulatedWithBetDetails extends PostPopulated { + placed_bet_details: PlacedBetDetail +} + +async function fetchProfile() { + try { + return await individualUser().get_profile_details() + } catch (e) { + Log('error', 'Could not fetch user profile', { + error: e, + from: 'profile.fetchProfile', + }) + } +} + +export function sanitizeProfile( + profile: UserProfileDetailsForFrontend, + userId: string, +): UserProfile { + return { + username_set: !!profile.unique_user_name[0], + unique_user_name: + profile.unique_user_name[0] || generateRandomName('username', userId), + profile_picture_url: + profile.profile_picture_url[0] || getDefaultImageUrl(userId), + display_name: profile.display_name[0] || generateRandomName('name', userId), + principal_id: profile.principal_id.toText(), + followers_count: Number(profile.followers_count), + following_count: Number(profile.following_count), + profile_stats: { + hots_earned_count: Number(profile.profile_stats.hot_bets_received) || 0, + nots_earned_count: Number(profile.profile_stats.not_bets_received) || 0, + lifetime_earnings: Number(profile.lifetime_earnings) || 0, + }, + updated_at: Date.now(), + } +} + +export async function updateProfile(profile?: UserProfileDetailsForFrontend) { + const authStateData = get(authState) + if (authStateData.isLoggedIn) { + const updateProfile = profile || (await fetchProfile()) + if (updateProfile) { + userProfile.set({ + ...sanitizeProfile(updateProfile, authStateData.idString || 'random'), + }) + if (updateProfile.unique_user_name[0]) { + try { + const { idb } = await import('$lib/idb') + idb.set( + 'canisters', + updateProfile.unique_user_name[0], + authStateData.userCanisterId, + ) + } catch (e) { + Log('warn', 'Error while accessing IDB', { + error: e, + from: 'profile.updateProfile', + type: 'idb', + }) + } + } + } else { + Log('warn', 'No profile found', { + from: 'profile.updateProfile', + }) + } + } else { + userProfile.set(emptyProfileValues) + } + updateUserProperties() // GA + setUser(authStateData.idString) //Sentry + Log('info', 'Updated user profile', { + profile: get(userProfile), + from: 'profile.updateProfile', + }) +} + +async function updateUserProperties() { + const profile = get(userProfile) + const authStateData = get(authState) + if (authStateData.isLoggedIn && profile.principal_id) { + const res = await fetchTokenBalance() + setUserProperties({ + display_name: profile.display_name, + userId: profile.principal_id, + user_canister_id: authStateData.userCanisterId, + ...(profile.username_set && { username: profile.unique_user_name }), + ...(!res.error && { wallet_balance: res.balance }), + }) + } else { + setUserProperties() + } +} + +type ProfilePostsResponse = + | { + error: true + } + | { + error: false + posts: PostDetailsForFrontend[] + noMorePosts: boolean + } + +type ProfileSpeculationsResponse = + | { + error: true + } + | { + error: false + posts: PostPopulatedWithBetDetails[] + noMorePosts: boolean + } + +export async function fetchPosts( + id: string, + from: number, +): Promise { + try { + const canId = await getCanisterId(id) + + const res = await individualUser( + Principal.from(canId), + ).get_posts_of_this_user_profile_with_pagination( + BigInt(from), + BigInt(from + 10), + ) + if ('Ok' in res) { + return { + error: false, + posts: res.Ok, + noMorePosts: res.Ok.length < 10, + } + } else if ('Err' in res) { + type UnionKeyOf = U extends U ? keyof U : never + type errors = UnionKeyOf + const err = Object.keys(res.Err)[0] as errors + switch (err) { + case 'ExceededMaxNumberOfItemsAllowedInOneRequest': + case 'InvalidBoundsPassed': + return { error: true } + case 'ReachedEndOfItemsList': + return { error: false, noMorePosts: true, posts: [] } + } + } else throw new Error(`Unknown response, ${JSON.stringify(res)}`) + } catch (e) { + Log('error', 'Error while loading posts', { + error: e, + from: 'profile.fetchPosts', + }) + return { error: true } + } +} + +export async function fetchSpeculations( + id: string, + from: number, +): Promise { + try { + const canId = await getCanisterId(id) + + const res = await individualUser( + Principal.from(canId), + ).get_hot_or_not_bets_placed_by_this_profile_with_pagination(BigInt(from)) + const populatedRes = await populatePosts(res) + if (populatedRes.error) { + return { error: true } + } + return { + error: false, + posts: populatedRes.posts, + noMorePosts: res.length < 10, + } + } catch (e) { + Log('error', 'Error while loading posts', { + error: e, + from: 'profile.fetchSpeculations', + }) + return { error: true } + } +} + +async function populatePosts(posts: PlacedBetDetail[]) { + try { + if (!posts.length) { + return { posts: [], error: false } + } + + const res = await Promise.all( + posts.map(async (post) => { + try { + const r = await individualUser( + Principal.from(post.canister_id), + ).get_individual_post_details_by_id(post.post_id) + return { + ...r, + placed_bet_details: post, + score: BigInt(0), + created_by_user_principal_id: + r.created_by_user_principal_id.toText(), + publisher_canister_id: post.canister_id.toText(), + } as PostPopulatedWithBetDetails + } catch (_) { + return undefined + } + }), + ) + return { + posts: res.filter((o) => !!o) as PostPopulatedWithBetDetails[], + error: false, + } + } catch (e) { + Log('error', 'Error while loading posts', { + error: e, + from: 'profile.populatePosts', + }) + return { error: true, posts: [] } + } +} + +export async function fetchLovers(id: string, from?: bigint) { + try { + const canId = await getCanisterId(id) + + const res = await individualUser( + Principal.from(canId), + ).get_principals_that_follow_this_profile_paginated(from ? [from] : []) + if (!res) { + throw new Error(`Unknown response, ${JSON.stringify(res)}`) + } + const populatedUsers = await populateProfiles(from ? res.slice(1) : res) + if (populatedUsers.error) { + throw new Error( + `Error while populating, ${JSON.stringify(populatedUsers)}`, + ) + } + return { + error: false, + lovers: populatedUsers.users, + noMoreLovers: res.length < 9, + } + } catch (e) { + Log('error', 'Error while loading followers', { + error: e, + from: 'profile.fetchLovers', + }) + return { error: true } + } +} + +export async function fetchLovingUsers(id: string, from?: bigint) { + try { + const canId = await getCanisterId(id) + + const res = await individualUser( + Principal.from(canId), + ).get_principals_this_profile_follows_paginated(from ? [from] : []) + if (!res) { + throw new Error(`Unknown response, ${JSON.stringify(res)}`) + } + const populatedUsers = await populateProfiles(from ? res.slice(1) : res) + if (populatedUsers.error) { + throw new Error( + `Error while populating, ${JSON.stringify(populatedUsers)}`, + ) + } + return { + error: false, + lovers: populatedUsers.users, + noMoreLovers: res.length < 10, + } + } catch (e) { + Log('error', 'Error while loading followers', { + error: e, + from: 'profile.fetchLovingUsers', + }) + return { error: true } + } +} + +async function populateProfiles(list: Array<[bigint, FollowEntryDetail]>) { + try { + if (!list.length) { + return { users: [], error: false } + } + + const authStateData = get(authState) + + const res = await Promise.all( + list.map(async ([id, detail]) => { + const principalId = detail?.principal_id?.toText() + if (!principalId) return + if (principalId === '2vxsx-fae') return + + const r = await individualUser( + Principal.from(detail.canister_id), + ).get_profile_details() + + return { + ...sanitizeProfile(r, principalId), + index_id: id, + i_follow: authStateData.isLoggedIn + ? await doIFollowThisUser(principalId) + : false, + } as UserProfileFollows + }), + ) + + return { + users: res.filter((o) => !!o) as UserProfileFollows[], + error: false, + } + } catch (e) { + Log('error', 'Error while loading profile', { + error: e, + from: 'profile.populateProfiles', + }) + return { error: true, users: [] } + } +} + +export async function doIFollowThisUser(principalId?: string) { + if (!principalId) return false + if (!isPrincipal(principalId)) { + throw 'Invalid Principal ID' + } + const canisterId = await getCanisterId(principalId) + if (!canisterId) { + throw 'Could not find Canister ID' + } + try { + const res = await individualUser().do_i_follow_this_user({ + followee_canister_id: Principal.from(canisterId), + followee_principal_id: Principal.from(principalId), + }) + return !!res['Ok'] + } catch (e) { + Log('error', 'Error while loading following status', { + error: e, + from: 'profile.doIFollowThisUser', + }) + return false + } +} + +export async function loveUser(principalId: string) { + try { + if (!isPrincipal(principalId)) { + throw 'Invalid Principal ID' + } + const canisterId = await getCanisterId(principalId) + if (!canisterId) { + throw 'Could not find Canister ID' + } + const res = + await individualUser().update_profiles_i_follow_toggle_list_with_specified_profile( + { + followee_canister_id: Principal.from(canisterId), + followee_principal_id: Principal.from(principalId), + }, + ) + if ('Ok' in res) { + return true + } else { + return false + } + } catch (e) { + Log('error', 'Error while following a status', { + error: e, + from: 'profile.loveUser', + }) + return false + } +} +type UnionKeyOf = U extends U ? keyof U : never +type UnionValueOf = U extends U ? U[keyof U] : never + +const walletEventDetails = ({} as WalletEvent)?.details +type WalletEvent = UnionValueOf +type WalletEventDetails = typeof walletEventDetails +export type WalletEventSubType = UnionKeyOf +type WalletEventSubDetails = UnionValueOf +type NotificationEventType = Omit< + WalletEventSubType, + 'BetOnHotOrNotPost' | 'NewUserSignup' +> +type EventOutcome = UnionKeyOf + +export interface TransactionHistory { + id: BigInt + type: UnionKeyOf + token: number + timestamp: SystemTime + subType: WalletEventSubType + details?: WalletEventSubDetails + eventOutcome?: EventOutcome +} + +export interface NotificationHistory { + id: BigInt + type: NotificationEventType + token: number + timestamp: SystemTime + details?: WalletEventSubDetails + eventOutcome?: EventOutcome +} + +type HistoryResponse = + | { + error: true + } + | { + error: false + history: TransactionHistory[] + endOfList: boolean + } + +type NotificationResponse = + | { + error: true + } + | { + error: false + notifications: NotificationHistory[] + endOfList: boolean + } + +async function transformHistoryRecords( + res: Array<[bigint, TokenEvent]>, + filter?: UnionKeyOf, +): Promise { + const history: TransactionHistory[] = [] + + res.forEach((o) => { + const event = o[1] + const type = Object.keys(event)[0] as UnionKeyOf + const subType = Object.keys(event[type].details)[0] as WalletEventSubType + const details = (event[type] as WalletEvent)?.details?.[ + subType + ] as WalletEventSubDetails + const eventOutcome = Object.keys( + details?.['event_outcome'] || {}, + )[0] as EventOutcome + + if (!filter || filter === subType) { + history.push({ + id: o[0], + type, + subType, + token: Object.values(event)?.[0]?.amount || 0, + timestamp: event[type].timestamp as SystemTime, + details, + eventOutcome, + }) + } + }) + + return history +} + +export async function setBetDetailToDb( + post: PostPopulated, + betDetail: PlacedBetDetail, +) { + if (!post) return + + try { + const idb = (await import('$lib/idb')).idb + idb.set('bets', post.publisher_canister_id + '@' + post.post_id, betDetail) + } catch (e) { + Log('error', 'Error while accessing IDB', { + error: e, + from: 'profile.setBetDetailToDb', + type: 'idb', + }) + return + } +} + +export async function fetchHistory( + from: number, + filter?: UnionKeyOf, +): Promise { + try { + const res = + await individualUser().get_user_utility_token_transaction_history_with_pagination( + BigInt(from), + BigInt(from + 10), + ) + if ('Ok' in res) { + const history = await transformHistoryRecords(res.Ok, filter) + + return { + error: false, + history, + endOfList: history.length < 10, + } + } else if ('Err' in res) { + type errors = UnionKeyOf + const err = Object.keys(res.Err)[0] as errors + switch (err) { + case 'InvalidBoundsPassed': + return { error: true } + case 'ReachedEndOfItemsList': + return { error: false, endOfList: true, history: [] } + } + } else throw new Error(`Unknown response, ${JSON.stringify(res)}`) + } catch (e) { + Log('error', 'Error while loading transaction history', { + error: e, + from: 'profile.fetchHistory', + }) + return { error: true } + } + return { error: true } +} + +async function transformNotificationRecords(res: Array<[bigint, TokenEvent]>) { + const notifications: NotificationHistory[] = [] + + res.forEach((o) => { + const event = o[1] + const type = Object.keys(event)[0] as UnionKeyOf + const subType = Object.keys(event[type].details)[0] as WalletEventSubType + const details = (event[type] as WalletEvent)?.details?.[ + subType + ] as WalletEventSubDetails + const eventOutcome = Object.keys( + details?.['event_outcome'] || {}, + )[0] as EventOutcome + + if (subType !== 'BetOnHotOrNotPost' && subType !== 'NewUserSignup') { + notifications.push({ + id: o[0], + type: subType, + token: Object.values(event)?.[0]?.amount || 0, + timestamp: event[type].timestamp as SystemTime, + details, + eventOutcome, + }) + } + }) + return notifications +} + +export async function fetchNotifications( + from: number, +): Promise { + try { + const res = + await individualUser().get_user_utility_token_transaction_history_with_pagination( + BigInt(from), + BigInt(from + 20), + ) + if ('Ok' in res) { + const notifications = await transformNotificationRecords(res.Ok) + + return { + error: false, + notifications, + endOfList: history.length < 10, + } + } else if ('Err' in res) { + type errors = UnionKeyOf + const err = Object.keys(res.Err)[0] as errors + switch (err) { + case 'InvalidBoundsPassed': + return { error: true } + case 'ReachedEndOfItemsList': + return { error: false, endOfList: true, notifications: [] } + } + } else throw new Error(`Unknown response, ${JSON.stringify(res)}`) + } catch (e) { + Log('error', 'Error while loading transaction history', { + error: e, + from: 'profile.fetchNotifications', + }) + return { error: true } + } + return { error: true } +} + +export async function fetchTokenBalance(): Promise< + { error: false; balance: number } | { error: true } +> { + try { + const res = await individualUser().get_utility_token_balance() + return { error: false, balance: Number(res) } + } catch (e) { + Log('error', 'Error while loading token balance', { + error: e, + from: 'profile.fetchTokenBalance', + }) + return { error: true } + } +} diff --git a/packages/experiments/src/lib/helpers/sentry.ts b/packages/experiments/src/lib/helpers/sentry.ts new file mode 100644 index 000000000..2f6d431bc --- /dev/null +++ b/packages/experiments/src/lib/helpers/sentry.ts @@ -0,0 +1,5 @@ +import * as Sentry from '@sentry/browser' + +export function setUser(id?: string) { + Sentry.setUser(id ? { id } : null) +} diff --git a/packages/experiments/src/lib/helpers/signup.ts b/packages/experiments/src/lib/helpers/signup.ts new file mode 100644 index 000000000..0cb36ad54 --- /dev/null +++ b/packages/experiments/src/lib/helpers/signup.ts @@ -0,0 +1,36 @@ +import Log from '$lib/utils/Log' +import { configuration } from './backend' + +const cfWorkerHost = import.meta.env.VITE_CLOUDFLARE_WORKERS_API_HOST + +export async function checkSignupStatusCf(): Promise { + try { + const res = await fetch(`${cfWorkerHost}/backend/signupStatus`) + const body = await res.json() + Log('info', 'Checking signup status on CF', { + from: 'signup.checkSignupStatusCf', + }) + if (body.allowed) { + return true + } else return false + } catch (e) { + Log('error', 'Could not fetch signup status on CF', { + from: 'signup.checkSignupStatusCf', + error: e, + }) + return false + } +} + +export async function checkSignupStatusCanister(): Promise { + try { + const res = await configuration().are_signups_enabled() + return res + } catch (e) { + Log('error', 'Could not fetch signup status on canister', { + from: 'signup.checkSignupStatusCanister', + error: e, + }) + return false + } +} diff --git a/packages/experiments/src/lib/helpers/stream.ts b/packages/experiments/src/lib/helpers/stream.ts new file mode 100644 index 000000000..1336ae12e --- /dev/null +++ b/packages/experiments/src/lib/helpers/stream.ts @@ -0,0 +1,124 @@ +import Log from '$lib/utils/Log' +import { authState } from '$stores/auth' +import { get } from 'svelte/store' + +const cfWorkerHost = import.meta.env.VITE_CLOUDFLARE_WORKERS_API_HOST + +async function generateUrl() { + const authStateData = get(authState) + const res = await fetch(`${cfWorkerHost}/video/getVideoUploadURL`, { + method: 'POST', + body: JSON.stringify({ + principalId: authStateData.idString || '', + fileName: Date.now().toString(), + }), + }) + const body = await res.json() + if (body.success) { + return body.result as { uploadURL: string; uid: string } + } else { + return undefined + } +} + +export async function uploadVideoToStream( + file: Blob | File, + onProgress: any, +): Promise { + const uploadRes = await generateUrl() + if (!uploadRes || !uploadRes.uploadURL) { + return { + success: false, + errorMessage: "Couldn't generate upload Url", + } + } + + return new Promise((resolve) => { + const xhr = new XMLHttpRequest() + xhr.upload.addEventListener('progress', (e) => + onProgress(e.loaded / e.total), + ) + xhr.addEventListener('load', () => + resolve({ success: true, uid: uploadRes.uid }), + ) + xhr.addEventListener('error', (e) => + resolve({ + success: false, + error: e, + errorMessage: 'Something went wrong while uploading file', + }), + ) + xhr.addEventListener('abort', () => + resolve({ success: false, errorMessage: 'Upload cancelled by user' }), + ) + xhr.open('POST', uploadRes.uploadURL, true) + const formData = new FormData() + formData.append('file', file) + xhr.send(formData) + }) +} + +export async function checkVideoStatus(uid: string): Promise { + try { + const req = await fetch( + `${cfWorkerHost}/video/${uid}/getVideoProcessingStatus`, + { + method: 'GET', + }, + ) + const result: CheckVideoStatusResult = await req.json() + if (result.readyToStream && result.mp4Url == '') { + enableMp4Downloads(uid) + } + return { + success: true, + result, + } + } catch (e) { + return { + success: false, + error: e, + errorMessage: + 'Something went wrong while checking status for uid: ' + uid, + } + } +} + +export async function enableMp4Downloads(uid: string) { + try { + await fetch(`${cfWorkerHost}/video/${uid}/enableMp4Download`, { + method: 'GET', + }) + } catch (e) { + Log('error', 'Could not enable downloads for video', { + from: 'stream.enableMp4Downloads', + uid, + error: e, + }) + } +} + +type RequestError = { + success: false + errorMessage: string + error?: any +} + +export type CheckVideoStatusResult = { + readyToStream: boolean + thumbnail: string + mp4Url: string + playback?: { + hls?: string + dash?: string + } +} + +type CheckVideoStatus = + | RequestError + | { + success: true + result: CheckVideoStatusResult + } + +type UploadVideoToStream = RequestError | { success: true; uid: string } diff --git a/packages/experiments/src/lib/idb/db.ts b/packages/experiments/src/lib/idb/db.ts new file mode 100644 index 000000000..3f3fc7655 --- /dev/null +++ b/packages/experiments/src/lib/idb/db.ts @@ -0,0 +1,61 @@ +import { openDB } from 'idb' +import Log from '../utils/Log' + +type DBStores = + | 'canisters' + | 'watch' + | 'watch-hon' + | 'bets' + | 'wallet' + | 'reported' + +const dbPromise = openDB('keyval-store', 7, { + upgrade(db) { + if (!db.objectStoreNames.contains('keyval')) { + db.createObjectStore('keyval') + } + if (!db.objectStoreNames.contains('canisters')) { + db.createObjectStore('canisters') + } + if (!db.objectStoreNames.contains('watch')) { + db.createObjectStore('watch') + } + if (!db.objectStoreNames.contains('watch-hon')) { + db.createObjectStore('watch-hon') + } + if (!db.objectStoreNames.contains('bets')) { + db.createObjectStore('bets') + } + if (!db.objectStoreNames.contains('wallet')) { + db.createObjectStore('wallet') + } + if (!db.objectStoreNames.contains('reported')) { + db.createObjectStore('reported') + } + }, +}).catch((e) => { + Log('error', 'Error while accessing IDB', { + error: e, + from: 'db.openDB', + type: 'idb', + }) +}) + +export async function get(storeName: DBStores, key) { + return (await dbPromise)?.get(storeName, key) +} +export async function set(storeName: DBStores, key, val) { + return (await dbPromise)?.put(storeName, val, key) +} +export async function del(storeName: DBStores, key) { + return (await dbPromise)?.delete(storeName, key) +} +export async function clear(storeName: DBStores) { + return (await dbPromise)?.clear(storeName) +} +export async function keys(storeName: DBStores) { + return (await dbPromise)?.getAllKeys(storeName) +} +export async function values(storeName: DBStores) { + return (await dbPromise)?.getAll(storeName) +} diff --git a/packages/experiments/src/lib/idb/index.ts b/packages/experiments/src/lib/idb/index.ts new file mode 100644 index 000000000..bc60cf2af --- /dev/null +++ b/packages/experiments/src/lib/idb/index.ts @@ -0,0 +1,5 @@ +import * as idb from './db' + +export type IDB = typeof idb + +export { idb } diff --git a/packages/experiments/src/lib/utils/Log.ts b/packages/experiments/src/lib/utils/Log.ts new file mode 100644 index 000000000..8747cde02 --- /dev/null +++ b/packages/experiments/src/lib/utils/Log.ts @@ -0,0 +1,42 @@ +import { browser } from '$app/environment' +import * as Sentry from '@sentry/svelte' + +function replaceErrors(_v: any, value) { + if (typeof value === 'bigint') { + return Number(value) + } else if (value instanceof Error) { + const error = {} + + Object.getOwnPropertyNames(value).forEach((propName) => { + error[propName] = value[propName] + }) + + return error + } + + return value +} + +type Logs = 'log' | 'info' | 'warn' | 'error' + +const logTypeMap: Record = { + log: '📺', + info: 'ℹ️', + warn: '⚠️', + error: '🚨', +} + +export default (type: Logs, message: string, data?: any) => { + const dataStr = JSON.stringify(data, replaceErrors) || data + const localhost = browser + ? location.host.includes('localhost') + : import.meta.env.NODE_ENV !== 'production' + if (localhost || type == 'error') { + console[type](logTypeMap[type], message, dataStr) + } + if (type === 'error') { + Sentry.captureException(new Error(message), { + extra: data, + }) + } +} diff --git a/packages/experiments/src/lib/utils/canvas.ts b/packages/experiments/src/lib/utils/canvas.ts new file mode 100644 index 000000000..19962353b --- /dev/null +++ b/packages/experiments/src/lib/utils/canvas.ts @@ -0,0 +1,88 @@ +//@ts-nocheck + +const createImage = (url) => + new Promise((resolve, reject) => { + const image = new Image() + image.addEventListener('load', () => resolve(image)) + image.addEventListener('error', (error) => reject(error)) + image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox + image.src = url + }) + +function getRadianAngle(degreeValue) { + return (degreeValue * Math.PI) / 180 +} + +export async function getCroppedImg(imageSrc, pixelCrop, rotation = 0): Blob { + const image = await createImage(imageSrc) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + const maxSize = Math.max(image.width, image.height) + const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2)) + + // set each dimensions to double largest dimension to allow for a safe area for the + // image to rotate in without being clipped by canvas context + canvas.width = safeArea + canvas.height = safeArea + + // translate canvas context to a central location on image to allow rotating around the center. + ctx.translate(safeArea / 2, safeArea / 2) + ctx.rotate(getRadianAngle(rotation)) + ctx.translate(-safeArea / 2, -safeArea / 2) + + // draw rotated image and store data. + ctx.drawImage( + image, + safeArea / 2 - image.width * 0.5, + safeArea / 2 - image.height * 0.5, + ) + const data = ctx.getImageData(0, 0, safeArea, safeArea) + + // set canvas width to final desired crop size - this will clear existing context + canvas.width = pixelCrop.width + canvas.height = pixelCrop.height + + // paste generated rotate image with correct offsets for x,y crop values. + ctx.putImageData( + data, + Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x), + Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y), + ) + + // As Base64 string + // return canvas.toDataURL('image/jpeg'); + + // As a blob + return new Promise((resolve) => { + canvas.toBlob((file) => { + resolve(file) + }, 'image/png') + }) +} + +export async function getRotatedImage(imageSrc, rotation = 0) { + const image = await createImage(imageSrc) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + const orientationChanged = + rotation === 90 || rotation === -90 || rotation === 270 || rotation === -270 + if (orientationChanged) { + canvas.width = image.height + canvas.height = image.width + } else { + canvas.width = image.width + canvas.height = image.height + } + + ctx.translate(canvas.width / 2, canvas.height / 2) + ctx.rotate((rotation * Math.PI) / 180) + ctx.drawImage(image, -image.width / 2, -image.height / 2) + + return new Promise((resolve) => { + canvas.toBlob((file) => { + resolve(URL.createObjectURL(file)) + }, 'image/png') + }) +} diff --git a/packages/experiments/src/lib/utils/clickOutside.ts b/packages/experiments/src/lib/utils/clickOutside.ts new file mode 100644 index 000000000..8a81de844 --- /dev/null +++ b/packages/experiments/src/lib/utils/clickOutside.ts @@ -0,0 +1,31 @@ +export default function clickOutside(node: Node, enabled: boolean = true) { + const handleClick = (event: MouseEvent) => { + if ( + node && + !node.contains(event.target as Node) && + !event.defaultPrevented + ) { + node.dispatchEvent( + new CustomEvent('clickOutside', node as CustomEventInit), + ) + } + } + const addListener = () => + document.addEventListener('click', handleClick, true) + const removeListener = () => + document.removeEventListener('click', handleClick, true) + + if (enabled) addListener() + return { + update(enabled: boolean) { + if (enabled) { + addListener() + } else { + removeListener() + } + }, + destroy() { + removeListener() + }, + } +} diff --git a/packages/experiments/src/lib/utils/cloudflare.ts b/packages/experiments/src/lib/utils/cloudflare.ts new file mode 100644 index 000000000..9c31d1188 --- /dev/null +++ b/packages/experiments/src/lib/utils/cloudflare.ts @@ -0,0 +1,17 @@ +const host = 'https://customer-2p3jflss4r4hmpnz.cloudflarestream.com' + +export function getThumbnailUrl(uid: string) { + return `${host}/${uid}/thumbnails/thumbnail.jpg` +} + +export function getMp4Url(uid: string) { + return `${host}/${uid}/downloads/default.mp4` +} + +export function getHlsUrl(uid: string) { + return `${host}/${uid}/manifest/video.m3u8` +} + +export function getDashUrl(uid: string) { + return `${host}/${uid}/manifest/video.mpd` +} diff --git a/packages/experiments/src/lib/utils/faq.ts b/packages/experiments/src/lib/utils/faq.ts new file mode 100644 index 000000000..2930e5903 --- /dev/null +++ b/packages/experiments/src/lib/utils/faq.ts @@ -0,0 +1,108 @@ +export default { + general: [ + { + title: 'What is Hot or Not?', + body: 'It is a short video content sharing platform, similar to Tik-Tok, built on the ICP (Internet Computer Protocol) blockchain. Hot or Not combines the entertainment of short-video social media with end-user monetisation by enabling users to speculate on the content and earn tokens (COYN token). Hot or not is the second version of GoBazzinga, developed by GoBazzinga inc. taking forward the vision to create a social media platform that financially rewards the consumers of the platform instead of advertisers.', + }, + { + title: 'How does the Hot or Not monetisation work?', + body: `The current app is the alpha version which does not contain the Hot or Not monetisation system. This will be added to the platform in the coming months, in accordance with GoBazzinga's Dfinity grant milestones. + The platform will contain an in-app game called “Hot or Not”. Users can stake their COYN tokens on any video, on either “Hot” or “Not” outcomes, depending on their thoughts on the viral capacity of the video. + If the user stakes their token on the side of the majority, they win and earn double their tokens back. + If the user stakes their tokens on the minority side, they lose all the tokens staked on that particular video. + Hot or Not also monetises content creation without relying on advertisers, by giving 10% of all tokens staked to the creator of the video, regardless of the outcome.`, + }, + { + title: 'How to be a part of the Hot or Not family?', + body: "If you're reading this, you are already a part of the family. In order to make the most of the app, we would recommend logging in through your Internet Identity or NFID, to start uploading videos and to enjoy a personalised feed and experience.", + }, + { + title: 'What devices is “Hot or Not” available on?', + body: 'Hot or Not is currently available in the form of a web app on mobiles and desktops on both android and ios systems.', + }, + { + title: 'Is Hot or Not 100% on-chain?', + body: 'Everything on the app is stored on-chain, with the exception of the videos and profile pictures, which are hosted on Cloudflare. We are awaiting the storage subnets so that we can later put these things on-chain as well. On this forum, at https://forum.dfinity.org/t/long-term-r-d-storage-subnets-proposal/9390, you can read more about the storage subnets. User experience, cost, and simplicity were taken into consideration when choosing Cloudflare over non-ICP on-chain services like IPFS. Our roadmap outlines our goal of becoming entirely on-chain as we are committed to creating a decentralised system where users have complete control over their data and content.', + }, + { + title: 'In how many languages Hot or Not is available?', + body: 'As we are just starting out, we are able to provide the app in only the English language. Adding more languages to the app is on our roadmap in order to make it more accessible and inclusive.', + }, + { + title: 'What are the things we can do on the app?', + body: `You can do a lot of new things on the app, summarised as follows: + create videos upto 60 seconds, + upload videos upto 60 seconds, + like and share the videos that you enjoy, + love your favourite creators to see more of their content, + customise your profile, + create your own community of Lovers who enjoy your content, and + scroll through endless content with our personalised feed. + There are lots of other features which will be launching on the app in the coming months, including the Hot or Not game. Stay tuned for the same. + `, + }, + { + title: 'How to upload videos on the platform?', + body: `You can upload a video on the platform only after login. After logging in, go to the “Plus Icon” on the home screen to access the Camera. You can upload a video from your gallery, or create a new video with the in-app camera. If the video is under 60 seconds, it will be uploaded to the platform. It may take upto 30 minutes for a video to show up in the home feed after upload. + Note: A video once uploaded cannot be deleted from the platform. + `, + }, + { + title: 'How many videos in a day can we post?', + body: 'There is currently no limit on the amount of videos you can post, as long as the videos are under 60 seconds each.', + }, + { + title: 'How to use filters for unique content?', + body: 'Filters are available in our in-app camera automatically. You can open the camera to access them, and scroll through to find your favourite one.', + }, + { + title: 'How to edit the video created?', + body: 'The facility of making edits to a video created within the app is not yet available. This feature will be added in the coming months, including text addition, sounds library, and stickers among other facilities.', + }, + { + title: 'What type of content is prohibited ?', + body: 'Refer to our community guidelines for the same. Go to Menu > Terms of Service to access community guidelines.', + }, + { + title: 'How do I add or change my profile picture?', + body: "You can go to Menu > View Profile. Once you're on your profile page, you will see an edit icon on the top right hand corner of the screen. It will open an edit window which will allow you to change your profile picture and name.", + }, + { + title: 'How can I add a profile name ?', + body: 'You will be allotted a cute username and profile name automatically when you login. You can change your username once, but you can change your profile name as many times as you want by going to edit profile as explained in the answer above.', + }, + { + title: 'How can I delete my account?', + body: 'Currently, there is no way to delete an account already created. You can change your Profile Picture and your Profile Name on your existing account. You can also create another account with a new Internet Identity, but this does not remove your videos from the platform. A video once uploaded cannot be deleted as of now, this service may be made available in the future.', + }, + ], + tokens: [ + { + title: 'Which tokens are being used on the app?', + body: 'The GoBazzinga tokens are being renamed COYN tokens, which will be available in the Hot or Not app wallet. These tokens will be used for playing, staking, tipping and many more activities on the Hot or Not app. Wallet and tokens will be added to the app in the coming months.', + }, + { + title: 'What is the value of the COYN token?', + body: 'The value of a COYN token will be determined by the market demand when it is listed on an exchange. Have a look at our roadmap to know more.', + }, + { + title: 'Where can I learn more about the tokenomics of Hot or Not?', + body: 'You can read about Hot or Not tokenomics in our whitepaper available on our website. Any information pertaining to the same will be made available there in its latest format.', + }, + { + title: + 'Where will the winners of GoBazzinga tokens in giveaways receive their rewards?', + body: 'The giveaway winners of GoBazzinga tokens (now COYN tokens) will receive their rewards in the Hot or Not app wallet. This will be done once the app has gone through alpha and beta testing, to ensure all winners get their rewards without any lapse.', + }, + ], + nfts: [ + { + title: 'What will be the use of GobGob NFTs?', + body: 'GobGob NFTs will be a part of end-user monetisation on the platform. They will be utilised for speculation on content, enabling both consumer, and creator to earn. This will also create a utility for people holding GobGobs, as they will receive royalties every time their NFT is used on the platform. This feature will be added to the platform in the coming months.', + }, + { + title: 'Is the value of NFTs more than tokens?', + body: 'The value of GobGob is dependent on the demand in the secondary marketplace, as GoBazzinga is not selling them in the primary market. In addition, GoBazzinga is taking 0% royalty on all sales. This being said, the value of tokens will also be dependent on demand once they are listed, thus comparing the two is not possible.', + }, + ], +} diff --git a/packages/experiments/src/lib/utils/feedUrl.ts b/packages/experiments/src/lib/utils/feedUrl.ts new file mode 100644 index 000000000..866b712ab --- /dev/null +++ b/packages/experiments/src/lib/utils/feedUrl.ts @@ -0,0 +1,13 @@ +import { registerPageView } from '$components/analytics/GA.svelte' +import type { PostPopulated } from '$lib/helpers/feed' +import { navigateBack } from '$stores/navigation' +import { playerState } from '$stores/playerState' + +export function updateURL(post?: PostPopulated) { + if (!post) return + const url = post.publisher_canister_id + '@' + post.post_id + navigateBack.set(url) + playerState.update((o) => ({ ...o, currentFeedUrl: url })) + window.history.replaceState('', '', url) + registerPageView(new URL(window.location.href)) +} diff --git a/packages/experiments/src/lib/utils/filtersMap.ts b/packages/experiments/src/lib/utils/filtersMap.ts new file mode 100644 index 000000000..b494cd2a0 --- /dev/null +++ b/packages/experiments/src/lib/utils/filtersMap.ts @@ -0,0 +1,157 @@ +export const allFilters: Record> = { + aden: { + 'hue-rotate': -20, + 'contrast': 0.9, + 'brightness': 1.2, + 'saturate': 0.85, + }, + + inkwell: { + sepia: 0.3, + contrast: 1.1, + brightness: 1.1, + grayscale: 1, + }, + + reyes: { + sepia: 0.22, + contrast: 0.85, + brightness: 1.1, + saturate: 0.75, + }, + + gingham: { + 'hue-rotate': -10, + 'brightness': 1.05, + }, + + toaster: { + contrast: 1.5, + brightness: 0.9, + }, + + walden: { + 'hue-rotate': -10, + 'brightness': 1.1, + 'sepia': 0.3, + 'saturate': 1.6, + }, + + hudson: { + brightness: 1.2, + contrast: 0.9, + saturate: 1.1, + }, + + earlybird: { + contrast: 0.9, + sepia: 0.2, + }, + + mayfair: { + contrast: 1.1, + saturate: 1.1, + }, + + lofi: { + contrast: 1.5, + saturate: 1.1, + }, + + 1977: { + contrast: 1.1, + brightness: 1.1, + saturate: 1.3, + }, + + brooklyn: { + contrast: 0.9, + brightness: 1.1, + }, + + xpro2: { + sepia: 0.3, + }, + + nashville: { + contrast: 1.2, + brightness: 1.05, + saturate: 1.2, + sepia: 0.2, + }, + + lark: { + contrast: 0.9, + }, + + moon: { + brightness: 1.1, + contrast: 1.1, + grayscale: 1, + }, + + clarendon: { + contrast: 1.2, + saturate: 1.35, + }, + + willow: { + contrast: 0.95, + brightness: 0.9, + grayscale: 0.5, + }, + + rise: { + contrast: 0.9, + brightness: 1.05, + sepia: 0.2, + saturate: 0.9, + }, + + slumber: { + brightness: 1.05, + saturate: 0.66, + }, + + brannan: { + contrast: 1.4, + sepia: 0.5, + }, + + valencia: { + contrast: 1.08, + brightness: 1.08, + sepia: 0.08, + }, + + maven: { + contrast: 0.95, + brightness: 1.95, + saturate: 1.5, + sepia: 0.25, + }, + + stinson: { + contrast: 0.75, + brightness: 1.15, + saturate: 0.85, + }, + + amaro: { + 'hue-rotate': -10, + 'contrast': 0.9, + 'brightness': 1.1, + 'saturate': 1.5, + }, +} + +export function getFilterCss(filterName: keyof typeof allFilters) { + const settings = allFilters[filterName] + return Object.keys(settings) + .map((key) => { + return `${key}(${settings[key]}${ + key === 'hue-rotate' ? 'deg' : key === 'blur' ? 'px' : '' + })` + }) + .join(' ') +} diff --git a/packages/experiments/src/lib/utils/getDefaultImageUrl.ts b/packages/experiments/src/lib/utils/getDefaultImageUrl.ts new file mode 100644 index 000000000..52652924c --- /dev/null +++ b/packages/experiments/src/lib/utils/getDefaultImageUrl.ts @@ -0,0 +1,38 @@ +import { Principal } from '@dfinity/principal' + +export const imageHost = + 'https://hotornot.wtf/cdn-cgi/imagedelivery/abXI9nS4DYYtyR1yFFtziA' + +const cfAvatarImageIds = [ + '01452301-3861-4b96-c9ee-b3e443a22300', + '3317a9a5-df08-44cd-fc9d-9b5c7066fd00', + 'b4cb25e8-efa1-4560-9990-2fcb55794e00', + '8c602cc9-09c1-473b-ea9d-3973b4730700', + '2b2963fd-36cf-4fb0-a37e-8d9e37d84c00', + '3b0b7c6d-ff3f-48dc-8a50-8d317579b300', + '51434d58-119c-4dc6-ea36-079498eba400', + '05ac99c9-18d6-4f0c-ccd3-7d0974057400', + 'fa856806-2a8e-4079-0f59-151a90789c00', + '8123f9e8-8e4a-40d6-f42b-9991bc611e00', + '4357594e-1557-4cc5-7677-ccdcb89f2e00', + 'c86b4d37-6751-4e9b-c0a7-0fa2b68e0300', + 'f5ba394b-472a-485c-eff5-8dec88d5e800', + '940a5534-e5c8-4609-02d3-f6435d9b7800', + '9537bbf2-88c2-4924-88ce-140a4f709300', + '1395d656-5d42-445f-9909-c0bc72ff6600', + '0bbb24da-0dbc-4252-60ef-4f642a1fc200', + '721a3f2d-655e-4c6d-06f2-090c63bf4600', + '9d728621-e33c-444f-b81c-c6502b30e600', + '098ae9bb-be53-4128-5918-2b794250ae00', +] + +export default (principal?: Principal | string) => { + let string = 'random' + if (typeof principal === 'string') { + string = principal + } else if (principal?._isPrincipal) { + string = Principal.from(principal).toText() + } + const sum = string.split('').reduce((acc, val) => val.charCodeAt(0) + acc, 0) + return `${imageHost}/${cfAvatarImageIds[sum % 20]}/public` +} diff --git a/packages/experiments/src/lib/utils/getTimeDifference.ts b/packages/experiments/src/lib/utils/getTimeDifference.ts new file mode 100644 index 000000000..09d8fcbad --- /dev/null +++ b/packages/experiments/src/lib/utils/getTimeDifference.ts @@ -0,0 +1,13 @@ +const TWO_DAYS_MS = 86_400 * 2 * 1000 +import { formatDistanceToNow, format } from 'date-fns' + +export default function (ms: number, options?: { showTime?: boolean }): string { + const now = new Date().getTime() + if (now - ms > TWO_DAYS_MS) { + return format( + new Date(ms), + `${options?.showTime ? 'KK:mm aa, ' : ''}MM/dd/yy`, + ) + } + return formatDistanceToNow(ms, { addSuffix: true }) +} diff --git a/packages/experiments/src/lib/utils/goBack.ts b/packages/experiments/src/lib/utils/goBack.ts new file mode 100644 index 000000000..e98e06199 --- /dev/null +++ b/packages/experiments/src/lib/utils/goBack.ts @@ -0,0 +1,15 @@ +import { goto } from '$app/navigation' +import { navigationHistory } from '$stores/navigation' +import { get } from 'svelte/store' + +export default function (goBackTo?: string | null, replaceState?: boolean) { + const navHistory = get(navigationHistory) + if (navHistory.length === 0 && goBackTo) { + goto(goBackTo, { replaceState }) + } else { + history.back() + navHistory.pop() + navigationHistory.set(navHistory) + return false + } +} diff --git a/packages/experiments/src/lib/utils/isPrincipal.ts b/packages/experiments/src/lib/utils/isPrincipal.ts new file mode 100644 index 000000000..4a916c56d --- /dev/null +++ b/packages/experiments/src/lib/utils/isPrincipal.ts @@ -0,0 +1,11 @@ +import { Principal } from '@dfinity/principal' + +export function isPrincipal(p?: any) { + try { + if (!p) return false + const r = Principal.from(p) + return r._isPrincipal ? true : false + } catch (_) { + return false + } +} diff --git a/packages/experiments/src/lib/utils/isSafari.ts b/packages/experiments/src/lib/utils/isSafari.ts new file mode 100644 index 000000000..1a0cf798c --- /dev/null +++ b/packages/experiments/src/lib/utils/isSafari.ts @@ -0,0 +1,3 @@ +export function isiPhone() { + return /iPhone|iPod|iPad/.test(navigator.platform) +} diff --git a/packages/experiments/src/lib/utils/params.ts b/packages/experiments/src/lib/utils/params.ts new file mode 100644 index 000000000..da2a8d57d --- /dev/null +++ b/packages/experiments/src/lib/utils/params.ts @@ -0,0 +1,44 @@ +import { get } from 'svelte/store' +import { page } from '$app/stores' +import { authState, authHelper, referralId } from '$stores/auth' +import { isPrincipal } from './isPrincipal' +import { initializeAuthClient } from '$lib/helpers/auth' + +export async function handleParams() { + const pageStore = get(page) + if (!pageStore) return + + const showLogin = pageStore.url.searchParams.get('login') + + if (showLogin) { + const ogData = get(authState) + authState.set({ + ...ogData, + showLogin: true, + }) + } + + const refId = pageStore.url.searchParams.get('refId') + if (refId && isPrincipal(refId)) { + referralId.set({ + principalId: refId, + time: new Date().getTime(), + }) + } else { + const refStore = get(referralId) + if (refStore && refStore.time) { + const now = new Date().getTime() + if (now - refStore.time > 172800000) { + //older than two days + referralId.set({}) + } + } + } + + const logout = pageStore.url.searchParams.get('logout') + if (logout) { + const authHelperState = get(authHelper) + await authHelperState.client?.logout() + initializeAuthClient() + } +} diff --git a/packages/experiments/src/lib/utils/pluralize.ts b/packages/experiments/src/lib/utils/pluralize.ts new file mode 100644 index 000000000..7f8356271 --- /dev/null +++ b/packages/experiments/src/lib/utils/pluralize.ts @@ -0,0 +1,5 @@ +export function pluralize(word: string, count: number, suffix = 's') { + if (count > 1) { + return word + suffix + } else return word +} diff --git a/packages/experiments/src/lib/utils/randomUsername.ts b/packages/experiments/src/lib/utils/randomUsername.ts new file mode 100644 index 000000000..33100f07b --- /dev/null +++ b/packages/experiments/src/lib/utils/randomUsername.ts @@ -0,0 +1,28 @@ +import { adjectives, verbs } from './verbs-adjectives' + +export function generateRandomNumber(length: number, seed: number): string { + let r = Math.sin(seed) * 10 + r = r - Math.floor(r) + return Math.floor(r * Math.pow(10, length)) + .toString() + .padEnd(length, '0') +} + +export function generateRandomName(type: 'name' | 'username', seed?: string) { + if (!seed) { + seed = 'random' + } + const sum = seed.split('').reduce((acc, val) => val.charCodeAt(0) + acc, 0) + + const r1 = verbs[sum % verbs.length] + const r2 = adjectives[sum % adjectives.length] + + if (type === 'name') { + return `${r1} ${r2}` + } else { + return `${r1.toLowerCase()}-${r2.toLowerCase()}-${generateRandomNumber( + 4, + sum, + )}` + } +} diff --git a/packages/experiments/src/lib/utils/shortNumber.ts b/packages/experiments/src/lib/utils/shortNumber.ts new file mode 100644 index 000000000..98cc0b03d --- /dev/null +++ b/packages/experiments/src/lib/utils/shortNumber.ts @@ -0,0 +1,5 @@ +export function getShortNumber(number: number) { + if (number > 1000) { + return `${Math.round((number / 1000) * 10) / 10}K` + } else return number +} diff --git a/packages/experiments/src/lib/utils/sleep.ts b/packages/experiments/src/lib/utils/sleep.ts new file mode 100644 index 000000000..f243ac905 --- /dev/null +++ b/packages/experiments/src/lib/utils/sleep.ts @@ -0,0 +1,3 @@ +export default function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/packages/experiments/src/lib/utils/timeLeft.ts b/packages/experiments/src/lib/utils/timeLeft.ts new file mode 100644 index 000000000..d05b1a879 --- /dev/null +++ b/packages/experiments/src/lib/utils/timeLeft.ts @@ -0,0 +1,96 @@ +import type { SystemTime } from '$canisters/individual_user_template/individual_user_template.did' +import { readable } from 'svelte/store' + +const ONE_HOUR_MS = 36_00_000 +const SECONDS_MS = 1000 +const MINUTES_MS = SECONDS_MS * 60 +const HOURS_MS = MINUTES_MS * 60 +const DAYS_MS = HOURS_MS * 24 + +type TimeParts = { + minutes: number + seconds: number + days: number + hours: number +} + +export function getTimeLeft(startTime: Date, endTime: Date) { + let diff = endTime.getTime() - startTime.getTime() + if (diff > 0) { + return readable(getTimeStringFromMs(diff), (set) => { + const updateMs = () => { + diff -= 1000 + if (diff > 0) { + set(getTimeStringFromMs(diff)) + } else { + clearInterval(interval) + } + } + + const interval = setInterval(updateMs, 1000) + + return () => { + clearInterval(interval) + } + }) + } else { + return readable({ minutes: 0, seconds: 0, days: 0, hours: 0 }) + } +} + +export function getMsLeftForBetResult( + betSlotNumber: number, + createdAt: SystemTime, +) { + const betEndTime = new Date(Number(createdAt.secs_since_epoch) * 1000) + + betEndTime.setHours(betEndTime.getHours() + betSlotNumber) + + const now = new Date() + let diff = betEndTime.getTime() - now.getTime() + + if (diff > 0) { + const dt = getTimeStringFromMs(diff) + const initialValue = + dt.minutes + ':' + (dt.seconds < 10 ? '0' : '') + dt.seconds + + return readable(initialValue, (set) => { + let counter = 1 + const updateMs = () => { + if (diff - counter * 1000 > 0) { + const { minutes, seconds } = getTimeStringFromMs( + diff - counter * 1000, + ) + set(minutes + ':' + (seconds < 10 ? '0' : '') + seconds) + } else { + counter = 1 + diff = ONE_HOUR_MS + } + } + + const interval = setInterval(() => { + updateMs() + counter++ + }, 1000) + + return () => { + clearInterval(interval) + } + }) + } else { + return readable('') + } +} + +export function getTimeStringFromMs(timeMs: number) { + const days = Math.floor(timeMs / DAYS_MS) + const hours = Math.round((timeMs % DAYS_MS) / HOURS_MS) + const minutes = Math.floor((timeMs % HOURS_MS) / MINUTES_MS) + const seconds = Math.floor((timeMs % MINUTES_MS) / 1000) + return { + minutes, + seconds, + days, + hours, + } +} diff --git a/packages/experiments/src/lib/utils/verbs-adjectives.ts b/packages/experiments/src/lib/utils/verbs-adjectives.ts new file mode 100644 index 000000000..9ddde02b3 --- /dev/null +++ b/packages/experiments/src/lib/utils/verbs-adjectives.ts @@ -0,0 +1,350 @@ +export const verbs = [ + 'Adorable', + 'Adventurous', + 'Agreeable', + 'Alert', + 'Amused', + 'Angry', + 'Beautiful', + 'Better', + 'Bewildered', + 'Black', + 'Blue', + 'Blue-eyed', + 'Attractive', + 'Blushing', + 'Brainy', + 'Brave', + 'Bright', + 'Busy', + 'Buttery', + 'Calm', + 'Careful', + 'Cautious', + 'Charming', + 'Cheerful', + 'Clean', + 'Clear', + 'Clever', + 'Cloudy', + 'Clumsy', + 'Dark', + 'Defiant', + 'Delightful', + 'Determined', + 'Different', + 'Colorful', + 'Comfortable', + 'Condemned', + 'Confused', + 'Cool', + 'Cooperative', + 'Courageous', + 'Curious', + 'Cute', + 'Difficult', + 'Disturbed', + 'Dizzy', + 'Doubtful', + 'Eager', + 'Easy', + 'Elated', + 'Elegant', + 'Enchanting', + 'Encouraging', + 'Fair', + 'Faithful', + 'Famous', + 'Fancy', + 'Fantastic', + 'Fierce', + 'Fine', + 'Energetic', + 'Enthusiastic', + 'Excited', + 'Expensive', + 'Exuberant', + 'Fragile', + 'Frantic', + 'Friendly', + 'Funny', + 'Furry', + 'Gentle', + 'Gifted', + 'Glamorous', + 'Gleaming', + 'Glorious', + 'Good', + 'Handsome', + 'Happy', + 'Helpful', + 'Hilarious', + 'Gorgeous', + 'Graceful', + 'Grumpy', + 'Grungy', + 'Homely', + 'Hungry', + 'Important', + 'Innocent', + 'Inquisitive', + 'Lazy', + 'Light', + 'Lively', + 'Long', + 'Lovely', + 'Lucky', + 'Jittery', + 'Jolly', + 'Joyous', + 'Kind', + 'Magnificent', + 'Misty', + 'Modern', + 'Muddy', + 'Mushy', + 'Mysterious', + 'Naughty', + 'Nervous', + 'Nice', + 'Nutty', + 'Obedient', + 'Panicky', + 'Perfect', + 'Plain', + 'Pleasant', + 'Poised', + 'Powerful', + 'Old-fashioned', + 'Open', + 'Outrageous', + 'Outstanding', + 'Precious', + 'Prickly', + 'Proud', + 'Puzzled', + 'Quaint', + 'Real', + 'Relieved', + 'Rich', + 'Scary', + 'Shiny', + 'Shy', + 'Silly', + 'Sleepy', + 'Smiling', + 'Talented', + 'Tame', + 'Thankful', + 'Thoughtful', + 'Sparkling', + 'Splendid', + 'Spotless', + 'Stormy', + 'Strange', + 'Successful', + 'Super', + 'Svelte', + 'Tough', + 'Uninterested', + 'Unusual', + 'Useful', + 'Vast', + 'Victorious', + 'Vivacious', + 'Wandering', + 'Wide-eyed', + 'Wild', + 'Witty', + 'Worried', + 'Zany', +] + +export const adjectives = [ + 'Aardvark', + 'Alligator', + 'Alpaca', + 'Antelope', + 'Armadillo', + 'Ape', + 'Albatross', + 'Anaconda', + 'Badger', + 'Bald Eagle', + 'Bandicoot', + 'Barnacle', + 'Beaver', + 'Bison', + 'Butterfly', + 'Bulldog', + 'Baboon', + 'Beagle', + 'Bumblebee', + 'Bear', + 'Beetle', + 'Bluejay', + 'Bobcat', + 'Buffalo', + 'Chameleon', + 'Chimaera', + 'Chinook', + 'Chipmunk', + 'Cockatoo', + 'Cougar', + 'Crab', + 'Crocodile', + 'Crow', + 'Capybara', + 'Caribou', + 'Chimpanzee', + 'Coral', + 'Camel', + 'Caracal', + 'Cat', + 'Cheetah', + 'Chinchilla', + 'Coyote', + 'Cricket', + 'Dodo', + 'Dog', + 'Dragonfish', + 'Dragonfly', + 'Deer', + 'Dolphin', + 'Duck', + 'Eagle', + 'Elephant', + 'Emu', + 'Eel', + 'Falcon', + 'Frog', + 'Firefly', + 'Fangtooth', + 'Ferret', + 'Fox', + 'Gecko', + 'Gibbon', + 'Goat', + 'Goose', + 'Gazelle', + 'Gerbil', + 'Giraffe', + 'Gorilla', + 'Greyhound', + 'Hamster', + 'Hare', + 'Horse', + 'Hedgehog', + 'Hippopotamus', + 'Hornbill', + 'Hyena', + 'Honeybee', + 'Ibex', + 'Iguana', + 'Impala', + 'Jackrabbit', + 'Jaguar', + 'Jellyfish', + 'Kangaroo', + 'Kingfisher', + 'Kiwi', + 'Koala', + 'Krait', + 'Labrador ', + 'Ladybug', + 'Lemur', + 'Leopard', + 'Lion', + 'Lionfish', + 'Llama', + 'Lobster', + 'Macaque', + 'Manatee', + 'Mandrill', + 'Meerkat', + 'Millipede', + 'Mink', + 'Mongoose', + 'Monkey', + 'Moose', + 'Mouse', + 'Mule', + 'Myna', + 'Narwhal', + 'Newt', + 'Nightingale', + 'Nilgai', + 'Octopus', + 'Opossum', + 'Orangutan', + 'Ostrich', + 'Otter', + 'Owl', + 'Ox', + 'Oyster', + 'Panther', + 'Parakeet', + 'Parrot', + 'Peacock', + 'Pelican', + 'Penguin', + 'Piranha', + 'Platypus', + 'Polar Bear', + 'Porcupine', + 'Possum', + 'Pterodactyl', + 'Pufferfish', + 'Puma', + 'Python', + 'Quail', + 'Quokka', + 'Rabbit', + 'Raccoon', + 'Reindeer', + 'Rhinoceros', + 'Robin', + 'Rooster', + 'Salamander', + 'Scorpion', + 'Seagull', + 'Seahorse', + 'Seal', + 'Shark', + 'Sheep', + 'Skunk', + 'Sloth', + 'Snail', + 'Snake', + 'Snowy Owl', + 'Sparrow', + 'Spider', + 'Squid', + 'Squirrel', + 'Starfish', + 'Stingray', + 'Stork', + 'Swan', + 'Tasmanian Devil', + 'Tiger', + 'Tortoise', + 'Tuna', + 'Turkey', + 'Turtles', + 'T-Rex', + 'Venus Flytrap', + 'Viper', + 'Walrus', + 'Warthog', + 'Wasp', + 'Weasel', + 'Whale', + 'Wildebeest', + 'Wolf', + 'Wolverine', + 'Wombat', + 'Woodpecker', + 'Woodrat', + 'Yak', + 'Zebra', +] diff --git a/packages/experiments/src/lib/utils/video.ts b/packages/experiments/src/lib/utils/video.ts new file mode 100644 index 000000000..48d018161 --- /dev/null +++ b/packages/experiments/src/lib/utils/video.ts @@ -0,0 +1,43 @@ +import type { PostPopulated } from '$lib/helpers/feed' +import { getThumbnailUrl } from './cloudflare' + +export type VideoViewReport = { + progress: number + videoId: bigint + canisterId: string + profileId: string + count: number + score: bigint +} + +export function joinArrayUniquely( + a: PostPopulated[], + b: PostPopulated[], +): PostPopulated[] { + b.forEach((o) => { + const duplicates = a.findIndex( + (v) => + (v.post_id === o.post_id && + v.publisher_canister_id === o.publisher_canister_id) || + v.video_uid === o.video_uid, + ) + if (duplicates < 0) { + a.push(o) + } + }) + return a +} + +export function updateMetadata(video?: PostPopulated) { + if (!video) return + if (!('mediaSession' in navigator)) return + navigator.mediaSession.metadata = new MediaMetadata({ + title: video.description + '| Hot or Not', + artist: + video.created_by_display_name[0] || + video.created_by_unique_user_name[0] || + '', + album: 'Hot or Not', + artwork: [{ src: getThumbnailUrl(video.video_uid), type: 'image/png' }], + }) +} diff --git a/packages/experiments/src/params/videoId.ts b/packages/experiments/src/params/videoId.ts new file mode 100644 index 000000000..0bec5b630 --- /dev/null +++ b/packages/experiments/src/params/videoId.ts @@ -0,0 +1,13 @@ +import { isPrincipal } from '$lib/utils/isPrincipal' +import type { ParamMatcher } from '@sveltejs/kit' +export const match: ParamMatcher = (param: string) => { + if (!param.includes('@')) { + return false + } + const idArr = param.split('@') + if (idArr.length != 2 && !isPrincipal(idArr[0]) && isNaN(Number(idArr[1]))) { + return false + } + + return true +} diff --git a/packages/experiments/src/routes/(feed)/(splash)/+layout.svelte b/packages/experiments/src/routes/(feed)/(splash)/+layout.svelte new file mode 100644 index 000000000..ff456d034 --- /dev/null +++ b/packages/experiments/src/routes/(feed)/(splash)/+layout.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/packages/experiments/src/routes/(feed)/(splash)/up-down/+layout.svelte b/packages/experiments/src/routes/(feed)/(splash)/up-down/+layout.svelte new file mode 100644 index 000000000..de66c8f4e --- /dev/null +++ b/packages/experiments/src/routes/(feed)/(splash)/up-down/+layout.svelte @@ -0,0 +1,47 @@ + + + + Up Down | Hot or Not + + + + + {#if !walletPage} + + + + (resultPage = false)} href="/up-down" class="z-[2]"> + Up & Down Game + + (resultPage = true)} + href="/up-down/results" + class="z-[2] flex items-center space-x-2"> + Results + + + + {/if} + + + + + diff --git a/packages/experiments/src/routes/(feed)/(splash)/up-down/+page.ts b/packages/experiments/src/routes/(feed)/(splash)/up-down/+page.ts new file mode 100644 index 000000000..551c1dbb3 --- /dev/null +++ b/packages/experiments/src/routes/(feed)/(splash)/up-down/+page.ts @@ -0,0 +1,25 @@ +export const ssr = false + +import type { PageLoad } from './$types' +import { redirect } from '@sveltejs/kit' +import { postCache } from '$lib/helpers/backend' + +export const load: PageLoad = async ({ fetch }) => { + const res = await postCache( + fetch, + ).get_top_posts_aggregated_from_canisters_on_this_network_for_hot_or_not_feed( + BigInt(0), + BigInt(1), + ) + + if ('Ok' in res && res.Ok[0]) { + throw redirect( + 307, + `/up-down/${res.Ok[0].publisher_canister_id.toText()}@${ + res.Ok[0].post_id + }`, + ) + } else { + throw redirect(307, '/up-down/no-videos') + } +} diff --git a/packages/experiments/src/routes/(feed)/(splash)/up-down/[id=videoId]/+page.svelte b/packages/experiments/src/routes/(feed)/(splash)/up-down/[id=videoId]/+page.svelte new file mode 100644 index 000000000..38d0cd3ff --- /dev/null +++ b/packages/experiments/src/routes/(feed)/(splash)/up-down/[id=videoId]/+page.svelte @@ -0,0 +1,226 @@ + + + + Up Down | Hot or Not + + + + {#each videos as post, i (i)} + + {#if currentVideoIndex - 2 < i && currentVideoIndex + keepVideosLoadedCount > i} + + hideSplashScreen(500)} + on:watchedPercentage={({ detail }) => recordView(detail)} + on:videoUnavailable={() => handleUnavailableVideo(i)} + index={i} + playFormat="hls" + {Hls} + inView={i == currentVideoIndex && $playerState.visible} + uid={post.video_uid} /> + + + + + + {/if} + + {/each} + {#if showError} + + + + Error loading posts. Please, refresh the page. + + e.preventDefault()} + href="/hotornot"> + Clear here to refresh + + + + {/if} + {#if loading} + + + Loading + + + {/if} + {#if noMoreVideos} + + + + + There are no more videos to vote on + + + + + + + {/if} + diff --git a/packages/experiments/src/routes/(feed)/(splash)/up-down/[id=videoId]/+page.ts b/packages/experiments/src/routes/(feed)/(splash)/up-down/[id=videoId]/+page.ts new file mode 100644 index 000000000..b9b052f14 --- /dev/null +++ b/packages/experiments/src/routes/(feed)/(splash)/up-down/[id=videoId]/+page.ts @@ -0,0 +1,54 @@ +export const ssr = false +export const prerender = false + +import { Principal } from '@dfinity/principal' +import type { PageLoad } from './$types' +import type { PostPopulated } from '$lib/helpers/feed' +import { individualUser } from '$lib/helpers/backend' +import Log from '$lib/utils/Log' + +export const load: PageLoad = async ({ params, fetch }) => { + try { + const id = params.id.split('@') + const postId = BigInt(Number(id[1])) + const principal = Principal.from(id[0]) + let cachedPost: PostPopulated | undefined = undefined + + try { + const { idb } = await import('$lib/idb') + cachedPost = await idb.get('watch', params.id) + } catch (e) { + Log('error', 'Error while accessing IDB', { + error: e, + from: 'feedLoad', + type: 'idb', + }) + cachedPost = undefined + } + + if (cachedPost) { + return { post: cachedPost } + } else { + const r = await individualUser( + principal, + fetch, + ).get_individual_post_details_by_id(postId) + if (r.video_uid) { + return { + post: { + ...r, + publisher_canister_id: id[0], + created_by_user_principal_id: + r.created_by_user_principal_id.toText(), + post_id: postId, + score: BigInt(0), + } as PostPopulated, + } + } else { + return + } + } + } catch (e) { + return + } +} diff --git a/packages/experiments/src/routes/(feed)/(splash)/up-down/no-videos/+page.svelte b/packages/experiments/src/routes/(feed)/(splash)/up-down/no-videos/+page.svelte new file mode 100644 index 000000000..c0713c50b --- /dev/null +++ b/packages/experiments/src/routes/(feed)/(splash)/up-down/no-videos/+page.svelte @@ -0,0 +1,22 @@ + + + + + + There are no videos to vote on + + + + + diff --git a/packages/experiments/src/routes/(feed)/(splash)/up-down/results/+page.svelte b/packages/experiments/src/routes/(feed)/(splash)/up-down/results/+page.svelte new file mode 100644 index 000000000..9aff2cf4e --- /dev/null +++ b/packages/experiments/src/routes/(feed)/(splash)/up-down/results/+page.svelte @@ -0,0 +1,48 @@ + + + + + + + + + + + {username} + + + + Your vote + + {pluralize('Token', 100)} + + + + + 30:00 + + + + + + diff --git a/packages/experiments/src/routes/(feed)/(splash)/up-down/wallet/+page.svelte b/packages/experiments/src/routes/(feed)/(splash)/up-down/wallet/+page.svelte new file mode 100644 index 000000000..624f73bfe --- /dev/null +++ b/packages/experiments/src/routes/(feed)/(splash)/up-down/wallet/+page.svelte @@ -0,0 +1,52 @@ + + + + + + + + Welcome! + Autogenerated! + + + + + + Login + + Your coYn balance + 11,99,520 + + + Recent transactions + + {#each new Array(15) as _} + + + + + + + + + Won Up/Down Video + + 100 Coins + + + + + {/each} + + diff --git a/packages/experiments/src/routes/(feed)/+layout.svelte b/packages/experiments/src/routes/(feed)/+layout.svelte new file mode 100644 index 000000000..10670d9b3 --- /dev/null +++ b/packages/experiments/src/routes/(feed)/+layout.svelte @@ -0,0 +1,57 @@ + + + + + {#if pathname.includes('feed') || pathname.includes('hotornot')} + + + + + {:else if pathname.includes('menu')} + + Menu + + {/if} + + + + + + {#if !pathname.includes('hotornot') && !pathname.includes('up-down')} + + {/if} + + diff --git a/packages/experiments/src/routes/(feed)/+layout.ts b/packages/experiments/src/routes/(feed)/+layout.ts new file mode 100644 index 000000000..62ad4e4f4 --- /dev/null +++ b/packages/experiments/src/routes/(feed)/+layout.ts @@ -0,0 +1 @@ +export const ssr = false diff --git a/packages/experiments/src/routes/+error.svelte b/packages/experiments/src/routes/+error.svelte new file mode 100644 index 000000000..034c59986 --- /dev/null +++ b/packages/experiments/src/routes/+error.svelte @@ -0,0 +1,51 @@ + + + + + {#each statusCode as c} + {#if c == '0'} + + {:else} + + {c} + + {/if} + {/each} + + + Oh no! + Something went wrong. + + Let me take you to safety + + + Code: {status} + + + Message: {error} + + + + + diff --git a/packages/experiments/src/routes/+layout.svelte b/packages/experiments/src/routes/+layout.svelte new file mode 100644 index 000000000..1d97509d5 --- /dev/null +++ b/packages/experiments/src/routes/+layout.svelte @@ -0,0 +1,124 @@ + + + { + registerEvent('pwa_installed', { + canister_id: $authState.userCanisterId, + userId: $userProfile.principal_id, + }) + }} + on:beforeinstallprompt={(e) => { + deferredPrompt.set(e) + }} /> + + + + + Alpha + + +{#if $authState.showLogin} + +{/if} + + + + + +{#if GA} + +{/if} diff --git a/packages/experiments/src/routes/+layout.ts b/packages/experiments/src/routes/+layout.ts new file mode 100644 index 000000000..a78e99b85 --- /dev/null +++ b/packages/experiments/src/routes/+layout.ts @@ -0,0 +1 @@ +export const ssr = import.meta.env.ENABLE_SSR diff --git a/packages/experiments/src/routes/+page.ts b/packages/experiments/src/routes/+page.ts new file mode 100644 index 000000000..c20620d3e --- /dev/null +++ b/packages/experiments/src/routes/+page.ts @@ -0,0 +1,6 @@ +export const ssr = false + +import { redirect } from '@sveltejs/kit' +export const load = async () => { + throw redirect(307, '/up-down/') +} diff --git a/packages/experiments/src/routes/manifest.webmanifest/+server.ts b/packages/experiments/src/routes/manifest.webmanifest/+server.ts new file mode 100644 index 000000000..0d532ae51 --- /dev/null +++ b/packages/experiments/src/routes/manifest.webmanifest/+server.ts @@ -0,0 +1,36 @@ +export const prerender = true + +const config = { + backgroundColor: 'black', + description: '', + siteShortTitle: '', + siteTitle: '', + themeColor: '', +} + +export const GET = function get({ setHeaders }) { + const { + backgroundColor, + description, + siteShortTitle, + siteTitle, + themeColor, + } = config + const manifest = { + name: siteTitle, + short_name: siteShortTitle, + description, + start_url: '/', + background_color: backgroundColor, + theme_color: themeColor, + display: 'standalone', + icons: [ + { src: '/icon-192.png', type: 'image/png', sizes: '192x192' }, + { src: '/icon-512.png', type: 'image/png', sizes: '512x512' }, + ], + } + setHeaders({ + 'content-type': 'application/json', + }) + return new Response(JSON.stringify(manifest)) +} diff --git a/packages/experiments/src/service-worker.ts b/packages/experiments/src/service-worker.ts new file mode 100644 index 000000000..b284bb58a --- /dev/null +++ b/packages/experiments/src/service-worker.ts @@ -0,0 +1,54 @@ +/// + +import { version } from '$service-worker' + +const worker = self +const CACHE_NAME = `static-cache-${version}` + +// const to_cache = build.concat(files); + +// worker.addEventListener('install', (event) => { +// console.log('[ServiceWorker] Install', to_cache[0], event['test']); + +// event.waitUntil( +// caches.open(CACHE_NAME).then((cache) => { +// console.log('[ServiceWorker] Pre-caching offline page'); +// // return cache.addAll(to_cache).then(() => { +// // //@ts-ignore +// // worker.skipWaiting(); +// // }); +// }) +// ); +// }); + +worker.addEventListener('activate', (event) => { + console.log('[ServiceWorker] Activate') + // Remove previous cached data from disk + event.waitUntil( + caches.keys().then(async (keys) => + Promise.all( + keys.map((key) => { + if (key !== CACHE_NAME) { + console.log('[ServiceWorker] Removing old cache', key) + return caches.delete(key) + } + }), + ), + ), + ) + //@ts-ignore + worker.clients.claim() +}) + +self.addEventListener('fetch', (event) => { + if (event.request.mode !== 'navigate') { + return + } + event.respondWith( + fetch(event.request).catch(() => { + return caches.open(CACHE_NAME).then((cache) => { + return cache.match('offline.html') + }) + }), + ) +}) diff --git a/packages/experiments/src/stores/auth.ts b/packages/experiments/src/stores/auth.ts new file mode 100644 index 000000000..bd1c26900 --- /dev/null +++ b/packages/experiments/src/stores/auth.ts @@ -0,0 +1,28 @@ +import type { Identity } from '@dfinity/agent' +import type { AuthClient } from '@dfinity/auth-client' +import type { Principal } from '@dfinity/principal' +import { persisted } from 'svelte-local-storage-store' +import { writable } from 'svelte/store' + +export const authHelper = writable<{ + client?: AuthClient + identity?: Identity + idPrincipal?: Principal + userCanisterPrincipal?: Principal +}>({}) + +export const authState = persisted<{ + isLoggedIn: boolean + idString?: string + userCanisterId?: string + showLogin: boolean + t?: boolean +}>('auth-state', { + isLoggedIn: false, + showLogin: false, +}) + +export const referralId = persisted<{ + principalId?: string + time?: number +}>('referral-id', {}) diff --git a/packages/experiments/src/stores/deferredPrompt.ts b/packages/experiments/src/stores/deferredPrompt.ts new file mode 100644 index 000000000..87c54a9ae --- /dev/null +++ b/packages/experiments/src/stores/deferredPrompt.ts @@ -0,0 +1,5 @@ +import { writable } from 'svelte/store' + +export const deferredPrompt = writable( + undefined, +) diff --git a/packages/experiments/src/stores/fileUpload.ts b/packages/experiments/src/stores/fileUpload.ts new file mode 100644 index 000000000..52be3fe17 --- /dev/null +++ b/packages/experiments/src/stores/fileUpload.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store' + +export const fileToUpload = writable(null) diff --git a/packages/experiments/src/stores/loading.ts b/packages/experiments/src/stores/loading.ts new file mode 100644 index 000000000..05bc562df --- /dev/null +++ b/packages/experiments/src/stores/loading.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store' + +export const loadingAuthStatus = writable(true) diff --git a/packages/experiments/src/stores/navigation.ts b/packages/experiments/src/stores/navigation.ts new file mode 100644 index 000000000..a732e303d --- /dev/null +++ b/packages/experiments/src/stores/navigation.ts @@ -0,0 +1,5 @@ +import { persisted } from 'svelte-local-storage-store' +import { writable } from 'svelte/store' + +export const navigateBack = persisted('go-back', null) +export const navigationHistory = writable([]) diff --git a/packages/experiments/src/stores/permissions.ts b/packages/experiments/src/stores/permissions.ts new file mode 100644 index 000000000..73610aa4c --- /dev/null +++ b/packages/experiments/src/stores/permissions.ts @@ -0,0 +1,7 @@ +import { writable } from 'svelte/store' + +export const auth = writable<{ + camera: boolean + files: boolean + audio: boolean +}> diff --git a/packages/experiments/src/stores/playerState.ts b/packages/experiments/src/stores/playerState.ts new file mode 100644 index 000000000..8bb0afc3f --- /dev/null +++ b/packages/experiments/src/stores/playerState.ts @@ -0,0 +1,22 @@ +import type { PostPopulated } from '$lib/helpers/feed' +import { writable } from 'svelte/store' + +export const playerState = writable<{ + initialized: boolean + visible: boolean + muted: boolean + currentFeedUrl: string + currentHotOrNotUrl: string + selectedCoins: number +}>({ + initialized: false, + muted: true, + visible: true, + currentFeedUrl: '', + currentHotOrNotUrl: '', + selectedCoins: 10, +}) + +export const homeFeedVideos = writable([]) + +export const hotOrNotFeedVideos = writable([]) diff --git a/packages/experiments/src/stores/popups.ts b/packages/experiments/src/stores/popups.ts new file mode 100644 index 000000000..fa43e2523 --- /dev/null +++ b/packages/experiments/src/stores/popups.ts @@ -0,0 +1,28 @@ +import { writable, get } from 'svelte/store' +import { persisted } from 'svelte-local-storage-store' + +export const showOnboardingPopup = persisted('hot-or-not-ob', true) + +export const splashScreenPopup = writable<{ + show: boolean + shown: boolean +}>({ + show: true, + shown: false, +}) + +export const showAirdropPopup = writable(false) +export const shownAirdropPopup = persisted( + 'waitlist-form-submitted', + false, +) + +let splashScreenTimeOut: NodeJS.Timeout + +export function hideSplashScreen(timeoutMs: number = 2000) { + if (get(splashScreenPopup).shown) return + if (splashScreenTimeOut) clearTimeout(splashScreenTimeOut) + splashScreenTimeOut = setTimeout(() => { + splashScreenPopup.set({ show: false, shown: true }) + }, timeoutMs) +} diff --git a/packages/experiments/src/stores/userProfile.ts b/packages/experiments/src/stores/userProfile.ts new file mode 100644 index 000000000..fbd47b788 --- /dev/null +++ b/packages/experiments/src/stores/userProfile.ts @@ -0,0 +1,34 @@ +import { persisted } from 'svelte-local-storage-store' + +export type UserProfile = { + username_set: boolean + unique_user_name: string + profile_picture_url: string + display_name: string + principal_id?: string + followers_count: number + following_count: number + profile_stats: { + lifetime_earnings: number + hots_earned_count: number + nots_earned_count: number + } + updated_at: number +} + +export const emptyProfileValues = { + username_set: false, + unique_user_name: '', + profile_picture_url: '', + display_name: '', + followers_count: 0, + following_count: 0, + profile_stats: { + lifetime_earnings: 0, + hots_earned_count: 0, + nots_earned_count: 0, + }, + updated_at: Date.now(), +} + +export default persisted('user-profile', emptyProfileValues) diff --git a/packages/experiments/static/_headers b/packages/experiments/static/_headers new file mode 100644 index 000000000..eba534cd7 --- /dev/null +++ b/packages/experiments/static/_headers @@ -0,0 +1,5 @@ +/_app/* + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: no-referrer + Permissions-Policy: autoplay=*, camera=*, microphone=* \ No newline at end of file diff --git a/packages/experiments/static/assets/logos/logo-full-square-192.png b/packages/experiments/static/assets/logos/logo-full-square-192.png new file mode 100644 index 000000000..8151b4541 Binary files /dev/null and b/packages/experiments/static/assets/logos/logo-full-square-192.png differ diff --git a/packages/experiments/static/assets/logos/logo-full-square-512.png b/packages/experiments/static/assets/logos/logo-full-square-512.png new file mode 100644 index 000000000..e69be9950 Binary files /dev/null and b/packages/experiments/static/assets/logos/logo-full-square-512.png differ diff --git a/packages/experiments/static/assets/logos/logo-full.svg b/packages/experiments/static/assets/logos/logo-full.svg new file mode 100644 index 000000000..6138acf40 --- /dev/null +++ b/packages/experiments/static/assets/logos/logo-full.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/experiments/static/assets/logos/logo-symbol-alt-square-192.png b/packages/experiments/static/assets/logos/logo-symbol-alt-square-192.png new file mode 100644 index 000000000..3edaba40f Binary files /dev/null and b/packages/experiments/static/assets/logos/logo-symbol-alt-square-192.png differ diff --git a/packages/experiments/static/assets/logos/logo-symbol-alt-square-512.png b/packages/experiments/static/assets/logos/logo-symbol-alt-square-512.png new file mode 100644 index 000000000..870d08ae6 Binary files /dev/null and b/packages/experiments/static/assets/logos/logo-symbol-alt-square-512.png differ diff --git a/packages/experiments/static/assets/logos/logo-symbol-alt-square.svg b/packages/experiments/static/assets/logos/logo-symbol-alt-square.svg new file mode 100644 index 000000000..64887464c --- /dev/null +++ b/packages/experiments/static/assets/logos/logo-symbol-alt-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/experiments/static/assets/logos/logo-symbol-square-192.png b/packages/experiments/static/assets/logos/logo-symbol-square-192.png new file mode 100644 index 000000000..25407aff3 Binary files /dev/null and b/packages/experiments/static/assets/logos/logo-symbol-square-192.png differ diff --git a/packages/experiments/static/assets/logos/logo-symbol-square-512.png b/packages/experiments/static/assets/logos/logo-symbol-square-512.png new file mode 100644 index 000000000..cd03b58cd Binary files /dev/null and b/packages/experiments/static/assets/logos/logo-symbol-square-512.png differ diff --git a/packages/experiments/static/assets/logos/logo-symbol.svg b/packages/experiments/static/assets/logos/logo-symbol.svg new file mode 100644 index 000000000..7ff6781b7 --- /dev/null +++ b/packages/experiments/static/assets/logos/logo-symbol.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/experiments/static/assets/screenshots/0.jpg b/packages/experiments/static/assets/screenshots/0.jpg new file mode 100644 index 000000000..e72b61c90 Binary files /dev/null and b/packages/experiments/static/assets/screenshots/0.jpg differ diff --git a/packages/experiments/static/assets/screenshots/1.jpg b/packages/experiments/static/assets/screenshots/1.jpg new file mode 100644 index 000000000..70ec1e731 Binary files /dev/null and b/packages/experiments/static/assets/screenshots/1.jpg differ diff --git a/packages/experiments/static/assets/screenshots/2.jpg b/packages/experiments/static/assets/screenshots/2.jpg new file mode 100644 index 000000000..371f5d1b9 Binary files /dev/null and b/packages/experiments/static/assets/screenshots/2.jpg differ diff --git a/packages/experiments/static/favicon.ico b/packages/experiments/static/favicon.ico new file mode 100644 index 000000000..0fee3568d Binary files /dev/null and b/packages/experiments/static/favicon.ico differ diff --git a/packages/experiments/static/favicon.png b/packages/experiments/static/favicon.png new file mode 100644 index 000000000..244b3e59c Binary files /dev/null and b/packages/experiments/static/favicon.png differ diff --git a/packages/experiments/static/icons.sprite.svg b/packages/experiments/static/icons.sprite.svg new file mode 100644 index 000000000..757f7d38c --- /dev/null +++ b/packages/experiments/static/icons.sprite.svg @@ -0,0 +1,467 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/experiments/static/manifest.json b/packages/experiments/static/manifest.json new file mode 100644 index 000000000..2c0d56a7c --- /dev/null +++ b/packages/experiments/static/manifest.json @@ -0,0 +1,68 @@ +{ + "short_name": "Hot or Not Experiments", + "name": "Hot or Not Experiments", + "icons": [ + { + "src": "/assets/logos/logo-symbol-square-192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "/assets/logos/logo-full-square-512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": "/", + "orientation": "portrait", + "background_color": "#000", + "display": "standalone", + "scope": "/", + "theme_color": "#E96B25", + "shortcuts": [ + { + "name": "All Videos", + "short_name": "Videos", + "description": "View sizzling videos on Hot or Not", + "url": "/feed", + "icons": [ + { + "src": "/assets/logos/logo-symbol-square-192.png", + "type": "image/png", + "sizes": "192x192" + } + ] + }, + { + "name": "Hot or Not", + "short_name": "Videos", + "description": "Rate the videos hot or not", + "url": "/hot", + "icons": [ + { + "src": "/assets/logos/logo-symbol-alt-square-192.png", + "type": "image/png", + "sizes": "192x192" + } + ] + } + ], + "description": "The First App to Host Creative Short Video Challenges - Participate , Speculate, Gain Fame, Earn In Cryptocurrency", + "screenshots": [ + { + "src": "/assets/screenshots/0.jpg", + "type": "image/jpeg", + "sizes": "780x1688" + }, + { + "src": "/assets/screenshots/1.jpg", + "type": "image/jpeg", + "sizes": "780x1688" + }, + { + "src": "/assets/screenshots/2.jpg", + "type": "image/jpeg", + "sizes": "780x1688" + } + ] +} diff --git a/packages/experiments/static/offline.html b/packages/experiments/static/offline.html new file mode 100644 index 000000000..7e3eecd1f --- /dev/null +++ b/packages/experiments/static/offline.html @@ -0,0 +1,345 @@ + + + + + + + + You are offline + + + + + + + + + + + + + + + + + + + + You are offline + + + server down + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Click the button below to try reloading. + ⤾ Reload + + + + + diff --git a/packages/experiments/svelte.config.js b/packages/experiments/svelte.config.js new file mode 100644 index 000000000..df8d64cac --- /dev/null +++ b/packages/experiments/svelte.config.js @@ -0,0 +1,47 @@ +import staticAdapter from '@sveltejs/adapter-static' +import cfAdapter from '@sveltejs/adapter-cloudflare' +import preprocess from 'svelte-preprocess' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import cspDirectives from './directives.js' + +const isSSR = process.env.BUILD_MODE != 'static' +const isDev = process.env.NODE_ENV == 'dev' + +console.log( + 'svelte in', + isSSR ? 'ssr' : 'static', + 'build mode; csp enabled', + isDev, +) + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: preprocess({ + postcss: true, + }), + + kit: { + // csp: isDev + // ? undefined + // : { + // mode: 'hash', + // directives, + // }, + serviceWorker: { + register: false, + }, + files: { + assets: 'static', + hooks: { + server: './hooks/server.hooks.ts', + }, + }, + adapter: isSSR + ? cfAdapter() + : staticAdapter({ + fallback: 'index.html', + }), + }, +} + +export default config diff --git a/packages/experiments/tailwind.config.cjs b/packages/experiments/tailwind.config.cjs new file mode 100644 index 000000000..e721903d6 --- /dev/null +++ b/packages/experiments/tailwind.config.cjs @@ -0,0 +1,27 @@ +const config = { + content: ['./src/**/*.{html,js,svelte,ts}'], + + theme: { + extend: { + fontFamily: { + sans: ['sans-serif'], + }, + boxShadow: { + 'button-primary': '0px 4px 10px rgba(255, 113, 33, 0.2)', + 'up': [ + '0px -20px 25px -5px rgba(0,0,0,0.5)', + '0px -8px 10px -6px rgba(0,0,0,0.5)', + ], + }, + colors: { + primary: '#E96B25', + }, + animation: { + 'spin-slow': 'spin 3s linear infinite', + }, + }, + }, + plugins: [require('@tailwindcss/forms')], +} + +module.exports = config diff --git a/packages/experiments/tsconfig.json b/packages/experiments/tsconfig.json new file mode 100644 index 000000000..d4484d6c5 --- /dev/null +++ b/packages/experiments/tsconfig.json @@ -0,0 +1,38 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "ignoreDeprecations": "5.0", + "composite": true, + "noImplicitAny": false, + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"], + "$canisters/*": ["./declarations/*"], + "$components/*": ["./src/components/*"], + "$routes/*": ["./src/routes/*"], + "$icons/*": ["./src/icons/*"], + "$stores/*": ["./src/stores/*"], + "$assets/*": ["./src/assets/*"] + }, + "module": "ES2020", + "allowJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "importsNotUsedAsValues": "error", + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "outDir": "./", + "strict": true, + "types": ["gtag.js", "w3c-image-capture"] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.ts", + "src/**/*.js", + "src/**/*.svelte", + "declarations", + "tests/**/*.ts" + ], + "exclude": ["node_modules", "build"] +} diff --git a/packages/experiments/vite.config.ts b/packages/experiments/vite.config.ts new file mode 100644 index 000000000..dd1b8b7fa --- /dev/null +++ b/packages/experiments/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [sveltekit()], +})
Click the button below to try reloading.