├── mocks ├── data │ └── .gitkeep ├── node.ts └── browser.ts ├── app ├── stores │ ├── notifications.ts │ ├── search.ts │ └── user.ts ├── constants │ ├── pagination.ts │ ├── files.ts │ └── settings-section.ts ├── utils │ ├── cleanUrl.ts │ ├── tweetDeleted.ts │ ├── index.ts │ ├── date.ts │ ├── tweetLink.ts │ ├── public-routes.ts │ └── showToaster.ts ├── components │ ├── profile │ │ ├── skeletons │ │ │ ├── ProfileCoverSkeleton.vue │ │ │ ├── ProfileDetailsSkeleton.vue │ │ │ ├── ProfileAvatarSkeleton.vue │ │ │ └── ProfileInfoSkeleton.vue │ │ ├── ProfileDetails.vue │ │ ├── ProfileCover.vue │ │ ├── account-setup │ │ │ └── index.vue │ │ └── ProfileAvatarModal.vue │ ├── SideBar │ │ └── Right │ │ │ └── PreviewCard │ │ │ ├── item.vue │ │ │ └── index.vue │ ├── notifications │ │ ├── index.ts │ │ ├── QuoteMention.vue │ │ └── Reply.vue │ ├── ui │ │ ├── dialog │ │ │ ├── DialogClose.vue │ │ │ ├── DialogTrigger.vue │ │ │ ├── DialogFooter.vue │ │ │ ├── DialogHeader.vue │ │ │ ├── Dialog.vue │ │ │ ├── DialogTitle.vue │ │ │ ├── DialogDescription.vue │ │ │ └── DialogOverlay.vue │ │ ├── hover-card │ │ │ ├── HoverCardTrigger.vue │ │ │ └── HoverCard.vue │ │ ├── popover │ │ │ └── PopoverTrigger.vue │ │ ├── alert-dialog │ │ │ ├── AlertDialogFooter.vue │ │ │ ├── AlertDialogTrigger.vue │ │ │ ├── AlertDialogHeader.vue │ │ │ ├── AlertDialog.vue │ │ │ ├── AlertDialogTitle.vue │ │ │ ├── AlertDialogDescription.vue │ │ │ ├── AlertDialogAction.vue │ │ │ └── AlertDialogCancel.vue │ │ ├── Popover.vue │ │ ├── emoji-picker │ │ │ ├── index.ts │ │ │ ├── emoji-picker-category-header.vue │ │ │ ├── emoji-picker.vue │ │ │ ├── emoji-picker-emoji.vue │ │ │ └── emoji-picker-search.vue │ │ ├── dropdown-menu │ │ │ ├── DropdownMenuTrigger.vue │ │ │ └── DropdownMenu.vue │ │ ├── Tabs.vue │ │ ├── Button.vue │ │ ├── Spinner.vue │ │ ├── carousel │ │ │ ├── CarouselItem.vue │ │ │ ├── CarouselContent.vue │ │ │ └── interface.ts │ │ ├── AvatarImg.vue │ │ ├── form │ │ │ └── FieldInput.vue │ │ ├── Tab.vue │ │ ├── MuteToggleButton.vue │ │ ├── SearchList.vue │ │ ├── radio-group │ │ │ └── RadioGroup.vue │ │ ├── Toaster.vue │ │ ├── VideoPlayer.vue │ │ ├── BlockToggleButton.vue │ │ └── FollowToggleButton.vue │ ├── tweet │ │ ├── DeletedTweetPlaceholder.vue │ │ ├── QuotedTweetCard.vue │ │ ├── TweetMediaThumbnail.vue │ │ └── composer │ │ │ ├── PostTweetDialog.vue │ │ │ ├── ReplyTweetDialog.vue │ │ │ └── QuoteTweetDialog.vue │ ├── Logo │ │ └── Raven.vue │ ├── user │ │ └── UserHoverCard.vue │ ├── dm │ │ ├── conversation │ │ │ ├── input │ │ │ │ ├── MessageToolbar.vue │ │ │ │ ├── MessageSendButton.vue │ │ │ │ └── MessageAttachmentPreview.vue │ │ │ └── DmConversationHeader.vue │ │ ├── DmSearchBar.vue │ │ ├── DmHeader.vue │ │ └── DmConversationEmptyState.vue │ ├── search │ │ └── Hashtag.vue │ ├── auth │ │ └── login │ │ │ └── LoginDialog.vue │ ├── Settings │ │ ├── SettingsItem.vue │ │ └── SettingsSection │ │ │ └── index.vue │ └── explore │ │ └── Hashtag.vue ├── services │ ├── me │ │ └── meService.ts │ ├── auth │ │ ├── authService.ts │ │ ├── accountService.ts │ │ ├── loginService.ts │ │ └── passwordService.ts │ ├── explore │ │ └── exploreService.ts │ ├── tweet │ │ └── createTweetService.ts │ ├── notifications │ │ └── notificationsService.ts │ ├── home │ │ └── homeService.ts │ └── profile │ │ └── profileInteractionService.ts ├── pages │ ├── settings │ │ ├── index.vue │ │ ├── content-you-see.vue │ │ └── mute-and-block.vue │ ├── home │ │ └── index.vue │ ├── explore │ │ └── index.vue │ ├── bookmarks │ │ └── index.vue │ ├── search │ │ └── index.vue │ ├── messages │ │ ├── [conversationId] │ │ │ └── index.vue │ │ └── index.vue │ ├── media │ │ └── index.vue │ ├── playground │ │ ├── input.vue │ │ ├── mock-server.vue │ │ ├── carousel.vue │ │ ├── tweet.vue │ │ ├── protected-route.vue │ │ ├── avatars.vue │ │ ├── toaster.vue │ │ ├── radio-button.vue │ │ ├── tweets.vue │ │ └── tabs.vue │ └── profile │ │ ├── [username] │ │ ├── following.vue │ │ ├── followers.vue │ │ ├── followers-you-follow.vue │ │ └── status │ │ │ └── [tweetid] │ │ │ ├── likes.vue │ │ │ └── reposts.vue │ │ └── index.vue ├── plugins │ ├── videojs-player.client.ts │ ├── google-client.client.ts │ ├── recapcha.client.ts │ └── vue-query.ts ├── layouts │ ├── media.vue │ ├── notifications.vue │ ├── default.vue │ ├── home.vue │ └── explore.vue ├── composables │ ├── useUserProfile.ts │ ├── useIsCurrentUser.ts │ ├── useMyProfileQuery.ts │ ├── useSearchQuery.ts │ ├── useTheme.ts │ └── useNotificationSound.ts ├── schemas │ ├── username.ts │ └── auth.ts ├── middleware │ ├── profile-redirect.ts │ └── auth.global.ts └── api │ └── index.ts ├── .husky ├── pre-commit ├── pre-push └── commit-msg ├── public ├── robots.txt ├── favicon.ico └── sounds │ └── Raven.mp3 ├── .github └── CODEOWNERS ├── cypress ├── fixtures │ ├── settings │ │ ├── privacy_users.json │ │ └── user.json │ ├── auth │ │ ├── resetPwdUser.json │ │ └── existingUser.json │ └── profile │ │ ├── avatar.png │ │ └── banner.jpg ├── tsconfig.cypress.json ├── types │ └── ExtendedAUTWindow.ts └── support │ ├── helpers │ ├── createTestUser.ts │ └── loginViaAPI.ts │ └── e2e.ts ├── .prettierignore ├── .dockerignore ├── shared └── types │ ├── hashtag.ts │ ├── pagination.ts │ ├── leftsidebar.ts │ ├── interests.ts │ ├── oauth.ts │ ├── entity.ts │ ├── timeline.ts │ ├── search.ts │ ├── window.d.ts │ ├── notifications.ts │ ├── shared.ts │ ├── google.d.ts │ └── user.ts ├── docker-compose.yaml ├── server ├── schemas │ └── username.ts ├── api │ ├── me │ │ ├── index.get.ts │ │ ├── banner.delete.ts │ │ ├── banner.post.ts │ │ ├── index.patch.ts │ │ ├── profile-picture.post.ts │ │ ├── blocks │ │ │ └── [username] │ │ │ │ ├── index.post.ts │ │ │ │ └── index.delete.ts │ │ └── mutes │ │ │ └── [username] │ │ │ ├── index.delete.ts │ │ │ └── index.post.ts │ ├── tweets │ │ ├── index.post.ts │ │ ├── index.get.ts │ │ └── [id] │ │ │ ├── delete │ │ │ └── index.delete.ts │ │ │ ├── like │ │ │ ├── index.post.ts │ │ │ └── index.delete.ts │ │ │ ├── retweet │ │ │ ├── index.post.ts │ │ │ └── index.delete.ts │ │ │ ├── index.get.ts │ │ │ ├── quotes.get.ts │ │ │ ├── likes.get.ts │ │ │ ├── replies │ │ │ └── index.get.ts │ │ │ ├── retweets.get.ts │ │ │ └── summary │ │ │ └── index.get.ts │ ├── settings │ │ ├── interests │ │ │ ├── index.get.ts │ │ │ └── index.put.ts │ │ ├── mutes.get.ts │ │ ├── blocks.get.ts │ │ ├── email │ │ │ ├── resend-otp.post.ts │ │ │ ├── index.put.ts │ │ │ └── verify.post.ts │ │ ├── username │ │ │ ├── update.patch.ts │ │ │ └── suggestions.get.ts │ │ └── password │ │ │ └── index.put.ts │ ├── notifications │ │ ├── seen.patch.ts │ │ └── index.get.ts │ ├── explore │ │ ├── news.get.ts │ │ ├── sports.get.ts │ │ ├── for-you.get.ts │ │ ├── trending.get.ts │ │ └── entertainment.get.ts │ ├── auth │ │ ├── register │ │ │ ├── start.post.ts │ │ │ ├── verify.post.ts │ │ │ ├── resend-otp.post.ts │ │ │ └── complete.post.ts │ │ ├── password │ │ │ ├── resend-otp.post.ts │ │ │ ├── forgot │ │ │ │ ├── verify.post.ts │ │ │ │ └── index.post.ts │ │ │ └── reset.post.ts │ │ ├── check-email.get.ts │ │ ├── check-identifier.get.ts │ │ ├── login.post.ts │ │ ├── logout.post.ts │ │ └── refresh-token.post.ts │ ├── conversations │ │ ├── [conversationId] │ │ │ ├── index.get.ts │ │ │ └── messages │ │ │ │ ├── [messageId] │ │ │ │ └── index.delete.ts │ │ │ │ └── index.get.ts │ │ ├── with │ │ │ └── [username] │ │ │ │ └── index.post.ts │ │ └── index.get.ts │ ├── timeline │ │ ├── for-you.get.ts │ │ └── following.get.ts │ ├── media │ │ └── upload │ │ │ ├── gif.post.ts │ │ │ ├── image.post.ts │ │ │ └── video.post.ts │ ├── search │ │ ├── suggestions.get.ts │ │ ├── users │ │ │ ├── suggestions.get.ts │ │ │ └── index.get.ts │ │ └── tweets.get.ts │ ├── users │ │ ├── [username] │ │ │ ├── following.post.ts │ │ │ ├── following.delete.ts │ │ │ ├── likes.get.ts │ │ │ ├── media.get.ts │ │ │ ├── replies.get.ts │ │ │ ├── tweets.get.ts │ │ │ ├── mutual.get.ts │ │ │ ├── following.get.ts │ │ │ ├── followers.get.ts │ │ │ └── profile.get.ts │ │ └── id │ │ │ └── [id].get.ts │ ├── oauth │ │ ├── complete │ │ │ └── index.post.ts │ │ └── [provider] │ │ │ └── callback │ │ │ └── index.post.ts │ └── stream.get.ts ├── plugins │ ├── msw.server.ts │ └── undici.ts ├── utils │ └── handler.ts └── middleware │ └── auth.global.ts ├── pnpm-workspace.yaml ├── .prettierrc.json ├── .editorconfig ├── vitest.setup.ts ├── .env.example ├── test └── nuxt │ ├── utils │ └── index.spec.ts │ ├── components │ ├── ui │ │ ├── Checkbox.spec.ts │ │ └── Spinner.spec.ts │ ├── notifications │ │ └── QuoteMention.spec.ts │ ├── settings │ │ └── SettingsSection.spec.ts │ └── user │ │ └── UserHoverCard.spec.ts │ ├── pages │ ├── bookmarks.spec.ts │ ├── explore │ │ └── index.spec.ts │ ├── settings │ │ ├── index.spec.ts │ │ ├── privacy.spec.ts │ │ └── content-you-see.spec.ts │ ├── index.spec.ts │ ├── media │ │ └── index.spec.ts │ ├── home │ │ └── index.spec.ts │ └── messages.spec.ts │ ├── services │ └── auth │ │ └── accountService.spec.ts │ ├── server │ └── api │ │ ├── auth │ │ ├── check-email.get.spec.ts │ │ ├── password │ │ │ ├── resend-otp.post.spec.ts │ │ │ └── forgot │ │ │ │ └── verify.post.spec.ts │ │ └── register │ │ │ └── resend-otp.post.spec.ts │ │ └── me │ │ ├── banner.delete.spec.ts │ │ └── index.get.spec.ts │ └── App.spec.ts ├── i18n └── i18n.config.ts ├── cypress.config.ts ├── tsconfig.json ├── .gitignore ├── vitest.config.ts ├── eslint.config.mjs └── Dockerfile /mocks/data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/stores/notifications.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpx lint-staged -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /app/constants/pagination.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_PAGE_SIZE = 30; 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @AhmedSobhy01 @AmrSamy59 @im-saif @LoayAhmed304 2 | -------------------------------------------------------------------------------- /cypress/fixtures/settings/privacy_users.json: -------------------------------------------------------------------------------- 1 | ["gelgel", "notnowomar"] 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raven-swe/frontend/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/sounds/Raven.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raven-swe/frontend/HEAD/public/sounds/Raven.mp3 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .nuxt 2 | coverage 3 | dist 4 | node_modules 5 | public 6 | .husky 7 | .github 8 | pnpm-lock.yaml 9 | .pnpm-store -------------------------------------------------------------------------------- /cypress/fixtures/auth/resetPwdUser.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "mostafa@gmail.com", 3 | "newPassword": "NewPassword@123" 4 | } 5 | -------------------------------------------------------------------------------- /cypress/fixtures/profile/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raven-swe/frontend/HEAD/cypress/fixtures/profile/avatar.png -------------------------------------------------------------------------------- /cypress/fixtures/profile/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raven-swe/frontend/HEAD/cypress/fixtures/profile/banner.jpg -------------------------------------------------------------------------------- /app/utils/cleanUrl.ts: -------------------------------------------------------------------------------- 1 | export function cleanUrl(url: string): string { 2 | return url.replace(/^https?:\/\/(www\.)?/, ''); 3 | } 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .nuxt 2 | .output 3 | node_modules 4 | .vscode 5 | .husky 6 | .github 7 | /test 8 | 9 | .gitignore 10 | README.md -------------------------------------------------------------------------------- /shared/types/hashtag.ts: -------------------------------------------------------------------------------- 1 | export type TrendingHashtag = { 2 | hashtag: string; 3 | tweetsCount: number; 4 | category: string; 5 | }; 6 | -------------------------------------------------------------------------------- /shared/types/pagination.ts: -------------------------------------------------------------------------------- 1 | export type PaginationParams = { 2 | cursor: string | null; 3 | limit?: number | null | undefined; 4 | }; 5 | -------------------------------------------------------------------------------- /cypress/fixtures/settings/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": "mostafa", 3 | "email": "samym5468@gmail.com", 4 | "password": "Password@123" 5 | } 6 | -------------------------------------------------------------------------------- /mocks/node.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | import { handlers } from './handler'; 3 | 4 | export const server = setupServer(...handlers); 5 | -------------------------------------------------------------------------------- /app/components/profile/skeletons/ProfileCoverSkeleton.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw/browser'; 2 | import { handlers } from './handler'; 3 | 4 | export const worker = setupWorker(...handlers); 5 | -------------------------------------------------------------------------------- /shared/types/leftsidebar.ts: -------------------------------------------------------------------------------- 1 | export interface LeftSidebarTab { 2 | label: string; 3 | icon: string; 4 | route: string; 5 | badgeCount?: number; 6 | } 7 | -------------------------------------------------------------------------------- /cypress/tsconfig.cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["cypress"] 5 | }, 6 | "include": ["**/*.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /app/utils/tweetDeleted.ts: -------------------------------------------------------------------------------- 1 | export function isTweetDeleted(tweet: Tweet | DeletedTweet): tweet is DeletedTweet { 2 | return (tweet as DeletedTweet).isDeleted === true; 3 | } 4 | -------------------------------------------------------------------------------- /shared/types/interests.ts: -------------------------------------------------------------------------------- 1 | export type Interest = { 2 | code: string; // translation code ex. GAMING, MUSIC, ART 3 | name: string; // fallback name 4 | isSelected: boolean; 5 | }; 6 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | frontend: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | ports: 7 | - '5173:3000' 8 | env_file: 9 | - .env 10 | -------------------------------------------------------------------------------- /server/schemas/username.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const usernameParamsSchema = yup.object({ username: yup.string().required().min(3) }); 4 | 5 | export default usernameParamsSchema; 6 | -------------------------------------------------------------------------------- /app/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /app/services/me/meService.ts: -------------------------------------------------------------------------------- 1 | import { apiFetch } from '~/api'; 2 | 3 | export const meService = { 4 | fetchProfile: async () => { 5 | return await apiFetch>('/api/me'); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /app/pages/settings/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 11 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | ignoredBuiltDependencies: 2 | - msw 3 | - unrs-resolver 4 | - vue-demi 5 | 6 | onlyBuiltDependencies: 7 | - '@parcel/watcher' 8 | - '@tailwindcss/oxide' 9 | - esbuild 10 | - sharp 11 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "plugins": ["prettier-plugin-tailwindcss"], 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": true, 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /app/pages/home/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /app/components/SideBar/Right/PreviewCard/item.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /app/pages/explore/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /app/plugins/videojs-player.client.ts: -------------------------------------------------------------------------------- 1 | import VueVideoPlayer from '@videojs-player/vue'; 2 | import 'video.js/dist/video-js.css'; 3 | 4 | export default defineNuxtPlugin((nuxtApp) => { 5 | nuxtApp.vueApp.use(VueVideoPlayer); 6 | }); 7 | -------------------------------------------------------------------------------- /app/utils/date.ts: -------------------------------------------------------------------------------- 1 | export function formatMonthYear(isoDate: string): string { 2 | const date = new Date(isoDate); 3 | 4 | return date.toLocaleDateString('en-US', { 5 | month: 'long', 6 | year: 'numeric', 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /cypress/fixtures/auth/existingUser.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Omar Hassan", 3 | "email": "omarg@gmail.com", 4 | "dob": { 5 | "day": "4", 6 | "month": "12", 7 | "year": "2003" 8 | }, 9 | "password": "test1234" 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | end_of_line = lf 8 | max_line_length = 100 -------------------------------------------------------------------------------- /app/pages/bookmarks/index.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | // @vitest-environment node 2 | import { beforeAll, afterEach, afterAll } from 'vitest'; 3 | import { server } from './mocks/node.ts'; 4 | 5 | beforeAll(() => server.listen()); 6 | afterEach(() => server.resetHandlers()); 7 | afterAll(() => server.close()); 8 | -------------------------------------------------------------------------------- /app/layouts/media.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /app/utils/tweetLink.ts: -------------------------------------------------------------------------------- 1 | export function buildTweetLink(username: string, id: string, origin?: string): string { 2 | const base = 3 | origin !== undefined ? origin : typeof window !== 'undefined' ? window.location.origin : ''; 4 | return `${base}/profile/${username}/status/${id}`; 5 | } 6 | -------------------------------------------------------------------------------- /app/plugins/google-client.client.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin(() => { 2 | // Load GIS script 3 | const script = document.createElement('script'); 4 | script.src = 'https://accounts.google.com/gsi/client'; 5 | script.async = true; 6 | script.defer = true; 7 | document.head.appendChild(script); 8 | }); 9 | -------------------------------------------------------------------------------- /server/api/me/index.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const fetcher = serverApiFetch(event); 5 | 6 | const response = await fetcher>(`/me`); 7 | return response; 8 | }); 9 | -------------------------------------------------------------------------------- /app/pages/search/index.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /server/api/tweets/index.post.ts: -------------------------------------------------------------------------------- 1 | export default defineWrappedResponseHandler(async (event) => { 2 | const fetcher = serverApiFetch(event); 3 | const body = await readBody(event); 4 | const response = await fetcher>(`/tweets`, { 5 | method: 'POST', 6 | body, 7 | }); 8 | return response; 9 | }); 10 | -------------------------------------------------------------------------------- /app/components/notifications/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Base } from './Base.vue'; 2 | export { default as Follow } from './Follow.vue'; 3 | export { default as Like } from './Like.vue'; 4 | export { default as Reply } from './Reply.vue'; 5 | export { default as Repost } from './Repost.vue'; 6 | export { default as QuoteMention } from './QuoteMention.vue'; 7 | -------------------------------------------------------------------------------- /shared/types/oauth.ts: -------------------------------------------------------------------------------- 1 | export interface OAuthCallbackRequest { 2 | code: string; 3 | } 4 | 5 | export interface OAuthTokenRequest { 6 | creationToken: string; 7 | birthDate: string; 8 | } 9 | 10 | export type OAuthCallbackResponse = 11 | | { 12 | accessToken: string; 13 | } 14 | | { 15 | creationToken: string; 16 | }; 17 | -------------------------------------------------------------------------------- /server/api/settings/interests/index.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const fetcher = serverApiFetch(event); 5 | return await fetcher>(`/me/settings/interests`, { 6 | method: 'GET', 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogClose.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /server/api/me/banner.delete.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const fetcher = serverApiFetch(event); 5 | 6 | const response = await fetcher(`/me/banner`, { 7 | method: 'DELETE', 8 | }); 9 | 10 | return response; 11 | }); 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | BACKEND_URL=http://example.com 2 | NUXT_PUBLIC_RECAPTCHA_SITE_KEY=sitekey 3 | NUXT_PUBLIC_GOOGLE_CLIENT_ID=google-client-id 4 | NUXT_PUBLIC_GITHUB_CLIENT_ID=github-client-id 5 | NUXT_PUBLIC_GITHUB_SCOPE=github-scope 6 | NUXT_PUBLIC_GITHUB_REDIRECT_URI=https://raven.cmp27.space/oauth/github/bridge 7 | NUXT_PUBLIC_BASE_URL=http://localhost:5173 8 | NUXT_PUBLIC_USE_MOCKS=true -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogTrigger.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /app/utils/public-routes.ts: -------------------------------------------------------------------------------- 1 | export const publicRoutePatterns: RegExp[] = [ 2 | /^\/$/, 3 | /^\/password-reset(?:\/.*)?$/, 4 | /^\/auth(?:\/.*)?$/, 5 | /^\/profile(?:\/.*)?$/, 6 | /^\/terms-of-service$/, 7 | /^\/privacy-policy$/, 8 | ]; 9 | 10 | export const isPublicRoute = (path: string) => { 11 | return publicRoutePatterns.some((regex) => regex.test(path)); 12 | }; 13 | -------------------------------------------------------------------------------- /app/utils/showToaster.ts: -------------------------------------------------------------------------------- 1 | import { toast } from 'vue-sonner'; 2 | import { useNuxtApp } from '#app'; 3 | 4 | export function showToaster( 5 | type: 'success' | 'error' | 'warning' | 'info', 6 | message: string, 7 | translate = false, 8 | ) { 9 | const { $i18n } = useNuxtApp(); 10 | const text = translate ? $i18n.t(message) : message; 11 | 12 | toast[type](text); 13 | } 14 | -------------------------------------------------------------------------------- /app/components/profile/ProfileDetails.vue: -------------------------------------------------------------------------------- 1 | 6 | 13 | -------------------------------------------------------------------------------- /server/api/tweets/index.get.ts: -------------------------------------------------------------------------------- 1 | import type { Tweet } from '~~/shared/types/tweets'; 2 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const fetcher = serverApiFetch(event); 6 | return await fetcher>('/tweets', { 7 | method: 'GET', 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/nuxt/utils/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { cn } from '@/utils/index'; 3 | 4 | describe('Utility Function Tests', () => { 5 | it('cn function combines and merges class names correctly', () => { 6 | const result = cn('btn', 'btn-primary', 'rounded', 'btn'); 7 | expect(result).toBe('btn btn-primary rounded btn'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /app/components/tweet/DeletedTweetPlaceholder.vue: -------------------------------------------------------------------------------- 1 | 2 | 13 | -------------------------------------------------------------------------------- /server/api/notifications/seen.patch.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const fetcher = serverApiFetch(event); 5 | 6 | const response = await fetcher>('/notifications/seen', { 7 | method: 'PATCH', 8 | }); 9 | 10 | return response; 11 | }); 12 | -------------------------------------------------------------------------------- /app/components/ui/hover-card/HoverCardTrigger.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /app/components/ui/popover/PopoverTrigger.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /app/pages/messages/[conversationId]/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 14 | -------------------------------------------------------------------------------- /server/api/explore/news.get.ts: -------------------------------------------------------------------------------- 1 | import type { TrendingHashtag } from '~~/shared/types/hashtag'; 2 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const fetcher = serverApiFetch(event); 6 | return await fetcher>('/explore/news', { 7 | method: 'GET', 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /app/pages/media/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 16 | -------------------------------------------------------------------------------- /app/pages/messages/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 17 | -------------------------------------------------------------------------------- /i18n/i18n.config.ts: -------------------------------------------------------------------------------- 1 | export default defineI18nConfig(() => ({ 2 | legacy: false, 3 | pluralRules: { 4 | 'en-US': (choice: number, choicesLength: number): number => { 5 | return Math.max(0, Math.min(choice, choicesLength - 1)); 6 | }, 7 | 'ar-EG': (choice: number, choicesLength: number): number => { 8 | return Math.max(0, Math.min(choice, choicesLength - 1)); 9 | }, 10 | }, 11 | })); 12 | -------------------------------------------------------------------------------- /server/api/explore/sports.get.ts: -------------------------------------------------------------------------------- 1 | import type { TrendingHashtag } from '~~/shared/types/hashtag'; 2 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const fetcher = serverApiFetch(event); 6 | return await fetcher>('/explore/sports', { 7 | method: 'GET', 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogFooter.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogTrigger.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /server/api/explore/for-you.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const fetcher = serverApiFetch(event); 5 | return await fetcher>( 6 | '/explore/for-you', 7 | { 8 | method: 'GET', 9 | }, 10 | ); 11 | }); 12 | -------------------------------------------------------------------------------- /server/api/explore/trending.get.ts: -------------------------------------------------------------------------------- 1 | import type { TrendingHashtag } from '~~/shared/types/hashtag'; 2 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const fetcher = serverApiFetch(event); 6 | return await fetcher>('/explore/trending', { 7 | method: 'GET', 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /server/api/auth/register/start.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody(event); 5 | const fetcher = serverApiFetch(event); 6 | return await fetcher>('/auth/register/start', { 7 | method: 'POST', 8 | body, 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /server/api/auth/register/verify.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody(event); 5 | const fetcher = serverApiFetch(event); 6 | return await fetcher>('/auth/register/verify', { 7 | method: 'POST', 8 | body, 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /server/api/conversations/[conversationId]/index.get.ts: -------------------------------------------------------------------------------- 1 | export default defineWrappedResponseHandler(async (event) => { 2 | const params = event.context.params; 3 | const fetcher = serverApiFetch(event); 4 | const conversationId = params?.conversationId as string; 5 | const response = await fetcher>( 6 | `/conversations/${conversationId}`, 7 | ); 8 | return response; 9 | }); 10 | -------------------------------------------------------------------------------- /server/api/settings/interests/index.put.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody<{ interests: string[] }>(event); 5 | const fetcher = serverApiFetch(event); 6 | return await fetcher('/me/settings/interests', { 7 | method: 'PUT', 8 | body, 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /app/composables/useUserProfile.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '~~/shared/types/user'; 2 | 3 | export const useUserProfile = (username: string) => { 4 | const { 5 | data: userProfile, 6 | error, 7 | pending, 8 | } = useFetch(`/api/users/${username}/profile`, { 9 | key: `user-profile-${username}`, 10 | }); 11 | return { 12 | userProfile, 13 | error, 14 | loading: pending, 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /server/api/explore/entertainment.get.ts: -------------------------------------------------------------------------------- 1 | import type { TrendingHashtag } from '~~/shared/types/hashtag'; 2 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const fetcher = serverApiFetch(event); 6 | return await fetcher>('/explore/entertainment', { 7 | method: 'GET', 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /app/components/SideBar/Right/PreviewCard/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 15 | -------------------------------------------------------------------------------- /app/services/auth/authService.ts: -------------------------------------------------------------------------------- 1 | export function getAccessToken(): string | null { 2 | const cookie = useCookie('access_token'); 3 | return cookie.value || null; 4 | } 5 | 6 | export function clearAccessToken(): void { 7 | const cookie = useCookie('access_token'); 8 | cookie.value = null; 9 | } 10 | 11 | export function isAuthenticated(): boolean { 12 | const token = getAccessToken(); 13 | return Boolean(token); 14 | } 15 | -------------------------------------------------------------------------------- /server/api/auth/register/resend-otp.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody(event); 5 | const fetcher = serverApiFetch(event); 6 | return await fetcher>('/auth/register/resend-otp', { 7 | method: 'POST', 8 | body, 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /server/api/timeline/for-you.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const query = getQuery(event); 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher>('/timeline/for-you', { 7 | method: 'GET', 8 | query: query, 9 | }); 10 | return response; 11 | }); 12 | -------------------------------------------------------------------------------- /server/api/auth/password/resend-otp.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody(event); 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher('/auth/password/resend-otp', { 7 | method: 'POST', 8 | body, 9 | }); 10 | return response; 11 | }); 12 | -------------------------------------------------------------------------------- /server/api/media/upload/gif.post.ts: -------------------------------------------------------------------------------- 1 | export default defineWrappedResponseHandler(async (event) => { 2 | const fetcher = serverApiFetch(event); 3 | const body = await readBody(event); 4 | 5 | const response = await fetcher< 6 | ApiSuccessResponse<{ 7 | id: string; 8 | url: string; 9 | }> 10 | >(`/media/upload/gif`, { 11 | method: 'POST', 12 | body: body, 13 | }); 14 | 15 | return response; 16 | }); 17 | -------------------------------------------------------------------------------- /server/api/timeline/following.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const query = getQuery(event); 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher>('/timeline/following', { 7 | method: 'GET', 8 | query: query, 9 | }); 10 | return response; 11 | }); 12 | -------------------------------------------------------------------------------- /server/api/auth/password/forgot/verify.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody(event); 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher('/auth/password/forgot/verify', { 7 | method: 'POST', 8 | body, 9 | }); 10 | return response; 11 | }); 12 | -------------------------------------------------------------------------------- /server/api/notifications/index.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const query = getQuery(event); 5 | const fetcher = serverApiFetch(event); 6 | 7 | const response = await fetcher>('/notifications', { 8 | method: 'GET', 9 | query, 10 | }); 11 | 12 | return response; 13 | }); 14 | -------------------------------------------------------------------------------- /app/components/tweet/QuotedTweetCard.vue: -------------------------------------------------------------------------------- 1 | 9 | 13 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogFooter.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /server/api/search/suggestions.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const fetcher = serverApiFetch(event); 5 | const query = getQuery(event); 6 | 7 | return await fetcher>('/search/suggestions', { 8 | method: 'GET', 9 | query: { 10 | query: query.query, 11 | }, 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /app/components/profile/ProfileCover.vue: -------------------------------------------------------------------------------- 1 | 6 | 17 | -------------------------------------------------------------------------------- /app/components/profile/skeletons/ProfileDetailsSkeleton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /app/pages/playground/input.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | -------------------------------------------------------------------------------- /app/stores/search.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | 3 | export const useSearchStore = defineStore('search', { 4 | state: () => ({ 5 | searchQuery: '', 6 | excludeMutedAndBlocked: false, 7 | }), 8 | 9 | actions: { 10 | setexcludeMutedAndBlocked(value: boolean) { 11 | this.excludeMutedAndBlocked = value; 12 | }, 13 | setSearchQuery(value: string) { 14 | this.searchQuery = value; 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /server/plugins/msw.server.ts: -------------------------------------------------------------------------------- 1 | export default defineNitroPlugin(async (nitroApp) => { 2 | const config = useRuntimeConfig(); 3 | 4 | if (!import.meta.dev || !config.public.useMocks) return; 5 | 6 | const { server } = await import('../../mocks/node'); 7 | server.listen({ onUnhandledRequest: 'bypass' }); 8 | console.warn('Mock Service Worker (server) started'); 9 | 10 | nitroApp.hooks.hook('close', () => { 11 | server.close(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /app/components/Logo/Raven.vue: -------------------------------------------------------------------------------- 1 | 2 | 16 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /server/api/auth/check-email.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const query = getQuery<{ email: string }>(event); 5 | const fetcher = serverApiFetch(event); 6 | return await fetcher>('/auth/check-email', { 7 | method: 'GET', 8 | query: { 9 | email: query.email, 10 | }, 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /app/services/auth/accountService.ts: -------------------------------------------------------------------------------- 1 | import { apiFetch } from '~/api'; 2 | 3 | export const accountService = { 4 | async checkAccountExists(_identifier: string) { 5 | const response = await apiFetch>( 6 | '/api/auth/check-identifier', 7 | { 8 | method: 'GET', 9 | query: { identifier: _identifier }, 10 | }, 11 | ); 12 | return response.data.exists ?? false; 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /app/components/user/UserHoverCard.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | -------------------------------------------------------------------------------- /app/pages/playground/mock-server.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /app/components/notifications/QuoteMention.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | 16 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | baseUrl: 'http://localhost:3000', 6 | env: { 7 | API_URL: 'https://stress.api.raven.cmp27.space', 8 | }, 9 | }, 10 | viewportWidth: 1280, 11 | viewportHeight: 800, 12 | reporter: 'mochawesome', 13 | reporterOptions: { 14 | reportDir: 'cypress/results', 15 | overwrite: false, 16 | html: true, 17 | json: true, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /cypress/types/ExtendedAUTWindow.ts: -------------------------------------------------------------------------------- 1 | export type CaptchaParams = { 2 | callback?: (token: string) => void; 3 | }; 4 | 5 | export type ExtendedAUTWindow = Cypress.AUTWindow & { 6 | // To add Nuxt-specific types to Cypress AUTWindow 7 | useNuxtApp: () => { 8 | isHydrating: boolean; 9 | }; 10 | grecaptcha: { 11 | render: (container: string | HTMLElement, params: CaptchaParams) => string; 12 | reset: () => void; 13 | getResponse: () => string; 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "files": [], 4 | "references": [ 5 | { 6 | "path": "./.nuxt/tsconfig.app.json" 7 | }, 8 | { 9 | "path": "./.nuxt/tsconfig.server.json" 10 | }, 11 | { 12 | "path": "./.nuxt/tsconfig.shared.json" 13 | }, 14 | { 15 | "path": "./.nuxt/tsconfig.node.json" 16 | }, 17 | { 18 | "path": "./cypress/tsconfig.cypress.json" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /app/components/ui/Popover.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /shared/types/entity.ts: -------------------------------------------------------------------------------- 1 | export type MentionEntity = { 2 | username: string; 3 | startPosition: number; 4 | }; 5 | 6 | export type HashtagEntity = { 7 | hashtag: string; 8 | startPosition: number; 9 | }; 10 | 11 | export type ContentEntities = { 12 | mentions: MentionEntity[] | null; 13 | hashtags: HashtagEntity[] | null; 14 | }; 15 | 16 | export type ParsedToken = { 17 | type: 'mention' | 'hashtag' | 'link' | 'text'; 18 | value: string; 19 | display?: string; 20 | }; 21 | -------------------------------------------------------------------------------- /app/components/ui/emoji-picker/index.ts: -------------------------------------------------------------------------------- 1 | export { default as EmojiPicker } from './emoji-picker.vue'; 2 | export { default as EmojiPickerSearch } from './emoji-picker-search.vue'; 3 | export { default as EmojiPickerContent } from './emoji-picker-content.vue'; 4 | export { default as EmojiPickerFooter } from './emoji-picker-footer.vue'; 5 | export { default as EmojiPickerEmoji } from './emoji-picker-emoji.vue'; 6 | export { default as EmojiPickerCategoryHeader } from './emoji-picker-category-header.vue'; 7 | -------------------------------------------------------------------------------- /server/api/me/banner.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const fetcher = serverApiFetch(event); 5 | 6 | const response = await fetcher(`/me/banner`, { 7 | method: 'POST', 8 | body: event.node.req, 9 | headers: { 10 | 'content-type': event.node.req.headers['content-type']!, 11 | }, 12 | }); 13 | 14 | return response; 15 | }); 16 | -------------------------------------------------------------------------------- /server/api/me/index.patch.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const fetcher = serverApiFetch(event); 5 | 6 | const response = await fetcher>('/me', { 7 | method: 'PATCH', 8 | body: event.node.req, 9 | headers: { 10 | 'content-type': event.node.req.headers['content-type']!, 11 | }, 12 | }); 13 | 14 | return response; 15 | }); 16 | -------------------------------------------------------------------------------- /server/api/settings/mutes.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import type { CompactUser } from '~~/shared/types/user'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const fetcher = serverApiFetch(event); 6 | const query = getQuery(event); 7 | const response = await fetcher>(`/me/settings/mutes`, { 8 | method: 'GET', 9 | query, 10 | }); 11 | return response; 12 | }); 13 | -------------------------------------------------------------------------------- /server/api/auth/password/forgot/index.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody(event); 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher>( 7 | '/auth/password/forgot', 8 | { 9 | method: 'POST', 10 | body, 11 | }, 12 | ); 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /app/components/ui/dialog/Dialog.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /app/components/ui/emoji-picker/emoji-picker-category-header.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 20 | -------------------------------------------------------------------------------- /app/schemas/username.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const usernameRegex = /^[a-zA-Z0-9_]{3,15}$/; 4 | 5 | const getUsernameSchema = (t: (key: string) => string) => 6 | yup 7 | .string() 8 | .trim() 9 | .required(t('setting.username.username-required')) 10 | .min(3, t('setting.username.username-invalid')) 11 | .max(15, t('setting.username.username-invalid')) 12 | .matches(usernameRegex, t('setting.username.username-invalid')); 13 | 14 | export default getUsernameSchema; 15 | -------------------------------------------------------------------------------- /server/api/auth/password/reset.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody(event); 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher>( 7 | '/auth/password/reset', 8 | { 9 | method: 'POST', 10 | body, 11 | }, 12 | ); 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /server/api/me/profile-picture.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const fetcher = serverApiFetch(event); 5 | 6 | const response = await fetcher(`/me/profile-picture`, { 7 | method: 'POST', 8 | body: event.node.req, 9 | headers: { 10 | 'content-type': event.node.req.headers['content-type']!, 11 | }, 12 | }); 13 | 14 | return response; 15 | }); 16 | -------------------------------------------------------------------------------- /server/api/media/upload/image.post.ts: -------------------------------------------------------------------------------- 1 | export default defineWrappedResponseHandler(async (event) => { 2 | const fetcher = serverApiFetch(event); 3 | 4 | const response = await fetcher< 5 | ApiSuccessResponse<{ 6 | id: string; 7 | url: string; 8 | }> 9 | >(`/media/upload/image`, { 10 | method: 'POST', 11 | body: event.node.req, 12 | headers: { 13 | 'content-type': event.node.req.headers['content-type']!, 14 | }, 15 | }); 16 | 17 | return response; 18 | }); 19 | -------------------------------------------------------------------------------- /server/api/media/upload/video.post.ts: -------------------------------------------------------------------------------- 1 | export default defineWrappedResponseHandler(async (event) => { 2 | const fetcher = serverApiFetch(event); 3 | 4 | const response = await fetcher< 5 | ApiSuccessResponse<{ 6 | id: string; 7 | url: string; 8 | }> 9 | >(`/media/upload/video`, { 10 | method: 'POST', 11 | body: event.node.req, 12 | headers: { 13 | 'content-type': event.node.req.headers['content-type']!, 14 | }, 15 | }); 16 | 17 | return response; 18 | }); 19 | -------------------------------------------------------------------------------- /server/api/settings/blocks.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import type { CompactUser } from '~~/shared/types/user'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const fetcher = serverApiFetch(event); 6 | const query = getQuery(event); 7 | 8 | const response = await fetcher>(`/me/settings/blocks`, { 9 | method: 'GET', 10 | query, 11 | }); 12 | 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenuTrigger.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /app/schemas/auth.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | export const createPasswordSchema = (t: (key: string) => string) => 4 | yup 5 | .string() 6 | .min(10, t('errors.PASSWORD_TOO_SHORT')) 7 | .matches( 8 | /^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{10,}$/, 9 | t('errors.PASSWORD_INVALID'), 10 | ); 11 | 12 | export const createOtpSchema = (t: (key: string) => string) => 13 | yup.object({ 14 | otp: yup.string().matches(/^\d{6}$/, t('errors.OTP_MUST_6')), 15 | }); 16 | -------------------------------------------------------------------------------- /app/components/dm/conversation/input/MessageToolbar.vue: -------------------------------------------------------------------------------- 1 | 4 | 16 | -------------------------------------------------------------------------------- /server/api/conversations/with/[username]/index.post.ts: -------------------------------------------------------------------------------- 1 | import type { DmConversation } from '~~/shared/types/dm'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const params = event.context.params; 5 | const fetcher = serverApiFetch(event); 6 | const username = params?.username as string; 7 | const response = await fetcher>( 8 | `/conversations/with/${username}`, 9 | { 10 | method: 'POST', 11 | }, 12 | ); 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /server/api/search/users/suggestions.get.ts: -------------------------------------------------------------------------------- 1 | import type { CompactUser } from '~~/shared/types/user'; 2 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const fetcher = serverApiFetch(event); 6 | const query = getQuery(event); 7 | 8 | return await fetcher>('/search/users/suggestions', { 9 | method: 'GET', 10 | query: { 11 | query: query.query, 12 | }, 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /app/components/ui/Tabs.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 24 | -------------------------------------------------------------------------------- /server/api/settings/email/resend-otp.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody<{ confirmationToken: string }>(event); 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher('/me/settings/email/resend-otp', { 7 | method: 'POST', 8 | body: { 9 | confirmationToken: body.confirmationToken, 10 | }, 11 | }); 12 | return response; 13 | }); 14 | -------------------------------------------------------------------------------- /shared/types/timeline.ts: -------------------------------------------------------------------------------- 1 | export const validHomeTabs = ['following', 'for-you'] as const; 2 | export type HomeTab = (typeof validHomeTabs)[number]; 3 | 4 | export const validExploreTabs = ['trending', 'news', 'sports', 'entertainment'] as const; 5 | export type ExploreTab = (typeof validExploreTabs)[number]; 6 | 7 | export const validSearchTabs = ['top', 'latest', 'media', 'people'] as const; 8 | export type SearchTab = (typeof validSearchTabs)[number]; 9 | 10 | export const validSearchTweetsTabs = ['top', 'latest', 'media'] as const; 11 | -------------------------------------------------------------------------------- /app/components/profile/skeletons/ProfileAvatarSkeleton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /app/composables/useIsCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue'; 2 | import { useUserStore } from '@/stores/user'; 3 | 4 | export function useIsCurrentUser() { 5 | const router = useRouter(); 6 | const userStore = useUserStore(); 7 | 8 | const username = computed(() => 9 | router.currentRoute.value.params.username?.toString().toLowerCase(), 10 | ); 11 | 12 | const isCurrentUser = computed(() => { 13 | return userStore.user?.username.toLowerCase() === username.value; 14 | }); 15 | 16 | return { isCurrentUser }; 17 | } 18 | -------------------------------------------------------------------------------- /server/api/conversations/index.get.ts: -------------------------------------------------------------------------------- 1 | import type { DmConversation } from '~~/shared/types/dm'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const query = getQuery<{ cursor?: string; limit?: number }>(event); 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher>('/conversations', { 7 | method: 'GET', 8 | query: { 9 | cursor: query.cursor, 10 | limit: query.limit || 20, 11 | }, 12 | }); 13 | 14 | return response; 15 | }); 16 | -------------------------------------------------------------------------------- /server/api/conversations/[conversationId]/messages/[messageId]/index.delete.ts: -------------------------------------------------------------------------------- 1 | export default defineWrappedResponseHandler(async (event) => { 2 | const params = event.context.params; 3 | const conversationId = params?.conversationId as string; 4 | const messageId = params?.messageId as string; 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher( 7 | `/conversations/${conversationId}/messages/${messageId}`, 8 | { 9 | method: 'DELETE', 10 | }, 11 | ); 12 | return response; 13 | }); 14 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/delete/index.delete.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | 4 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 5 | 6 | export default defineWrappedResponseHandler(async (event) => { 7 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 8 | const fetcher = serverApiFetch(event); 9 | 10 | return await fetcher(`/tweets/${id}`, { 11 | method: 'DELETE', 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/like/index.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | 4 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 5 | 6 | export default defineWrappedResponseHandler(async (event) => { 7 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 8 | const fetcher = serverApiFetch(event); 9 | 10 | return await fetcher(`/tweets/${id}/like`, { 11 | method: 'POST', 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /app/middleware/profile-redirect.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async (to) => { 2 | if (to.path === '/profile' || to.path === '/profile/') { 3 | const userStore = useUserStore(); 4 | const username = userStore.user?.username; 5 | 6 | if (username) { 7 | return navigateTo(`/profile/${username}`); 8 | } else { 9 | console.error('Username not found in user store', userStore.user); 10 | // will never reach here for any authenticated user, but just in case 11 | return navigateTo('/'); 12 | } 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/like/index.delete.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | 4 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 5 | 6 | export default defineWrappedResponseHandler(async (event) => { 7 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 8 | const fetcher = serverApiFetch(event); 9 | 10 | return await fetcher(`/tweets/${id}/like`, { 11 | method: 'DELETE', 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/retweet/index.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | 4 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 5 | 6 | export default defineWrappedResponseHandler(async (event) => { 7 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 8 | const fetcher = serverApiFetch(event); 9 | 10 | return await fetcher(`/tweets/${id}/retweet`, { 11 | method: 'POST', 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /app/components/tweet/TweetMediaThumbnail.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /server/api/settings/email/index.put.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody<{ newEmail: string }>(event); 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher< 7 | ApiSuccessResponse<{ 8 | confirmationToken: string; 9 | }> 10 | >('/me/settings/email', { 11 | method: 'PUT', 12 | body: { 13 | newEmail: body.newEmail, 14 | }, 15 | }); 16 | return response; 17 | }); 18 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/index.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | 4 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 5 | 6 | export default defineWrappedResponseHandler(async (event) => { 7 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 8 | 9 | const fetcher = serverApiFetch(event); 10 | return await fetcher>(`/tweets/${id}`, { 11 | method: 'GET', 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/retweet/index.delete.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | 4 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 5 | 6 | export default defineWrappedResponseHandler(async (event) => { 7 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 8 | const fetcher = serverApiFetch(event); 9 | 10 | return await fetcher(`/tweets/${id}/retweet`, { 11 | method: 'DELETE', 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /shared/types/search.ts: -------------------------------------------------------------------------------- 1 | import type { PaginationParams } from '~~/shared/types/pagination'; 2 | import type { validSearchTweetsTabs } from '~~/shared/types/timeline'; 3 | 4 | export enum PeopleFilter { 5 | anyone = 'anyone', 6 | following = 'following', 7 | } 8 | 9 | export interface SearchQuery { 10 | pagination: PaginationParams; 11 | query: string; 12 | peopleFilter: PeopleFilter; 13 | excludeMutedAndBlocked: boolean; 14 | } 15 | 16 | export interface TweetsSearchQuery extends SearchQuery { 17 | tab: (typeof validSearchTweetsTabs)[number]; 18 | } 19 | -------------------------------------------------------------------------------- /app/components/ui/Button.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | -------------------------------------------------------------------------------- /server/api/auth/check-identifier.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | export default defineWrappedResponseHandler(async (event) => { 3 | const query = getQuery<{ identifier: string }>(event); 4 | const fetcher = serverApiFetch(event); 5 | const response = await fetcher>( 6 | '/auth/check-identifier', 7 | { 8 | method: 'GET', 9 | query: { 10 | identifier: query.identifier, 11 | }, 12 | }, 13 | ); 14 | return response; 15 | }); 16 | -------------------------------------------------------------------------------- /server/api/settings/email/verify.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const body = await readBody<{ otp: string; confirmationToken: string }>(event); 5 | const fetcher = serverApiFetch(event); 6 | const response = await fetcher('/me/settings/email/verify', { 7 | method: 'POST', 8 | body: { 9 | otp: body.otp, 10 | confirmationToken: body.confirmationToken, 11 | }, 12 | }); 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /test/nuxt/components/ui/Checkbox.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 3 | import Checkbox from '@/components/ui/Checkbox.vue'; 4 | 5 | describe('Checkbox Component', () => { 6 | it('renders successfully', async () => { 7 | const wrapper = await mountSuspended(Checkbox); 8 | expect(wrapper.exists()).toBe(true); 9 | expect(wrapper.attributes('data-slot')).toBe('checkbox'); 10 | const classes = wrapper.classes(); 11 | expect(classes).toContain('size-5'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /app/components/ui/hover-card/HoverCard.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialog.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /app/services/explore/exploreService.ts: -------------------------------------------------------------------------------- 1 | import { apiFetch } from '~/api'; 2 | import type { ExploreTab } from '~~/shared/types/timeline'; 3 | import type { TrendingHashtag } from '~~/shared/types/hashtag'; 4 | 5 | export const exploreService = { 6 | async getExploreTab(tab: ExploreTab) { 7 | return await apiFetch>(`/api/explore/${tab}`, { 8 | method: 'GET', 9 | }); 10 | }, 11 | 12 | async getCategorizedTweets() { 13 | return await apiFetch('/api/explore/for-you', { 14 | method: 'GET', 15 | }); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /server/api/me/blocks/[username]/index.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | 10 | const response = await fetcher(`/me/blocks/${username}`, { 11 | method: 'POST', 12 | }); 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /server/api/me/mutes/[username]/index.delete.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | 10 | const response = await fetcher(`/me/mutes/${username}`, { 11 | method: 'DELETE', 12 | }); 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /server/api/me/mutes/[username]/index.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | 10 | const response = await fetcher(`/me/mutes/${username}`, { 11 | method: 'POST', 12 | }); 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /server/api/settings/username/update.patch.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | export default defineWrappedResponseHandler(async (event) => { 3 | const query = getQuery<{ newUsername: string }>(event); 4 | const fetcher = serverApiFetch(event); 5 | const response = await fetcher>( 6 | '/me/settings/username', 7 | { 8 | method: 'PATCH', 9 | body: { 10 | newUsername: query.newUsername, 11 | }, 12 | }, 13 | ); 14 | return response; 15 | }); 16 | -------------------------------------------------------------------------------- /app/pages/playground/carousel.vue: -------------------------------------------------------------------------------- 1 | 2 | 17 | -------------------------------------------------------------------------------- /app/plugins/recapcha.client.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin(() => { 2 | if (!window.grecaptcha) { 3 | window.onRecaptchaLoad = () => { 4 | window.dispatchEvent(new Event('recaptcha-script-loaded')); 5 | }; 6 | 7 | const script = document.createElement('script'); 8 | script.src = 'https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit'; 9 | script.async = true; 10 | script.defer = true; 11 | script.onerror = () => console.error('Failed to load reCAPTCHA script'); 12 | document.head.appendChild(script); 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /server/api/me/blocks/[username]/index.delete.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | 10 | const response = await fetcher(`/me/blocks/${username}`, { 11 | method: 'DELETE', 12 | }); 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /server/api/users/[username]/following.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | 10 | const response = await fetcher(`/users/${username}/following`, { 11 | method: 'POST', 12 | }); 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /server/api/users/[username]/following.delete.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | 10 | const response = await fetcher(`/users/${username}/following`, { 11 | method: 'DELETE', 12 | }); 13 | return response; 14 | }); 15 | -------------------------------------------------------------------------------- /server/api/auth/login.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import { setAuthCookies } from '~~/server/utils/auth/setAuthCookies'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const body = await readBody(event); 6 | const fetcher = serverApiFetch(event); 7 | const response = await fetcher.raw>('/auth/login', { 8 | method: 'POST', 9 | body, 10 | credentials: 'include', 11 | }); 12 | 13 | setAuthCookies(event, response); 14 | return response._data; 15 | }); 16 | -------------------------------------------------------------------------------- /server/api/settings/username/suggestions.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | export default defineWrappedResponseHandler(async (event) => { 3 | const body = getQuery<{ 4 | baseUsername: string; 5 | }>(event); 6 | const fetcher = serverApiFetch(event); 7 | const response = await fetcher>( 8 | `/onboarding/username-suggestions`, 9 | { 10 | method: 'GET', 11 | query: { 12 | typed: body.baseUsername, 13 | }, 14 | }, 15 | ); 16 | return response; 17 | }); 18 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | branch_name=$(git rev-parse --abbrev-ref HEAD) 4 | 5 | # Allowed format: type/feature-name (kebab-case) 6 | branch_regex="^(feat|fix|build|chore|refactor|docs|perf|test)/[a-z0-9]+(-[a-z0-9]+)*$" 7 | 8 | if ! echo "$branch_name" | grep -Eq "$branch_regex"; then 9 | echo "Invalid branch name. Expected format: type/feature-name" 10 | echo "Allowed types: feat, fix, build, chore, refactor, docs, perf, test" 11 | echo "Example: feat/login-page" 12 | echo "Use 'git branch -m new-branch-name' to rename your branch and try again." 13 | exit 1 14 | fi 15 | 16 | exit 0 -------------------------------------------------------------------------------- /app/pages/playground/tweet.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /app/components/ui/dropdown-menu/DropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /app/components/dm/conversation/input/MessageSendButton.vue: -------------------------------------------------------------------------------- 1 | 5 | 16 | -------------------------------------------------------------------------------- /app/pages/playground/protected-route.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 28 | -------------------------------------------------------------------------------- /app/services/tweet/createTweetService.ts: -------------------------------------------------------------------------------- 1 | import type { CreateTweetRequest, Tweet } from '~~/shared/types/tweets'; 2 | import type { ApiSuccessResponse } from '~~/shared/types/api'; 3 | import { apiFetch } from '~/api'; 4 | 5 | export async function createTweetService(payload: CreateTweetRequest): Promise { 6 | try { 7 | const response = await apiFetch>('/api/tweets', { 8 | method: 'POST', 9 | body: payload, 10 | }); 11 | 12 | return response.data; 13 | } catch (error) { 14 | console.error('Failed to create tweet:', error); 15 | throw error; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | commit_message_file=$1 4 | commit_message=$(cat "$commit_message_file") 5 | 6 | # Conventional commit pattern: type(scope?): description 7 | conventional_regex="^(feat|fix|docs|build|style|refactor|perf|test|chore)(\([a-z0-9_-]+\))?: .+" 8 | 9 | if ! echo "$commit_message" | grep -Eq "$conventional_regex"; then 10 | echo "Invalid commit message format: type(scope?): description. While (scope) is optional." 11 | echo "Example: docs(openapi): update API documentation" 12 | echo "Allowed types: feat, fix, docs, build, style, refactor, perf, test, chore" 13 | exit 1 14 | fi 15 | 16 | exit 0 -------------------------------------------------------------------------------- /app/components/ui/Spinner.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | 26 | 27 | # vscode 28 | .vscode/* 29 | 30 | # ignore testing coverage folder 31 | /coverage 32 | 33 | # ignore mock data folder 34 | /mocks/data/* 35 | !/mocks/data/.gitkeep 36 | 37 | # Cypress screenshots and videos 38 | cypress/screenshots 39 | cypress/videos 40 | .vite-dev-cache 41 | 42 | docs 43 | cypress/results 44 | -------------------------------------------------------------------------------- /app/components/dm/DmSearchBar.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 21 | -------------------------------------------------------------------------------- /cypress/support/helpers/createTestUser.ts: -------------------------------------------------------------------------------- 1 | export interface TestUser { 2 | name: string; 3 | email: string; 4 | dob: { day: string; month: string; year: string }; 5 | password?: string; 6 | } 7 | 8 | export const createTestUser = (overrides: Partial = {}): TestUser => { 9 | const timestamp = Date.now(); 10 | return { 11 | name: overrides.name || `Test User ${timestamp}`, 12 | email: overrides.email || `testuser${timestamp}@example.com`, 13 | dob: overrides.dob || { day: '15', month: '6', year: '1995' }, 14 | password: overrides.password || 'TestPass123!', 15 | ...overrides, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /server/api/users/[username]/likes.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | const query = getQuery(event); 10 | 11 | const response = await fetcher>(`/users/${username}/likes`, { 12 | method: 'GET', 13 | query, 14 | }); 15 | return response; 16 | }); 17 | -------------------------------------------------------------------------------- /server/api/users/[username]/media.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | const query = getQuery(event); 10 | 11 | const response = await fetcher>(`/users/${username}/media`, { 12 | method: 'GET', 13 | query, 14 | }); 15 | return response; 16 | }); 17 | -------------------------------------------------------------------------------- /shared/types/window.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | interface Window { 5 | onRecaptchaLoad?: () => void; 6 | grecaptcha?: { 7 | render: ( 8 | element: string | HTMLElement, 9 | options: { 10 | sitekey: string; 11 | callback?: (token: string) => void; 12 | theme?: 'dark' | 'light'; 13 | size?: 'compact' | 'normal'; 14 | 'expired-callback'?: () => void; 15 | 'error-callback'?: () => void; 16 | hl?: string; 17 | }, 18 | ) => void; 19 | reset: (widgetId: string | undefined) => void; 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/api/users/[username]/replies.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | const query = getQuery(event); 10 | 11 | const response = await fetcher>(`/users/${username}/replies`, { 12 | method: 'GET', 13 | query, 14 | }); 15 | return response; 16 | }); 17 | -------------------------------------------------------------------------------- /server/api/users/[username]/tweets.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | const query = getQuery(event); 10 | 11 | const response = await fetcher>(`/users/${username}/tweets`, { 12 | method: 'GET', 13 | query, 14 | }); 15 | return response; 16 | }); 17 | -------------------------------------------------------------------------------- /app/constants/files.ts: -------------------------------------------------------------------------------- 1 | // images 2 | export const MAX_IMAGE_SIZE_MB = 5; 3 | export const MAX_IMAGE_SIZE_BYTES = MAX_IMAGE_SIZE_MB * 1024 * 1024; 4 | export const ALLOWED_IMAGE_TYPES = [ 5 | 'image/png', 6 | 'image/jpg', 7 | 'image/jpeg', 8 | 'image/webp', 9 | 'image/gif', 10 | ]; 11 | export const ALLOWED_IMAGE_TYPES_FOR_HTML = ALLOWED_IMAGE_TYPES.join(','); 12 | 13 | // videos 14 | export const MAX_VIDEO_SIZE_MB = 10; 15 | export const MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024; 16 | export const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/mov']; 17 | export const ALLOWED_VIDEO_TYPES_FOR_HTML = ALLOWED_VIDEO_TYPES.join(','); 18 | -------------------------------------------------------------------------------- /server/api/search/tweets.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const fetcher = serverApiFetch(event); 5 | const query = getQuery(event); 6 | 7 | return await fetcher>('/search/tweets', { 8 | method: 'GET', 9 | query: { 10 | query: query.query, 11 | tab: query.tab, 12 | limit: query.limit, 13 | cursor: query.cursor ?? undefined, 14 | peopleFilter: query.peopleFilter, 15 | excludeMutedAndBlocked: query.excludeMutedAndBlocked, 16 | }, 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/quotes.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | 4 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 5 | 6 | export default defineWrappedResponseHandler(async (event) => { 7 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 8 | const query = getQuery(event); 9 | const fetcher = serverApiFetch(event); 10 | const response = await fetcher>(`/tweets/${id}/quotes`, { 11 | method: 'GET', 12 | query: query, 13 | }); 14 | return response; 15 | }); 16 | -------------------------------------------------------------------------------- /server/api/auth/register/complete.post.ts: -------------------------------------------------------------------------------- 1 | import { setAuthCookies } from '~~/server/utils/auth/setAuthCookies'; 2 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const body = await readBody(event); 6 | const fetcher = serverApiFetch(event); 7 | const response = await fetcher.raw>( 8 | '/auth/register/complete', 9 | { 10 | method: 'POST', 11 | body, 12 | credentials: 'include', 13 | }, 14 | ); 15 | 16 | setAuthCookies(event, response); 17 | return response._data; 18 | }); 19 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/likes.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | 4 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 5 | 6 | export default defineWrappedResponseHandler(async (event) => { 7 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 8 | const fetcher = serverApiFetch(event); 9 | const query = getQuery(event); 10 | 11 | const response = await fetcher>(`/tweets/${id}/likes`, { 12 | method: 'GET', 13 | query, 14 | }); 15 | return response; 16 | }); 17 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/replies/index.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | 4 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 5 | 6 | export default defineWrappedResponseHandler(async (event) => { 7 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 8 | const query = getQuery(event); 9 | const fetcher = serverApiFetch(event); 10 | const response = await fetcher>(`/tweets/${id}/replies`, { 11 | method: 'GET', 12 | query: query, 13 | }); 14 | return response; 15 | }); 16 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/retweets.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | 4 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 5 | 6 | export default defineWrappedResponseHandler(async (event) => { 7 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 8 | const fetcher = serverApiFetch(event); 9 | const query = getQuery(event); 10 | 11 | const response = await fetcher>(`/tweets/${id}/retweets`, { 12 | method: 'GET', 13 | query, 14 | }); 15 | return response; 16 | }); 17 | -------------------------------------------------------------------------------- /app/pages/playground/avatars.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/nuxt/pages/bookmarks.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 3 | import BookmarksPage from '@/pages/bookmarks/index.vue'; 4 | 5 | describe('Bookmarks Page', () => { 6 | it('renders page with correct content', async () => { 7 | const wrapper = await mountSuspended(BookmarksPage); 8 | 9 | const html = wrapper.html(); 10 | expect(html).toContain('Bookmarks'); 11 | expect(wrapper.find('h1').exists()).toBe(true); 12 | expect(wrapper.find('h1').classes()).toContain('text-2xl'); 13 | expect(wrapper.find('h1').classes()).toContain('font-bold'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /shared/types/notifications.ts: -------------------------------------------------------------------------------- 1 | export type NotificationType = 'RETWEET' | 'LIKE' | 'FOLLOW' | 'REPLY' | 'QUOTE' | 'MENTION'; 2 | 3 | export type ActorSummary = { 4 | username: string; 5 | displayName: string; 6 | avatarUrl: string; 7 | }; 8 | 9 | export type ActorSummaryContainer = { 10 | previewActors?: ActorSummary[]; 11 | totalCount?: number; 12 | }; 13 | 14 | export type Notification = { 15 | id: string; 16 | type: NotificationType | string; 17 | latestEventAt?: string; 18 | actorSummary?: ActorSummaryContainer | null; 19 | isSeen?: boolean; 20 | tweetSummary?: { 21 | primaryTweet?: Record | null; 22 | } | null; 23 | }; 24 | -------------------------------------------------------------------------------- /server/api/settings/password/index.put.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | export default defineWrappedResponseHandler(async (event) => { 3 | const body = getQuery<{ 4 | currentPassword: string; 5 | newPassword: string; 6 | }>(event); 7 | const fetcher = serverApiFetch(event); 8 | const response = await fetcher< 9 | ApiSuccessResponse<{ 10 | success: boolean; 11 | message: string; 12 | }> 13 | >('/me/password', { 14 | method: 'PUT', 15 | body: { 16 | currentPassword: body.currentPassword, 17 | newPassword: body.newPassword, 18 | }, 19 | }); 20 | return response; 21 | }); 22 | -------------------------------------------------------------------------------- /app/services/notifications/notificationsService.ts: -------------------------------------------------------------------------------- 1 | import { apiFetch } from '~/api'; 2 | 3 | export const notificationsService = { 4 | getNotifications: async ({ 5 | cursor = null, 6 | limit = 20, 7 | filter = undefined, 8 | }: { 9 | cursor?: string | null; 10 | limit?: number; 11 | filter?: string; 12 | } = {}) => { 13 | return await apiFetch('/api/notifications', { 14 | method: 'GET', 15 | query: { 16 | cursor, 17 | limit: (limit ?? 20).toString(), 18 | filter, 19 | }, 20 | }); 21 | }, 22 | 23 | markAllSeen: async () => { 24 | return await apiFetch('/api/notifications/seen', { method: 'PATCH' }); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /server/plugins/undici.ts: -------------------------------------------------------------------------------- 1 | // Configure Undici (Node's native fetch) to support long-lived SSE connections 2 | // Without this, you'll get HeadersTimeoutError after 300s (default) 3 | import { setGlobalDispatcher, Agent } from 'undici'; 4 | 5 | export default defineNitroPlugin(() => { 6 | setGlobalDispatcher( 7 | new Agent({ 8 | // Disable timeouts for SSE streams that stay open indefinitely 9 | headersTimeout: 0, // No timeout waiting for initial headers 10 | bodyTimeout: 0, // No timeout for streaming body 11 | keepAliveTimeout: 60_000, // Keep TCP connections alive for 60s 12 | keepAliveMaxTimeout: 600_000, // Max keep-alive time: 10 minutes 13 | }), 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /app/services/home/homeService.ts: -------------------------------------------------------------------------------- 1 | import { apiFetch } from '~/api'; 2 | import type { HomeTab } from '~~/shared/types/timeline'; 3 | import { DEFAULT_PAGE_SIZE } from '~/constants/pagination'; 4 | 5 | export const homeService = { 6 | async getHomeTab({ 7 | tab, 8 | cursor, 9 | limit, 10 | signal, 11 | }: { 12 | tab: HomeTab; 13 | cursor: string | null; 14 | limit?: number; 15 | signal?: AbortSignal; 16 | }) { 17 | return await apiFetch(`/api/timeline/${tab}`, { 18 | method: 'GET', 19 | query: { 20 | limit: (limit ?? DEFAULT_PAGE_SIZE).toString(), 21 | cursor: cursor ?? undefined, 22 | }, 23 | signal, 24 | }); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /app/stores/user.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import type { User } from '~~/shared/types/user'; 3 | 4 | export const useUserStore = defineStore('user', { 5 | state: () => ({ 6 | user: null as User | null, 7 | }), 8 | 9 | getters: { 10 | isProfileSetup: (state) => 11 | state.user?.avatarUrl.includes('default_avatar') && !state.user?.bio ? false : true, 12 | }, 13 | 14 | actions: { 15 | updateUser(userData: Partial) { 16 | if (this.user) this.user = { ...this.user, ...userData }; 17 | }, 18 | 19 | setUser(userData: User) { 20 | this.user = userData; 21 | }, 22 | 23 | logout() { 24 | this.user = null; 25 | }, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /app/components/ui/carousel/CarouselItem.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 27 | -------------------------------------------------------------------------------- /server/api/users/[username]/mutual.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | import type { CompactUser } from '~~/shared/types/user'; 4 | 5 | export default defineWrappedResponseHandler(async (event) => { 6 | const { username } = await getValidatedRouterParams(event, (data) => 7 | usernameParamsSchema.validate(data), 8 | ); 9 | const fetcher = serverApiFetch(event); 10 | const query = getQuery(event); 11 | 12 | const response = await fetcher>(`/users/${username}/mutual`, { 13 | method: 'GET', 14 | query, 15 | }); 16 | 17 | return response; 18 | }); 19 | -------------------------------------------------------------------------------- /app/components/ui/carousel/CarouselContent.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 25 | -------------------------------------------------------------------------------- /server/api/auth/logout.post.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import type { ApiResponseBase } from '~~/shared/types/api'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const cookie = getHeader(event, 'cookie'); 6 | const fetcher = serverApiFetch(event); 7 | const response = await fetcher.raw('/auth/logout', { 8 | method: 'POST', 9 | credentials: 'include', 10 | headers: { 11 | ...(cookie ? { cookie } : {}), // Forward client cookies 12 | }, 13 | }); 14 | deleteCookie(event, 'access_token', { path: '/' }); 15 | deleteCookie(event, 'refreshToken', { path: '/' }); 16 | return response._data; 17 | }); 18 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogTitle.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /app/components/search/Hashtag.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 23 | -------------------------------------------------------------------------------- /app/components/ui/AvatarImg.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 35 | -------------------------------------------------------------------------------- /app/constants/settings-section.ts: -------------------------------------------------------------------------------- 1 | export const settingSections = [ 2 | { 3 | title: 'setting.account-information', 4 | route: '/settings/account', 5 | cy: 'account-settings-btn', 6 | }, 7 | { 8 | title: 'setting.privacy-settings.title', 9 | route: '/settings/privacy', 10 | cy: 'privacy-settings-btn', 11 | }, 12 | { 13 | title: 'setting.display.title', 14 | route: '/settings/display', 15 | cy: 'display-settings-btn', 16 | }, 17 | { 18 | title: 'legal.terms.title', 19 | route: '/terms-of-service', 20 | cy: 'terms-of-service-btn', 21 | }, 22 | { 23 | title: 'legal.privacy.title', 24 | route: '/privacy-policy', 25 | cy: 'privacy-policy-btn', 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /app/components/dm/DmHeader.vue: -------------------------------------------------------------------------------- 1 | 9 | 23 | -------------------------------------------------------------------------------- /app/composables/useMyProfileQuery.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/vue-query'; 2 | import { meService } from '~/services/me/meService'; 3 | 4 | export default function useMyProfileQuery() { 5 | const userStore = useUserStore(); 6 | 7 | const query = useQuery({ 8 | queryKey: ['layout-data'], 9 | queryFn: async () => { 10 | const response = await meService.fetchProfile(); 11 | if (response.success) userStore.setUser(response.data); 12 | return response; 13 | }, 14 | refetchOnMount: false, 15 | refetchOnWindowFocus: false, 16 | staleTime: 1000 * 60 * 10, // 10 minutes 17 | }); 18 | 19 | onServerPrefetch(async () => { 20 | await query.suspense(); 21 | }); 22 | return query; 23 | } 24 | -------------------------------------------------------------------------------- /cypress/support/helpers/loginViaAPI.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Login via API request (fastest approach) 3 | * This bypasses the UI entirely and sets auth tokens directly 4 | */ 5 | export function loginViaAPI(email: string, password: string) { 6 | return cy 7 | .request({ 8 | method: 'POST', 9 | url: `${Cypress.env('API_URL')}/auth/login`, 10 | body: { 11 | email, 12 | password, 13 | }, 14 | }) 15 | .then((response) => { 16 | // Store auth token in localStorage/cookies as your app expects 17 | window.localStorage.setItem('auth-token', response.body.token); 18 | // Or set cookies if your app uses cookies 19 | // cy.setCookie('auth-token', response.body.token); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /app/components/ui/emoji-picker/emoji-picker.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 27 | -------------------------------------------------------------------------------- /server/api/users/[username]/following.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | import type { CompactUser } from '~~/shared/types/user'; 4 | 5 | export default defineWrappedResponseHandler(async (event) => { 6 | const { username } = await getValidatedRouterParams(event, (data) => 7 | usernameParamsSchema.validate(data), 8 | ); 9 | const fetcher = serverApiFetch(event); 10 | const query = getQuery(event); 11 | 12 | const response = await fetcher>( 13 | `/users/${username}/following`, 14 | { 15 | method: 'GET', 16 | query, 17 | }, 18 | ); 19 | return response; 20 | }); 21 | -------------------------------------------------------------------------------- /app/components/ui/form/FieldInput.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 22 | -------------------------------------------------------------------------------- /server/api/oauth/complete/index.post.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isOAuthResponseWithAccessToken, 3 | setAuthCookies, 4 | } from '~~/server/utils/auth/setAuthCookies'; 5 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 6 | 7 | export default defineWrappedResponseHandler(async (event) => { 8 | const body = await readBody(event); 9 | const fetcher = serverApiFetch(event); 10 | 11 | const response = await fetcher.raw>('/oauth/complete', { 12 | method: 'POST', 13 | body, 14 | credentials: 'include', 15 | }); 16 | 17 | if (isOAuthResponseWithAccessToken(response)) { 18 | setAuthCookies(event, response); 19 | } 20 | 21 | return response._data; 22 | }); 23 | -------------------------------------------------------------------------------- /server/api/auth/refresh-token.post.ts: -------------------------------------------------------------------------------- 1 | import { setAuthCookies } from '~~/server/utils/auth/setAuthCookies'; 2 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const clientCookie = getHeader(event, 'cookie'); 6 | const fetcher = serverApiFetch(event); 7 | const response = await fetcher.raw>( 8 | '/auth/refresh-token', 9 | { 10 | method: 'POST', 11 | credentials: 'include', 12 | headers: { 13 | ...(clientCookie ? { cookie: clientCookie } : {}), // Forward client cookies 14 | }, 15 | }, 16 | ); 17 | 18 | setAuthCookies(event, response); 19 | return response._data; 20 | }); 21 | -------------------------------------------------------------------------------- /server/api/conversations/[conversationId]/messages/index.get.ts: -------------------------------------------------------------------------------- 1 | import type { DmConversationMessagesResponse } from '~~/shared/types/dm'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const params = event.context.params; 5 | const query = getQuery<{ cursor?: string; limit?: number }>(event); 6 | const conversationId = params?.conversationId as string; 7 | const fetcher = serverApiFetch(event); 8 | const response = await fetcher>( 9 | `/conversations/${conversationId}/messages`, 10 | { 11 | method: 'GET', 12 | query: { 13 | cursor: query.cursor, 14 | limit: query.limit || 20, 15 | }, 16 | }, 17 | ); 18 | return response; 19 | }); 20 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogDescription.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /app/components/ui/emoji-picker/emoji-picker-emoji.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 32 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogTitle.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /server/api/tweets/[id]/summary/index.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import * as yup from 'yup'; 3 | const paramsSchema = yup.object({ id: yup.string().required().min(1) }); 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { id } = await getValidatedRouterParams(event, (data) => paramsSchema.validate(data)); 6 | const query = getQuery<{ 7 | locale: string; 8 | }>(event); 9 | const fetcher = serverApiFetch(event); 10 | const response = await fetcher>( 11 | `/tweets/${id}/summary`, 12 | { 13 | method: 'GET', 14 | query: { 15 | locale: query.locale, 16 | }, 17 | }, 18 | ); 19 | return response; 20 | }); 21 | -------------------------------------------------------------------------------- /server/api/stream.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | export default defineWrappedResponseHandler(async (event) => { 3 | const query = getQuery(event); 4 | const fetcher = serverApiFetch(event); 5 | const upstream = await fetcher('/stream', { 6 | method: 'GET', 7 | query, 8 | headers: { 9 | Accept: 'text/event-stream', 10 | }, 11 | responseType: 'stream', 12 | }); 13 | 14 | // Prepare SSE response headers for the client 15 | setHeader(event, 'Content-Type', 'text/event-stream'); 16 | setHeader(event, 'Cache-Control', 'no-cache, no-transform'); 17 | setHeader(event, 'Connection', 'keep-alive'); 18 | setHeader(event, 'X-Accel-Buffering', 'no'); 19 | 20 | return sendStream(event, upstream); 21 | }); 22 | -------------------------------------------------------------------------------- /server/api/search/users/index.get.ts: -------------------------------------------------------------------------------- 1 | import type { CompactUser } from '~~/shared/types/user'; 2 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const fetcher = serverApiFetch(event); 6 | const query = getQuery(event); 7 | 8 | const response = await fetcher>('/search/users', { 9 | method: 'GET', 10 | query: { 11 | query: query.query, 12 | limit: query.limit, 13 | cursor: query.cursor ?? undefined, 14 | peopleFilter: query.peopleFilter, 15 | excludeMutedAndBlocked: query.excludeMutedAndBlocked, 16 | }, 17 | }); 18 | 19 | return { 20 | ...response, 21 | data: response.data.users, 22 | }; 23 | }); 24 | -------------------------------------------------------------------------------- /server/api/users/id/[id].get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | 3 | export default defineWrappedResponseHandler(async (event) => { 4 | const { id } = event.context.params as { id: string }; 5 | const fetcher = serverApiFetch(event); 6 | 7 | const response = await fetcher>(`/users/${id}/profile`, { 8 | method: 'GET', 9 | }); 10 | 11 | // note that this is temporary until the real backend implementation is done 12 | const modifiedResponse: ApiSuccessResponse<{ 13 | username: string; 14 | displayName: string; 15 | }> = { 16 | ...response, 17 | data: { 18 | username: response.data.username, 19 | displayName: response.data.displayName, 20 | }, 21 | }; 22 | return modifiedResponse; 23 | }); 24 | -------------------------------------------------------------------------------- /app/components/ui/Tab.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 27 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogDescription.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 24 | -------------------------------------------------------------------------------- /app/composables/useSearchQuery.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import { useRoute, useRouter } from 'vue-router'; 3 | 4 | export const useSearchQuery = () => { 5 | const route = useRoute(); 6 | const router = useRouter(); 7 | const searchQuery = ref(''); 8 | 9 | const initializeFromRoute = () => { 10 | const queryParam = route.query.q; 11 | if (typeof queryParam === 'string') { 12 | searchQuery.value = queryParam; 13 | } 14 | }; 15 | 16 | const navigateToSearch = (query: string, tab: string = 'top') => { 17 | if (!query.trim()) return; 18 | 19 | router.push({ 20 | path: `/search/${tab}`, 21 | query: { q: query.trim() }, 22 | }); 23 | }; 24 | 25 | return { 26 | searchQuery, 27 | initializeFromRoute, 28 | navigateToSearch, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /app/components/tweet/composer/PostTweetDialog.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 29 | -------------------------------------------------------------------------------- /app/composables/useTheme.ts: -------------------------------------------------------------------------------- 1 | export function useTheme() { 2 | const themeCookie = useCookie('theme-mode', { 3 | maxAge: 60 * 60 * 24 * 365, // 1 year 4 | default: () => 'light', 5 | }); 6 | 7 | const primaryCookie = useCookie('theme-primary', { 8 | maxAge: 60 * 60 * 24 * 365, 9 | default: () => '#1d9bf0', 10 | }); 11 | 12 | const setTheme = (mode: 'light' | 'dark') => { 13 | themeCookie.value = mode; 14 | }; 15 | 16 | const setPrimary = (color: string) => { 17 | primaryCookie.value = color; 18 | }; 19 | 20 | useHead(() => ({ 21 | htmlAttrs: { 22 | class: themeCookie.value === 'dark' ? 'dark' : '', 23 | style: { 24 | '--primary': primaryCookie.value, 25 | }, 26 | }, 27 | })); 28 | 29 | return { themeCookie, setTheme, primaryCookie, setPrimary }; 30 | } 31 | -------------------------------------------------------------------------------- /test/nuxt/pages/explore/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 3 | import IndexPage from '~/pages/explore/index.vue'; 4 | 5 | const mockNavigateTo = vi.fn(); 6 | 7 | vi.mock('#app', async () => { 8 | const actual = await vi.importActual('#app'); 9 | return { 10 | ...actual, 11 | navigateTo: (...args: unknown[]) => mockNavigateTo(...args), 12 | }; 13 | }); 14 | 15 | describe('Explore index.vue', () => { 16 | beforeEach(() => { 17 | vi.clearAllMocks(); 18 | }); 19 | 20 | it('redirects to /explore/for-you', async () => { 21 | await mountSuspended(IndexPage, { 22 | route: '/explore', 23 | }); 24 | 25 | expect(mockNavigateTo).toHaveBeenCalledWith('/explore/for-you', { redirectCode: 301 }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /app/pages/playground/toaster.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 28 | -------------------------------------------------------------------------------- /app/services/auth/loginService.ts: -------------------------------------------------------------------------------- 1 | import { apiFetch } from '~/api'; 2 | 3 | export interface LoginSchema { 4 | identifier: string; 5 | password: string; 6 | } 7 | 8 | export const loginService = { 9 | async checkUser(_identifier: string) { 10 | const res = await $fetch('/api/auth/check-identifier', { 11 | method: 'GET', 12 | query: { identifier: _identifier }, 13 | }); 14 | return { exists: res.data.exists, type: res.data.type }; 15 | }, 16 | 17 | async login(data: LoginSchema) { 18 | return await $fetch('/api/auth/login', { 19 | method: 'POST', 20 | body: data, 21 | }); 22 | }, 23 | 24 | async logout() { 25 | const res = await apiFetch('/api/auth/logout', { 26 | method: 'POST', 27 | }); 28 | useUserStore().logout(); 29 | navigateTo('/'); 30 | return res; 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /test/nuxt/pages/settings/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import SettingsPage from '~/pages/settings/index.vue'; 3 | import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'; 4 | import { createI18n } from 'vue-i18n'; 5 | import messages from '@@/i18n/locales/en.json'; 6 | 7 | const i18n = createI18n({ locale: 'en', messages: { en: messages } }); 8 | 9 | const navigateToMock = vi.hoisted(() => vi.fn()); 10 | 11 | mockNuxtImport('navigateTo', () => { 12 | return navigateToMock; 13 | }); 14 | 15 | describe('settings page', () => { 16 | it('callls navigateTo settings/account', async () => { 17 | await mountSuspended(SettingsPage, { 18 | global: { 19 | plugins: [i18n], 20 | }, 21 | }); 22 | expect(navigateToMock).toHaveBeenCalledWith('/settings/account'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /shared/types/shared.ts: -------------------------------------------------------------------------------- 1 | export interface UpdateProfileRequest { 2 | displayName?: string | null; 3 | bio?: string | null; 4 | location?: string | null; 5 | websiteUrl?: string | null; 6 | birthDate?: string | null; 7 | } 8 | 9 | export interface UserData { 10 | username: string; 11 | displayName: string; 12 | bio: string | null; 13 | avatarUrl: string | null; 14 | bannerUrl: string | null; 15 | location: string | null; 16 | websiteUrl: string | null; 17 | birthDate: string | null; 18 | joinedAt: string | null; 19 | email: string; 20 | phone: string; 21 | languageCode: string; 22 | } 23 | 24 | export type MediaType = 'image' | 'video' | 'gif'; 25 | 26 | export type MediaItem = { 27 | id: string; 28 | file?: File; 29 | url: string; 30 | type: MediaType; 31 | altText?: string; 32 | tenorId?: string; // for GIFs 33 | }; 34 | -------------------------------------------------------------------------------- /app/pages/playground/radio-button.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 23 | -------------------------------------------------------------------------------- /app/components/ui/MuteToggleButton.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 35 | -------------------------------------------------------------------------------- /app/components/ui/carousel/interface.ts: -------------------------------------------------------------------------------- 1 | import type useEmblaCarousel from 'embla-carousel-vue'; 2 | import type { EmblaCarouselVueType } from 'embla-carousel-vue'; 3 | import type { HTMLAttributes, UnwrapRef } from 'vue'; 4 | 5 | type CarouselApi = EmblaCarouselVueType[1]; 6 | type UseCarouselParameters = Parameters; 7 | type CarouselOptions = UseCarouselParameters[0]; 8 | type CarouselPlugin = UseCarouselParameters[1]; 9 | 10 | export type UnwrapRefCarouselApi = UnwrapRef; 11 | 12 | export interface CarouselProps { 13 | opts?: CarouselOptions; 14 | plugins?: CarouselPlugin; 15 | orientation?: 'horizontal' | 'vertical'; 16 | } 17 | 18 | export interface CarouselEmits { 19 | (e: 'init-api', payload: UnwrapRefCarouselApi): void; 20 | } 21 | 22 | export interface WithClassAsProps { 23 | class?: HTMLAttributes['class']; 24 | } 25 | -------------------------------------------------------------------------------- /app/pages/playground/tweets.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | -------------------------------------------------------------------------------- /app/components/dm/DmConversationEmptyState.vue: -------------------------------------------------------------------------------- 1 | 10 | 22 | -------------------------------------------------------------------------------- /app/components/ui/dialog/DialogOverlay.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 27 | -------------------------------------------------------------------------------- /server/api/oauth/[provider]/callback/index.post.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isOAuthResponseWithAccessToken, 3 | setAuthCookies, 4 | } from '~~/server/utils/auth/setAuthCookies'; 5 | 6 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 7 | 8 | export default defineWrappedResponseHandler(async (event) => { 9 | const { provider } = event.context.params as { provider: string }; 10 | const body = await readBody(event); 11 | const fetcher = serverApiFetch(event); 12 | 13 | const response = await fetcher.raw>( 14 | `/oauth/${provider}/callback`, 15 | { 16 | method: 'POST', 17 | body, 18 | credentials: 'include', 19 | }, 20 | ); 21 | 22 | if (isOAuthResponseWithAccessToken(response)) { 23 | setAuthCookies(event, response); 24 | } 25 | 26 | return response._data; 27 | }); 28 | -------------------------------------------------------------------------------- /server/api/users/[username]/followers.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | import type { CompactUser } from '~~/shared/types/user'; 4 | 5 | type ModifiedCompactUser = CompactUser & { 6 | isFollowing: boolean; 7 | followsYou: boolean; 8 | isBlocked: boolean; 9 | }; 10 | 11 | export default defineWrappedResponseHandler(async (event) => { 12 | const { username } = await getValidatedRouterParams(event, (data) => 13 | usernameParamsSchema.validate(data), 14 | ); 15 | const fetcher = serverApiFetch(event); 16 | const query = getQuery(event); 17 | 18 | const response = await fetcher>( 19 | `/users/${username}/followers`, 20 | { 21 | method: 'GET', 22 | query, 23 | }, 24 | ); 25 | return response; 26 | }); 27 | -------------------------------------------------------------------------------- /app/components/ui/SearchList.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 33 | -------------------------------------------------------------------------------- /app/components/ui/radio-group/RadioGroup.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 26 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogAction.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 28 | -------------------------------------------------------------------------------- /test/nuxt/pages/settings/privacy.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | import PrivacyPage from '~/pages/settings/privacy.vue'; 4 | import { createI18n } from 'vue-i18n'; 5 | import en from '~~/i18n/locales/en.json'; 6 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 7 | 8 | const i18n = createI18n({ 9 | locale: 'en', 10 | messages: { 11 | en, 12 | }, 13 | }); 14 | 15 | const createWrapper = async () => { 16 | return await mountSuspended(PrivacyPage, { 17 | global: { 18 | plugins: [i18n], 19 | }, 20 | }); 21 | }; 22 | 23 | describe('pages/settings/privacy.vue', () => { 24 | it('renders header and description', async () => { 25 | const wrapper = await createWrapper(); 26 | expect(wrapper.text()).toContain('Privacy and safety'); 27 | expect(wrapper.text()).toContain('Manage what information you see and share on Raven.'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /shared/types/google.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | google?: { 4 | accounts: { 5 | id: { 6 | initialize: (config: GoogleIdConfiguration) => void; 7 | renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void; 8 | prompt: () => void; 9 | }; 10 | oauth2?: GoogleOAuth2; 11 | }; 12 | }; 13 | } 14 | } 15 | export interface CodeClient { 16 | requestCode(): void; 17 | } 18 | 19 | interface GoogleOAuth2 { 20 | initCodeClient(config: { 21 | client_id: string; 22 | scope: string; 23 | ux_mode?: 'popup' | 'redirect'; 24 | callback: (response: { code: string }) => void; 25 | }): CodeClient; 26 | } 27 | 28 | interface GoogleIdConfiguration { 29 | client_id: string; 30 | callback: (response: GoogleCredentialResponse) => void; 31 | auto_select?: boolean; 32 | cancel_on_tap_outside?: boolean; 33 | } 34 | -------------------------------------------------------------------------------- /test/nuxt/pages/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 3 | import IndexPage from '@/pages/index.vue'; 4 | 5 | mockNuxtImport('useI18n', () => { 6 | return () => ({ 7 | locale: { value: 'en' }, 8 | localeProperties: { value: { dir: 'ltr' } }, 9 | }); 10 | }); 11 | 12 | describe('Auth Page', () => { 13 | it('renders page with correct content', async () => { 14 | const wrapper = await mountSuspended(IndexPage); 15 | 16 | const html = wrapper.html(); 17 | expect(html).toContain('Happening now'); 18 | expect(html).toContain('Join today'); 19 | expect(wrapper.find('#github-signin').exists()).toBe(true); 20 | expect(wrapper.find('#google-signin-btn').exists()).toBe(true); 21 | expect(wrapper.find('#signup').exists()).toBe(true); 22 | expect(wrapper.find('#signin').exists()).toBe(true); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /app/components/auth/login/LoginDialog.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 29 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import { defineVitestProject } from '@nuxt/test-utils/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | setupFiles: './vitest.setup.ts', 7 | coverage: { 8 | provider: 'istanbul', 9 | reporter: ['text', 'json', 'lcov'], 10 | reportsDirectory: './coverage', 11 | include: ['app/**', 'server/**'], 12 | exclude: ['**/pages/playground/**', '**/plugins/**'], 13 | }, 14 | projects: [ 15 | { 16 | test: { 17 | name: 'unit', 18 | include: ['test/{e2e,unit}/**/*.{test,spec}.ts'], 19 | environment: 'node', 20 | }, 21 | }, 22 | await defineVitestProject({ 23 | test: { 24 | name: 'nuxt', 25 | include: ['test/nuxt/**/*.{test,spec}.ts'], 26 | environment: 'nuxt', 27 | }, 28 | }), 29 | ], 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /app/pages/profile/[username]/following.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 30 | -------------------------------------------------------------------------------- /test/nuxt/pages/media/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 3 | // Mock vue-router to observe replace navigation with a shared mock router 4 | const mockRouter = { replace: vi.fn() }; 5 | vi.mock('vue-router', () => ({ 6 | useRouter: () => mockRouter, 7 | })); 8 | 9 | describe('pages/media/index.vue', () => { 10 | it('redirects to /home/following on enter', async () => { 11 | const { default: MediaIndexPage } = await import('@/pages/media/index.vue'); 12 | // Router is the shared mock above 13 | const wrapper = await mountSuspended(MediaIndexPage, { 14 | global: { stubs: { Icon: true } }, 15 | }); 16 | // Ensure any pending navigation triggers 17 | await wrapper.vm?.$nextTick?.(); 18 | // Assert redirect was called on router.replace 19 | expect(mockRouter.replace).toHaveBeenCalledWith('/home/following'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /app/components/Settings/SettingsItem.vue: -------------------------------------------------------------------------------- 1 | 9 | 26 | -------------------------------------------------------------------------------- /app/components/profile/account-setup/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 22 | -------------------------------------------------------------------------------- /app/pages/profile/[username]/followers.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 31 | -------------------------------------------------------------------------------- /app/components/dm/conversation/DmConversationHeader.vue: -------------------------------------------------------------------------------- 1 | 7 | 28 | -------------------------------------------------------------------------------- /app/api/index.ts: -------------------------------------------------------------------------------- 1 | import { clearAccessToken, getAccessToken } from '~/services/auth/authService'; 2 | import type { NitroFetchRequest, $Fetch } from 'nitropack'; 3 | 4 | export const apiFetch: $Fetch = $fetch.create({ 5 | retry: 3, 6 | retryStatusCodes: [401], 7 | onRequest({ options }) { 8 | const token = getAccessToken(); 9 | if (token) { 10 | options.headers.set('Authorization', `Bearer ${token}`); 11 | } 12 | }, 13 | async onResponseError({ request, response, options }) { 14 | if (request.toString().includes('/api/auth/refresh-token')) { 15 | return; 16 | } 17 | 18 | if (response.status === 401) { 19 | try { 20 | await apiFetch('/api/auth/refresh-token', { 21 | method: 'POST', 22 | credentials: 'include', 23 | }); 24 | } catch { 25 | options.retry = 0; // prevent further retries 26 | clearAccessToken(); 27 | } 28 | } 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /app/plugins/vue-query.ts: -------------------------------------------------------------------------------- 1 | import type { DehydratedState, VueQueryPluginOptions } from '@tanstack/vue-query'; 2 | import { VueQueryPlugin, QueryClient, hydrate, dehydrate } from '@tanstack/vue-query'; 3 | 4 | export default defineNuxtPlugin((nuxt) => { 5 | const vueQueryState = useState('vue-query'); 6 | 7 | // Modify your Vue Query global settings here 8 | const queryClient = new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | staleTime: 1000 * 60 * 5, // 5 minutes 12 | refetchOnWindowFocus: false, 13 | }, 14 | }, 15 | }); 16 | const options: VueQueryPluginOptions = { queryClient }; 17 | 18 | nuxt.vueApp.use(VueQueryPlugin, options); 19 | 20 | if (import.meta.server) { 21 | nuxt.hooks.hook('app:rendered', () => { 22 | vueQueryState.value = dehydrate(queryClient); 23 | }); 24 | } 25 | 26 | if (import.meta.client) { 27 | hydrate(queryClient, vueQueryState.value); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /app/components/ui/emoji-picker/emoji-picker-search.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | -------------------------------------------------------------------------------- /app/layouts/notifications.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 34 | -------------------------------------------------------------------------------- /app/components/ui/Toaster.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 31 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 29 | 30 | 35 | -------------------------------------------------------------------------------- /app/services/profile/profileInteractionService.ts: -------------------------------------------------------------------------------- 1 | import { apiFetch } from '~/api'; 2 | 3 | export const profileInteractionService = { 4 | followUser: async (username: string) => { 5 | return await apiFetch(`/api/users/${username}/following`, { method: 'POST' }); 6 | }, 7 | 8 | unfollowUser: async (username: string) => { 9 | return await apiFetch(`/api/users/${username}/following`, { method: 'DELETE' }); 10 | }, 11 | 12 | blockUser: async (username: string) => { 13 | return await apiFetch(`/api/me/blocks/${username}`, { method: 'POST' }); 14 | }, 15 | 16 | unblockUser: async (username: string) => { 17 | return await apiFetch(`/api/me/blocks/${username}`, { method: 'DELETE' }); 18 | }, 19 | 20 | muteUser: async (username: string) => { 21 | return await apiFetch(`/api/me/mutes/${username}`, { method: 'POST' }); 22 | }, 23 | 24 | unmuteUser: async (username: string) => { 25 | return await apiFetch(`/api/me/mutes/${username}`, { method: 'DELETE' }); 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /app/components/profile/ProfileAvatarModal.vue: -------------------------------------------------------------------------------- 1 | 10 | 32 | -------------------------------------------------------------------------------- /app/pages/profile/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 31 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Handle Vite HMR errors during development 20 | Cypress.on('uncaught:exception', (err) => { 21 | // Ignore HMR/dynamic import errors from Vite/Nuxt 22 | if (err.message.includes('Failed to fetch dynamically imported module')) { 23 | return false; 24 | } 25 | // Let other errors fail the test 26 | return true; 27 | }); 28 | -------------------------------------------------------------------------------- /app/pages/settings/content-you-see.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 32 | -------------------------------------------------------------------------------- /test/nuxt/pages/home/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'; 3 | 4 | const { navigateToMock } = vi.hoisted(() => { 5 | return { 6 | navigateToMock: vi.fn(), 7 | }; 8 | }); 9 | 10 | mockNuxtImport('navigateTo', () => navigateToMock); 11 | 12 | describe('Home Index Page', () => { 13 | beforeEach(() => { 14 | vi.clearAllMocks(); 15 | }); 16 | 17 | it('redirects to /home/for-you with code 301', async () => { 18 | const { default: HomePage } = await import('~/pages/home/index.vue'); 19 | await mountSuspended(HomePage); 20 | 21 | expect(navigateToMock).toHaveBeenCalledWith('/home/for-you', { redirectCode: 301 }); 22 | }); 23 | 24 | it('calls navigateTo exactly once', async () => { 25 | const { default: HomePage } = await import('~/pages/home/index.vue'); 26 | await mountSuspended(HomePage); 27 | 28 | expect(navigateToMock).toHaveBeenCalledTimes(1); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /app/components/Settings/SettingsSection/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 26 | -------------------------------------------------------------------------------- /app/components/dm/conversation/input/MessageAttachmentPreview.vue: -------------------------------------------------------------------------------- 1 | 10 | 30 | -------------------------------------------------------------------------------- /app/composables/useNotificationSound.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | 3 | interface Options { 4 | minInterval?: number; // ms between sounds 5 | volume?: number; // 0–1 6 | } 7 | 8 | let audio: HTMLAudioElement | null = null; 9 | const lastPlayedAt = ref(0); 10 | const SOUND_SRC = '/sounds/Raven.mp3'; 11 | 12 | export function useNotificationSound(options: Options = {}) { 13 | const { minInterval = 800, volume = 1 } = options; 14 | 15 | // Load audio once on client 16 | if (import.meta.client && !audio) { 17 | audio = new Audio(SOUND_SRC); 18 | audio.volume = volume; 19 | audio.preload = 'auto'; 20 | } 21 | 22 | const play = () => { 23 | if (!import.meta.client || !audio) return; 24 | 25 | const now = Date.now(); 26 | const diff = now - lastPlayedAt.value; 27 | 28 | // prevent spam 29 | if (diff < minInterval) return; 30 | 31 | lastPlayedAt.value = now; 32 | 33 | audio.currentTime = 0; 34 | audio.play().catch(() => {}); 35 | }; 36 | 37 | return { 38 | play, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /app/components/ui/alert-dialog/AlertDialogCancel.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 35 | -------------------------------------------------------------------------------- /app/layouts/home.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 34 | -------------------------------------------------------------------------------- /app/pages/profile/[username]/followers-you-follow.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 35 | -------------------------------------------------------------------------------- /test/nuxt/services/auth/accountService.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | 3 | import { accountService } from '@/services/auth/accountService'; 4 | import { registerEndpoint } from '@nuxt/test-utils/runtime'; 5 | 6 | describe('accountService', () => { 7 | beforeEach(() => { 8 | vi.clearAllMocks(); 9 | vi.resetAllMocks(); 10 | vi.resetModules(); 11 | }); 12 | 13 | it('calls $fetch correctly for checkAccountExists()', async () => { 14 | registerEndpoint('/api/auth/check-identifier', () => { 15 | return { data: { exists: true, type: 'email' } }; 16 | }); 17 | 18 | const exists = await accountService.checkAccountExists('user@example.com'); 19 | expect(exists).toBe(true); 20 | }); 21 | 22 | it('returns false when API does not return exists field', async () => { 23 | registerEndpoint('/api/auth/check-identifier', () => { 24 | return { data: {} }; 25 | }); 26 | 27 | const exists = await accountService.checkAccountExists('user@example.com'); 28 | expect(exists).toBe(false); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /app/pages/profile/[username]/status/[tweetid]/likes.vue: -------------------------------------------------------------------------------- 1 | 17 | 34 | -------------------------------------------------------------------------------- /test/nuxt/pages/messages.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'; 3 | import MessagesPage from '@/pages/messages/index.vue'; 4 | 5 | mockNuxtImport('useI18n', () => { 6 | return () => ({ 7 | locale: { value: 'en' }, 8 | localeProperties: { value: { dir: 'ltr' } }, 9 | }); 10 | }); 11 | 12 | describe('Messages Page', () => { 13 | it('renders page with DM conversations section', async () => { 14 | const wrapper = await mountSuspended(MessagesPage); 15 | 16 | // Check if the DmConversationsSection component is rendered 17 | const html = wrapper.html(); 18 | expect(html).toBeTruthy(); 19 | 20 | // Check for DM-related content 21 | expect(wrapper.findComponent({ name: 'DmConversationsSection' }).exists()).toBe(true); 22 | }); 23 | 24 | it('uses the correct layout', async () => { 25 | const wrapper = await mountSuspended(MessagesPage); 26 | 27 | // The page should render successfully with settings layout 28 | expect(wrapper.html()).toBeTruthy(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /app/pages/profile/[username]/status/[tweetid]/reposts.vue: -------------------------------------------------------------------------------- 1 | 17 | 34 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import withNuxt from './.nuxt/eslint.config.mjs'; 3 | import pluginRegex from 'eslint-plugin-regex'; 4 | 5 | export default withNuxt({ 6 | ignores: ['eslint.config.mjs', '.output', '.nuxt', 'node_modules'], 7 | plugins: { 8 | regex: pluginRegex, 9 | }, 10 | rules: { 11 | 'no-console': ['warn', { allow: ['warn', 'error'] }], 12 | '@typescript-eslint/no-unused-vars': [ 13 | 'error', 14 | { 15 | argsIgnorePattern: '^_', 16 | varsIgnorePattern: '^_', 17 | }, 18 | ], 19 | 'vue/no-bare-strings-in-template': 'error', 20 | 'vue/html-self-closing': 'off', 21 | 'vue/multiword-component-names': 'off', 22 | 'regex/invalid': [ 23 | 'error', 24 | [ 25 | { 26 | id: 'no-ltr-tailwind', 27 | message: 28 | 'Avoid left/right-based Tailwind classes. Use logical ones (ms/me, ps/pe, start/end).', 29 | regex: String.raw`(?<=\bclass(?:Name)?=["'][^"']*)\b(?:ml|mr|pl|pr|left|right|inset-(?:l|r))(?:-[^\s"'>]+)?\b`, 30 | }, 31 | ], 32 | ], 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /shared/types/user.ts: -------------------------------------------------------------------------------- 1 | import type { ContentEntities } from './entity'; 2 | 3 | export type UserRelationship = { 4 | blocking?: boolean; 5 | blockedBy?: boolean; 6 | muted?: boolean; 7 | following?: boolean; 8 | follower?: boolean; 9 | }; 10 | 11 | export type MutualUser = { 12 | displayName: string; 13 | avatarUrl: string; 14 | }; 15 | 16 | export type CompactUser = MutualUser & { 17 | username: string; 18 | bio: string | null; 19 | bioEntities: ContentEntities | null; 20 | relationship: UserRelationship; 21 | }; 22 | 23 | export type User = CompactUser & { 24 | bannerUrl: string; 25 | location: string; 26 | websiteUrl: string; 27 | birthDate: string; 28 | joinedAt: string; // ISO date string 29 | followingCount: number; 30 | followersCount: number; 31 | mutualsCount?: number; 32 | mutualUsers?: MutualUser[]; 33 | email?: string; 34 | phone?: string; 35 | languageCode?: string; 36 | }; 37 | 38 | export type SearchedUser = { 39 | username: string; 40 | displayName: string; 41 | avatarUrl: string; 42 | isFollowing: boolean; 43 | isFollower: boolean; 44 | }; 45 | -------------------------------------------------------------------------------- /test/nuxt/server/api/auth/check-email.get.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { createMockH3Event } from '~~/test/mocks/h3-event'; 3 | import { useH3TestUtils } from '~~/test/mocks/h3-test-utils'; 4 | import checkEmailEventHandler from '~~/server/api/auth/check-email.get'; 5 | 6 | useH3TestUtils(); 7 | 8 | const mockServerApiFetch = vi.fn(); 9 | vi.stubGlobal('serverApiFetch', () => mockServerApiFetch); 10 | 11 | describe('GET /api/auth/check-email', () => { 12 | it('returns success response when email exists', async () => { 13 | const mockResponse = { success: true, data: { exists: true } }; 14 | mockServerApiFetch.mockResolvedValueOnce(mockResponse); 15 | 16 | const res = await checkEmailEventHandler( 17 | createMockH3Event({ 18 | query: { email: 'test@example.com' }, 19 | }), 20 | ); 21 | 22 | expect(mockServerApiFetch).toHaveBeenCalledWith('/auth/check-email', { 23 | method: 'GET', 24 | query: { 25 | email: 'test@example.com', 26 | }, 27 | }); 28 | expect(res).toEqual(mockResponse); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /server/utils/handler.ts: -------------------------------------------------------------------------------- 1 | import type { EventHandler, EventHandlerRequest } from 'h3'; 2 | import { createError, isError, defineEventHandler } from 'h3'; 3 | import { ValidationError } from 'yup'; 4 | 5 | export const defineWrappedResponseHandler = ( 6 | handler: EventHandler, 7 | ): EventHandler => 8 | defineEventHandler(async (event) => { 9 | try { 10 | const response = await handler(event); 11 | return response as D; 12 | } catch (err) { 13 | if (isError(err)) { 14 | throw err; 15 | } 16 | if (err instanceof ValidationError) { 17 | throw createError({ 18 | statusCode: 422, 19 | statusMessage: 'Validation Error', 20 | data: { 21 | message: err.message, 22 | errors: err.errors, 23 | }, 24 | }); 25 | } 26 | 27 | throw createError({ 28 | statusCode: 500, 29 | statusMessage: 'Internal Server Error', 30 | data: { message: (err as Error)?.message ?? 'An unexpected error occurred' }, 31 | }); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /test/nuxt/components/notifications/QuoteMention.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 3 | import QuoteMention from '@/components/notifications/QuoteMention.vue'; 4 | 5 | const mockTweet = { 6 | id: '123', 7 | author: { username: 'tweetAuthor' }, 8 | content: 'hello', 9 | }; 10 | 11 | describe('notifications/QuoteMention.vue', () => { 12 | it('renders TweetDefaultCard and forwards tweet prop', async () => { 13 | const wrapper = await mountSuspended(QuoteMention, { 14 | props: { tweet: mockTweet }, 15 | global: { 16 | stubs: { 17 | TweetDefaultCard: { 18 | name: 'TweetDefaultCard', 19 | props: ['tweet'], 20 | template: '
{{ tweet.id }}
', 21 | }, 22 | }, 23 | }, 24 | }); 25 | 26 | const card = wrapper.findComponent({ name: 'TweetDefaultCard' }); 27 | expect(card.exists()).toBe(true); 28 | expect(card.props('tweet')).toEqual(mockTweet); 29 | expect(wrapper.html()).toContain('123'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/nuxt/server/api/me/banner.delete.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import bannerPostHandler from '~~/server/api/me/banner.delete'; 3 | import { createMockH3Event } from '~~/test/mocks/h3-event'; 4 | import { useH3TestUtils } from '~~/test/mocks/h3-test-utils'; 5 | 6 | useH3TestUtils(); 7 | 8 | const mockServerApiFetch = vi.fn(); 9 | vi.stubGlobal('serverApiFetch', () => mockServerApiFetch); 10 | 11 | describe('DELETE /api/me/banner', () => { 12 | beforeEach(() => { 13 | vi.clearAllMocks(); 14 | }); 15 | 16 | it('should delete user banner successfully', async () => { 17 | mockServerApiFetch.mockResolvedValueOnce({ 18 | success: true, 19 | message: 'Deleted user banner successfully', 20 | }); 21 | const event = createMockH3Event({}, {}); 22 | const response = await bannerPostHandler(event); 23 | expect(mockServerApiFetch).toHaveBeenCalledWith('/me/banner', { 24 | method: 'DELETE', 25 | }); 26 | expect(response).toEqual({ 27 | success: true, 28 | message: 'Deleted user banner successfully', 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /app/components/tweet/composer/ReplyTweetDialog.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 34 | -------------------------------------------------------------------------------- /app/middleware/auth.global.ts: -------------------------------------------------------------------------------- 1 | import { apiFetch } from '~/api'; 2 | import { isAuthenticated } from '~/services/auth/authService'; 3 | import { isPublicRoute } from '~/utils/public-routes'; 4 | 5 | export default defineNuxtRouteMiddleware(async (to) => { 6 | let isAuth = isAuthenticated(); 7 | 8 | // If already authenticated, redirect to /home 9 | if (to.path === '/' && isAuth) { 10 | return navigateTo('/home'); 11 | } 12 | // Allow if it's a public route 13 | const isPublic = isPublicRoute(to.path); 14 | if (isPublic) return; 15 | 16 | if (!isAuth) { 17 | // try to authenticate using refresh token 18 | try { 19 | if (import.meta.client) { 20 | await apiFetch('/api/auth/refresh-token', { 21 | method: 'POST', 22 | credentials: 'include', 23 | }); 24 | } 25 | isAuth = isAuthenticated(); 26 | } catch { 27 | return navigateTo('/'); 28 | } 29 | } 30 | 31 | if (to.path === '/' && isAuth) { 32 | return navigateTo('/home'); 33 | } 34 | 35 | // Protect all other routes 36 | if (!isAuth) { 37 | return navigateTo('/'); 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /app/components/ui/VideoPlayer.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 38 | 39 | 50 | -------------------------------------------------------------------------------- /app/layouts/explore.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 46 | -------------------------------------------------------------------------------- /test/nuxt/server/api/me/index.get.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import meGetHandler from '~~/server/api/me/index.get'; 3 | import { createMockH3Event } from '~~/test/mocks/h3-event'; 4 | import { useH3TestUtils } from '~~/test/mocks/h3-test-utils'; 5 | 6 | useH3TestUtils(); 7 | 8 | const mockServerApiFetch = vi.fn(); 9 | vi.stubGlobal('serverApiFetch', () => mockServerApiFetch); 10 | 11 | describe('GET /api/me', () => { 12 | beforeEach(() => { 13 | vi.clearAllMocks(); 14 | }); 15 | 16 | it('should fetch current user data successfully', async () => { 17 | mockServerApiFetch.mockResolvedValueOnce({ 18 | success: true, 19 | message: 'User data fetched successfully', 20 | data: { id: 'user123', name: 'Test User' }, 21 | }); 22 | const event = createMockH3Event({}, {}); 23 | const response = await meGetHandler(event); 24 | expect(mockServerApiFetch).toHaveBeenCalledWith('/me'); 25 | expect(response).toEqual({ 26 | success: true, 27 | message: 'User data fetched successfully', 28 | data: { id: 'user123', name: 'Test User' }, 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /app/pages/settings/mute-and-block.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 37 | -------------------------------------------------------------------------------- /app/services/auth/passwordService.ts: -------------------------------------------------------------------------------- 1 | export interface CheckUserSchema { 2 | identifier: string; 3 | recaptchaToken: string; 4 | } 5 | 6 | export interface VerifyUserSchema { 7 | confirmationToken: string; 8 | otp: string; 9 | } 10 | 11 | export interface ResetPasswordSchema { 12 | confirmationToken: string; 13 | newPassword: string; 14 | } 15 | 16 | export const passwordService = { 17 | async checkUser(data: CheckUserSchema) { 18 | return await $fetch('/api/auth/password/forgot', { 19 | method: 'POST', 20 | body: data, 21 | }); 22 | }, 23 | 24 | async verifyUser(data: VerifyUserSchema) { 25 | return await $fetch('/api/auth/password/forgot/verify', { 26 | method: 'POST', 27 | body: data, 28 | }); 29 | }, 30 | 31 | async resendOtp(confirmationToken: string) { 32 | return await $fetch('/api/auth/password/resend-otp', { 33 | method: 'POST', 34 | body: { confirmationToken }, 35 | }); 36 | }, 37 | 38 | async resetPassword(data: ResetPasswordSchema) { 39 | return await $fetch('/api/auth/password/reset', { 40 | method: 'POST', 41 | body: data, 42 | }); 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /app/components/ui/BlockToggleButton.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 40 | 41 | 50 | -------------------------------------------------------------------------------- /app/pages/playground/tabs.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 32 | 33 | 39 | -------------------------------------------------------------------------------- /test/nuxt/server/api/auth/password/resend-otp.post.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { useH3TestUtils } from '~~/test/mocks/h3-test-utils'; 3 | import forgotPasswordResendOtpEventHandler from '~~/server/api/auth/password/resend-otp.post'; 4 | import { createMockH3Event } from '~~/test/mocks/h3-event'; 5 | 6 | useH3TestUtils(); 7 | 8 | const mockServerApiFetch = vi.fn(); 9 | vi.stubGlobal('serverApiFetch', () => mockServerApiFetch); 10 | 11 | describe('POST /api/auth/password/resend-otp', () => { 12 | it('returns success response when OTP is resent', async () => { 13 | const mockResponse = { 14 | success: true, 15 | message: 'otp resent successfully.', 16 | }; 17 | mockServerApiFetch.mockResolvedValueOnce(mockResponse); 18 | 19 | const res = await forgotPasswordResendOtpEventHandler( 20 | createMockH3Event({ body: { confirmationToken: 'token123' } }), 21 | ); 22 | 23 | expect(mockServerApiFetch).toHaveBeenCalledWith('/auth/password/resend-otp', { 24 | method: 'POST', 25 | body: { confirmationToken: 'token123' }, 26 | }); 27 | expect(res).toEqual(mockResponse); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/nuxt/pages/settings/content-you-see.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import MuteAndBlockPage from '~/pages/settings/content-you-see.vue'; 3 | import { createI18n } from 'vue-i18n'; 4 | import en from '~~/i18n/locales/en.json'; 5 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 6 | 7 | const i18n = createI18n({ 8 | locale: 'en', 9 | messages: { 10 | en, 11 | }, 12 | }); 13 | 14 | const createWrapper = async () => { 15 | return await mountSuspended(MuteAndBlockPage, { 16 | global: { 17 | plugins: [i18n], 18 | }, 19 | }); 20 | }; 21 | 22 | describe('pages/settings/content-you-see.vue', () => { 23 | it('renders header and description', async () => { 24 | const wrapper = await createWrapper(); 25 | expect(wrapper.text()).toContain('Content you see'); 26 | expect(wrapper.text()).toContain('Decide what you see on Raven based on your Interests'); 27 | }); 28 | it('contains links to interests page', async () => { 29 | const wrapper = await createWrapper(); 30 | const interestsLink = wrapper.find('a[href="/settings/interests"]'); 31 | expect(interestsLink.exists()).toBe(true); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /server/api/users/[username]/profile.get.ts: -------------------------------------------------------------------------------- 1 | import { defineWrappedResponseHandler } from '~~/server/utils/handler'; 2 | import usernameParamsSchema from '~~/server/schemas/username'; 3 | 4 | export default defineWrappedResponseHandler(async (event) => { 5 | const { username } = await getValidatedRouterParams(event, (data) => 6 | usernameParamsSchema.validate(data), 7 | ); 8 | const fetcher = serverApiFetch(event); 9 | 10 | const response = await fetcher>(`/users/${username}/profile`, { 11 | method: 'GET', 12 | }); 13 | 14 | // note that the backend will update the format to fix this, this is temporary 15 | const modifiedResponse: ApiSuccessResponse = { 16 | ...response, 17 | data: { 18 | ...response.data, 19 | mutualUsers: ((response.data as User & { mutualNames?: string[] })?.mutualNames || []) 20 | .map((name) => ({ 21 | displayName: name, 22 | avatarUrl: 'https://cdn.raven.cmp27.space/default_avatar.png', // Placeholder, as we don't have avatar URLs in mutualNames 23 | })) 24 | .concat(response.data.mutualUsers || []), 25 | }, 26 | }; 27 | return modifiedResponse; 28 | }); 29 | -------------------------------------------------------------------------------- /test/nuxt/components/settings/SettingsSection.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import SettingsSection from '@/components/Settings/SettingsSection/index.vue'; 3 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 4 | import { createI18n } from 'vue-i18n'; 5 | import en from '~~/i18n/locales/en.json'; 6 | import { settingSections } from '~/constants/settings-section'; 7 | 8 | const i18n = createI18n({ 9 | locale: 'en', 10 | messages: { 11 | en, 12 | }, 13 | }); 14 | 15 | const createWrapper = async () => { 16 | return await mountSuspended(SettingsSection, { 17 | global: { 18 | plugins: [i18n], 19 | }, 20 | }); 21 | }; 22 | 23 | describe('SettingsSection.vue', () => { 24 | it('renders correctly', async () => { 25 | const wrapper = await createWrapper(); 26 | expect(wrapper.text()).toContain('Settings'); 27 | settingSections.forEach((section) => { 28 | const link = wrapper.find(`[data-cy="${section.cy}"]`); 29 | expect(link.exists()).toBe(true); 30 | expect(link.attributes('href')).toBe(section.route); 31 | expect(link.text()).toContain(i18n.global.t(section.title)); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/nuxt/components/ui/Spinner.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 3 | import Spinner from '~/components/ui/Spinner.vue'; 4 | 5 | describe('Spinner', () => { 6 | it('renders the spinner with default classes', async () => { 7 | const wrapper = await mountSuspended(Spinner, { 8 | global: { 9 | mocks: { 10 | $t: (msg: string) => msg, 11 | }, 12 | }, 13 | }); 14 | 15 | const icon = wrapper.find('svg'); 16 | expect(icon.exists()).toBe(true); 17 | // defaults 18 | expect(icon.attributes('role')).toBe('status'); 19 | expect(icon.classes()).toContain('animate-spin'); 20 | }); 21 | 22 | it('applies custom classes to the spinner', async () => { 23 | const customClass = 'text-blue-500'; 24 | const wrapper = await mountSuspended(Spinner, { 25 | props: { 26 | class: customClass, 27 | }, 28 | global: { 29 | mocks: { 30 | $t: (msg: string) => msg, 31 | }, 32 | }, 33 | }); 34 | 35 | const icon = wrapper.find('svg'); 36 | expect(icon.classes()).toContain(customClass); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /app/components/profile/skeletons/ProfileInfoSkeleton.vue: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /test/nuxt/components/user/UserHoverCard.spec.ts: -------------------------------------------------------------------------------- 1 | import UserHoverCard from '@/components/user/UserHoverCard.vue'; 2 | import UserMetadata from '@/components/user/UserMetadata.vue'; 3 | import { describe, expect, it } from 'vitest'; 4 | import { mountSuspended } from '@nuxt/test-utils/runtime'; 5 | import messages from '@@/i18n/locales/en.json'; 6 | import { createI18n } from 'vue-i18n'; 7 | 8 | const i18n = createI18n({ locale: 'en', messages: { en: messages } }); 9 | 10 | describe('UserHoverCard Component', () => { 11 | it('renders metadata on hover', async () => { 12 | const wrapper = await mountSuspended(UserHoverCard, { 13 | props: { username: 'testuser' }, 14 | slots: { default: '
trigger
' }, 15 | global: { 16 | plugins: [i18n], 17 | stubs: { 18 | UiHoverCardContent: { 19 | template: '
', 20 | }, 21 | }, 22 | }, 23 | }); 24 | 25 | await wrapper.find('[data-test="trigger"]').trigger('mouseenter'); 26 | 27 | const metadata = wrapper.findComponent(UserMetadata); 28 | expect(metadata.exists()).toBe(true); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /app/components/explore/Hashtag.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 35 | -------------------------------------------------------------------------------- /test/nuxt/server/api/auth/register/resend-otp.post.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import resendOtpPostEventHander from '~~/server/api/auth/register/resend-otp.post'; 3 | import { createMockH3Event } from '~~/test/mocks/h3-event'; 4 | import { useH3TestUtils } from '~~/test/mocks/h3-test-utils'; 5 | 6 | useH3TestUtils(); 7 | 8 | const mockServerApiFetch = vi.fn(); 9 | vi.stubGlobal('serverApiFetch', () => mockServerApiFetch); 10 | 11 | describe('server/api/auth/register/start.post', () => { 12 | it('should return 200 for valid requests', async () => { 13 | const mockResponse = { 14 | status: 200, 15 | data: { 16 | success: true, 17 | message: 'OTP resent successfully', 18 | }, 19 | }; 20 | mockServerApiFetch.mockResolvedValueOnce(mockResponse); 21 | const event = createMockH3Event({ 22 | method: 'POST', 23 | body: { creationToken: 'valid-creation-token' }, 24 | }); 25 | const response = await resendOtpPostEventHander(event); 26 | expect(response.status).toBe(200); 27 | expect(response.data).toEqual({ 28 | success: true, 29 | message: 'OTP resent successfully', 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /server/middleware/auth.global.ts: -------------------------------------------------------------------------------- 1 | import { isPublicRoute } from '../../app/utils/public-routes'; 2 | import { setAuthCookies } from '../utils/auth/setAuthCookies'; 3 | 4 | export default defineEventHandler(async (event) => { 5 | const refreshToken = getCookie(event, 'refreshToken'); 6 | const accessToken = getCookie(event, 'access_token'); 7 | const authHeader = getHeader(event, 'Authorization'); 8 | if (!refreshToken || authHeader || accessToken) return; 9 | 10 | const clientCookie = getHeader(event, 'cookie'); 11 | 12 | const fetcher = serverApiFetch(event); 13 | try { 14 | const response = await fetcher.raw>( 15 | '/auth/refresh-token', 16 | { 17 | method: 'POST', 18 | headers: { 19 | ...(clientCookie ? { cookie: clientCookie } : {}), // Forward client cookies 20 | }, 21 | }, 22 | ); 23 | setAuthCookies(event, response); 24 | if (event.node.req.url === '/') sendRedirect(event, '/home', 302); 25 | } catch { 26 | deleteCookie(event, 'refreshToken'); 27 | deleteCookie(event, 'access_token'); 28 | if (!isPublicRoute(event.node.req.url || '')) sendRedirect(event, '/', 401); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /app/components/ui/FollowToggleButton.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 39 | 40 | 49 | -------------------------------------------------------------------------------- /test/nuxt/App.spec.ts: -------------------------------------------------------------------------------- 1 | import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'; 2 | import { describe, it, expect } from 'vitest'; 3 | import { createI18n } from 'vue-i18n'; 4 | 5 | mockNuxtImport('useI18n', () => { 6 | return () => ({ 7 | locale: { value: 'en' }, 8 | localeProperties: { value: { dir: 'ltr' } }, 9 | }); 10 | }); 11 | 12 | mockNuxtImport('useHead', () => { 13 | return (options) => { 14 | if (options().htmlAttrs) { 15 | document.documentElement.setAttribute('lang', options().htmlAttrs.lang); 16 | document.documentElement.setAttribute('dir', options().htmlAttrs.dir); 17 | } 18 | }; 19 | }); 20 | 21 | const i18n = createI18n({ 22 | locale: 'en', 23 | messages: { en: await import('@@/i18n/locales/en.json') }, 24 | }); 25 | 26 | describe('App.vue', () => { 27 | it('calls useHead with correct htmlAttrs', async () => { 28 | const { default: App } = await import('@/app.vue'); 29 | await mountSuspended(App, { 30 | global: { 31 | plugins: [i18n], 32 | }, 33 | }); 34 | 35 | expect(document.documentElement.getAttribute('lang')).toBe('en'); 36 | expect(document.documentElement.getAttribute('dir')).toBe('ltr'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/nuxt/server/api/auth/password/forgot/verify.post.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { useH3TestUtils } from '~~/test/mocks/h3-test-utils'; 3 | import forgotPasswordVerifyEventHandler from '~~/server/api/auth/password/forgot/verify.post'; 4 | import { createMockH3Event } from '~~/test/mocks/h3-event'; 5 | 6 | useH3TestUtils(); 7 | 8 | const mockServerApiFetch = vi.fn(); 9 | vi.stubGlobal('serverApiFetch', () => mockServerApiFetch); 10 | 11 | describe('POST /api/auth/password/forgot/verify', () => { 12 | it('returns success response when OTP is verified', async () => { 13 | const mockResponse = { 14 | success: true, 15 | message: 'Password reset verified successfully.', 16 | }; 17 | mockServerApiFetch.mockResolvedValueOnce(mockResponse); 18 | 19 | const res = await forgotPasswordVerifyEventHandler( 20 | createMockH3Event({ body: { confirmationToken: 'token123', otp: '123456' } }), 21 | ); 22 | 23 | expect(mockServerApiFetch).toHaveBeenCalledWith('/auth/password/forgot/verify', { 24 | method: 'POST', 25 | body: { confirmationToken: 'token123', otp: '123456' }, 26 | }); 27 | expect(res).toEqual(mockResponse); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-slim AS base 2 | ENV PNPM_HOME="/pnpm" 3 | ENV PATH="$PNPM_HOME:$PATH" 4 | WORKDIR /app 5 | RUN corepack enable 6 | 7 | # Stage 1: Build the application 8 | FROM base AS build 9 | ENV NODE_ENV=development 10 | 11 | ARG NUXT_PUBLIC_RECAPTCHA_SITE_KEY 12 | ARG NUXT_PUBLIC_DM_WS_URL 13 | ARG RAVEN_TENOR_KEY 14 | 15 | ENV NUXT_PUBLIC_RECAPTCHA_SITE_KEY=$NUXT_PUBLIC_RECAPTCHA_SITE_KEY 16 | ENV NUXT_PUBLIC_DM_WS_URL=$NUXT_PUBLIC_DM_WS_URL 17 | ENV RAVEN_TENOR_KEY=$RAVEN_TENOR_KEY 18 | 19 | # Install dependencies 20 | COPY package.json pnpm-lock.yaml ./ 21 | RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ 22 | pnpm install --frozen-lockfile 23 | 24 | # Copy the rest of the application code 25 | COPY . . 26 | RUN pnpm mock:gen 27 | 28 | # change back to production for build 29 | ENV NODE_ENV=production 30 | 31 | # Increase memory limit for Node.js during build 32 | ENV NODE_OPTIONS="--max-old-space-size=4096" 33 | 34 | # Build Nuxt (SSR) 35 | RUN pnpm build 36 | 37 | # Stage 2: Runtime 38 | FROM base 39 | 40 | # Copy build output and necessary files 41 | ENV NODE_ENV=production 42 | COPY --from=build /app/.output /app/.output 43 | COPY package.json ./ 44 | 45 | EXPOSE 3000 46 | CMD ["pnpm", "start"] 47 | -------------------------------------------------------------------------------- /app/components/tweet/composer/QuoteTweetDialog.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | -------------------------------------------------------------------------------- /app/components/notifications/Reply.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 43 | --------------------------------------------------------------------------------