├── .env ├── mise.toml ├── .prettierignore ├── .npmrc ├── public ├── _headers ├── favicon.png └── _routes.json ├── src ├── api │ ├── types.ts │ ├── utils │ │ ├── misc.ts │ │ ├── types.ts │ │ ├── richtext.ts │ │ ├── did.ts │ │ ├── url.ts │ │ ├── unicode.ts │ │ ├── dequal.ts │ │ ├── error.ts │ │ ├── query.ts │ │ └── richtext-stringify.ts │ ├── queries-cache │ │ ├── feed-precache.ts │ │ ├── list-precache.ts │ │ ├── profile-precache.ts │ │ ├── profile.ts │ │ ├── profile-autocomplete.ts │ │ ├── post-quotes.ts │ │ └── profile-list.ts │ ├── defaults.ts │ ├── queries │ │ ├── blob.ts │ │ ├── bookmark-entry.ts │ │ ├── handle.ts │ │ ├── list-members.ts │ │ ├── post-quotes.ts │ │ ├── search-profiles.ts │ │ ├── search-feeds.ts │ │ ├── profile-feeds.ts │ │ ├── profile-lists.ts │ │ ├── profile-autocomplete.ts │ │ ├── subject-reposters.ts │ │ ├── subject-likers.ts │ │ ├── labeler.ts │ │ ├── notification-count.tsx │ │ ├── bookmark.ts │ │ └── post.ts │ ├── cache │ │ ├── types.ts │ │ └── utils.ts │ ├── moderation │ │ └── entities │ │ │ ├── generic.ts │ │ │ ├── post.ts │ │ │ ├── quote.ts │ │ │ └── profile.ts │ └── types │ │ ├── at-uri.ts │ │ └── profile-response.ts ├── lib │ ├── hooks │ │ ├── id.ts │ │ ├── trigger.ts │ │ ├── resize-observer.ts │ │ ├── derived-signal.ts │ │ ├── abortable.ts │ │ ├── escape.ts │ │ ├── debounced-value.ts │ │ ├── media-query.ts │ │ ├── outside-click.ts │ │ ├── guard.ts │ │ └── local-storage.ts │ ├── utils │ │ ├── regex.ts │ │ ├── hash.ts │ │ ├── strings.ts │ │ ├── invariant.ts │ │ ├── state.ts │ │ └── blob.ts │ ├── preferences │ │ ├── global.ts │ │ └── sessions.ts │ ├── bluemoji │ │ └── render.ts │ ├── intl │ │ ├── language-fallback.ts │ │ └── number.ts │ ├── bsky │ │ ├── video.ts │ │ ├── url.ts │ │ ├── crop.ts │ │ └── video-upload.ts │ ├── styles.ts │ ├── validation.ts │ ├── pragmatic-dnd │ │ ├── DraggablePreview.tsx │ │ └── DropIndicator.tsx │ ├── observer.ts │ ├── element-refs.ts │ ├── aglais-bookmarks │ │ └── db.ts │ ├── atproto │ │ └── labeler.ts │ ├── interaction.ts │ └── states │ │ ├── singleton.tsx │ │ └── theme.tsx ├── components │ ├── main │ │ ├── sign-in-dialog-lazy.tsx │ │ ├── sign-out-dialog-lazy.tsx │ │ ├── manage-account-dialog-lazy.tsx │ │ ├── main-sidebar-public.tsx │ │ ├── main-sidebar.tsx │ │ ├── sign-out-dialog.tsx │ │ └── account-overflow-menu.tsx │ ├── composer │ │ ├── composer-dialog-lazy.tsx │ │ ├── dialogs │ │ │ ├── gif-alt-dialog-lazy.tsx │ │ │ ├── image-alt-dialog-lazy.tsx │ │ │ ├── gif-conversion-prompt-lazy.tsx │ │ │ ├── language-select-dialog-lazy.tsx │ │ │ └── composed-interaction-dialog-lazy.tsx │ │ ├── gifs │ │ │ └── gif-search-dialog-lazy.tsx │ │ ├── drafts │ │ │ ├── draft-list-dialog-lazy.tsx │ │ │ └── draft-list-dialog.tsx │ │ ├── lib │ │ │ ├── cid.ts │ │ │ └── link-detection.ts │ │ ├── compose-fab.tsx │ │ └── embeds │ │ │ ├── feed-embed.tsx │ │ │ └── list-embed.tsx │ ├── timeline │ │ ├── pin-post-prompt-lazy.tsx │ │ ├── delete-post-prompt-lazy.tsx │ │ ├── revise-post-prompt-lazy.tsx │ │ ├── post-deleted-gate.tsx │ │ ├── repost-menu.tsx │ │ └── timeline-list.tsx │ ├── images │ │ ├── image-viewer-modal-lazy.tsx │ │ └── image-upload-menu.tsx │ ├── moderation │ │ ├── mute-account-prompt-lazy.tsx │ │ ├── block-account-prompt-lazy.tsx │ │ ├── label-details-prompt-lazy.tsx │ │ ├── label-details-prompt.tsx │ │ └── labels-on-me.tsx │ ├── profiles │ │ └── edit-profile-dialog-lazy.tsx │ ├── bookmarks │ │ ├── add-post-to-folder-dialog-lazy.tsx │ │ └── bookmark-folder-form-dialog-lazy.tsx │ ├── end-of-list-view.tsx │ ├── circular-progress-view.tsx │ ├── icons-central │ │ ├── play-solid.tsx │ │ ├── pause-solid.tsx │ │ ├── bookmark-solid.tsx │ │ ├── home-solid.tsx │ │ ├── filter-outline.tsx │ │ ├── home-outline.tsx │ │ ├── bookmark-outline.tsx │ │ ├── arrow-left-outline.tsx │ │ ├── cross-large-outline.tsx │ │ ├── add-outline.tsx │ │ ├── check-outline.tsx │ │ ├── clipboard-outline.tsx │ │ ├── flag-outline.tsx │ │ ├── mail-solid.tsx │ │ ├── hashtag-outline.tsx │ │ ├── reply-outline.tsx │ │ ├── menu-outline.tsx │ │ ├── pin-solid.tsx │ │ ├── heart-outline.tsx │ │ ├── leave-outline.tsx │ │ ├── basket-solid.tsx │ │ ├── block-outline.tsx │ │ ├── expand-outline.tsx │ │ ├── pencil-outline.tsx │ │ ├── _icon.tsx │ │ ├── download-outline.tsx │ │ ├── open-in-new-outline.tsx │ │ ├── person-solid.tsx │ │ ├── problem-outline.tsx │ │ ├── folder-add-outline.tsx │ │ ├── shield-outline.tsx │ │ ├── bell-outline.tsx │ │ ├── magnifying-glass-outline.tsx │ │ ├── repeat-outline.tsx │ │ ├── bullet-list-outline.tsx │ │ ├── list-sparkle-outline.tsx │ │ ├── chevron-right-outline.tsx │ │ ├── directional-pad-solid.tsx │ │ ├── image-outline.tsx │ │ ├── step-back-outline.tsx │ │ ├── pin-outline.tsx │ │ ├── repeat-off-outline.tsx │ │ ├── shield-check-outline.tsx │ │ ├── link-outline.tsx │ │ ├── audio-solid.tsx │ │ ├── more-horiz-outline.tsx │ │ ├── volume-full-outlined.tsx │ │ ├── circle-placeholder-dashed-outline.tsx │ │ ├── circle-check-solid.tsx │ │ ├── earth-outline.tsx │ │ ├── globe-outline.tsx │ │ ├── share-outline.tsx │ │ ├── heart-solid.tsx │ │ ├── bell-solid.tsx │ │ ├── graduation-cap-solid.tsx │ │ ├── circle-info-outline.tsx │ │ ├── mute-outline.tsx │ │ ├── drag-indicator-outline.tsx │ │ ├── mail-outline.tsx │ │ ├── at-outline.tsx │ │ ├── brush-solid.tsx │ │ ├── translate-outline.tsx │ │ ├── color-palette-outline.tsx │ │ ├── gear-outline.tsx │ │ ├── trending-line-outlined.tsx │ │ ├── fire-solid.tsx │ │ ├── shield-off-outline.tsx │ │ ├── write-outline.tsx │ │ ├── person-outline.tsx │ │ ├── trash-outline.tsx │ │ ├── eye-open-outline.tsx │ │ ├── bookmark-check-outline.tsx │ │ ├── american-football-solid.tsx │ │ ├── megaphone-outline.tsx │ │ ├── person-check-outline.tsx │ │ ├── people-outline.tsx │ │ ├── person-remove-outline.tsx │ │ ├── gif-square-outline.tsx │ │ └── eye-slash-outline.tsx │ ├── keyed.tsx │ ├── search │ │ ├── search-posts.tsx │ │ ├── search-feeds.tsx │ │ ├── suggestions │ │ │ ├── date-autocompletion-view.tsx │ │ │ └── search-autocompletion-view.tsx │ │ ├── search-profiles.tsx │ │ └── context.tsx │ ├── input │ │ └── char-counter-accessory.tsx │ ├── circular-progress.tsx │ ├── fab.tsx │ ├── embeds │ │ ├── lib │ │ │ └── image-utils.ts │ │ └── quote-blocked-embed.tsx │ ├── divider.tsx │ ├── lists │ │ └── lib │ │ │ └── utils.ts │ ├── alt-button.tsx │ ├── inline-link.tsx │ ├── tab-bar.tsx │ ├── fieldset.tsx │ ├── search-input.tsx │ ├── threads │ │ └── thread-lines.tsx │ ├── filter-bar.tsx │ ├── settings │ │ └── moderation │ │ │ └── labeling │ │ │ └── labeler-overflow-menu.tsx │ └── time-ago.tsx ├── globals │ ├── locales.ts │ ├── events.ts │ ├── navigation.ts │ ├── preferences.ts │ └── modals.tsx ├── views │ ├── main │ │ ├── messages.tsx │ │ └── home.tsx │ ├── _error │ │ └── index.tsx │ ├── likes.tsx │ ├── not-found.tsx │ ├── _signed-out │ │ └── index.tsx │ ├── lists.tsx │ ├── moderation-lists.tsx │ ├── post-quotes.tsx │ ├── post-likes.tsx │ ├── post-reposts.tsx │ └── profile-lists.tsx ├── assets │ ├── default-labeler-avatar.svg │ ├── default-user-avatar.svg │ ├── default-list-avatar.svg │ └── default-feed-avatar.svg ├── vite-env.d.ts ├── service-worker.tsx └── styles │ └── app.css ├── .vscode ├── extensions.json └── settings.json ├── postcss.config.js ├── tsconfig.json ├── server ├── vite-env.d.ts └── lexicons.ts ├── .gitignore ├── .editorconfig ├── wrangler.jsonc ├── index.html ├── tsconfig.node.json ├── tsconfig.worker.json ├── .prettierrc ├── patches ├── solid-js.patch ├── vite-plugin-pwa.patch └── @floating-ui__utils.patch ├── tsconfig.app.json ├── scripts └── generate-oauth-keys.js └── LICENSE /.env: -------------------------------------------------------------------------------- 1 | VITE_APP_NAME=Aglais -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "24.1.0" 3 | pnpm = "10.11.0" 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | worker-configuration.d.ts 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=false 2 | public-hoist-pattern[]=workbox-window 3 | -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | /assets/* 2 | cache-control: public, max-age=31536000, immutable 3 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mary-ext/aglais/HEAD/public/favicon.png -------------------------------------------------------------------------------- /src/api/types.ts: -------------------------------------------------------------------------------- 1 | export interface DataServer { 2 | name: string; 3 | uri: string; 4 | } 5 | -------------------------------------------------------------------------------- /public/_routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "include": ["/api/*"], 4 | "exclude": [] 5 | } 6 | -------------------------------------------------------------------------------- /src/api/utils/misc.ts: -------------------------------------------------------------------------------- 1 | export const getCurrentDate = () => { 2 | return new Date().toISOString(); 3 | }; 4 | -------------------------------------------------------------------------------- /src/lib/hooks/id.ts: -------------------------------------------------------------------------------- 1 | let uid = 0; 2 | 3 | export const createId = () => { 4 | return `_${uid++}_`; 5 | }; 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "bradlc.vscode-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/api/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type UnwrapArray = T extends (infer V)[] ? V : never; 2 | export type AccessorMaybe = T | (() => T); 3 | -------------------------------------------------------------------------------- /src/lib/utils/regex.ts: -------------------------------------------------------------------------------- 1 | const ESCAPE_RE = /[/\-\\^$*+?.()|[\]{}]/g; 2 | 3 | export const escapeRegex = (str: string) => { 4 | return str.replace(ESCAPE_RE, '\\$&'); 5 | }; 6 | -------------------------------------------------------------------------------- /src/lib/hooks/trigger.ts: -------------------------------------------------------------------------------- 1 | import { createSignal } from 'solid-js'; 2 | 3 | export const createTrigger = () => { 4 | return createSignal(undefined, { equals: false }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/api/utils/richtext.ts: -------------------------------------------------------------------------------- 1 | const WS_TRIM_RE = /^\s+|\s+$| +(?=\n)|\n(?=(?: *\n){2}) */g; 2 | 3 | export const trimRichText = (str: string) => { 4 | return str.replace(WS_TRIM_RE, ''); 5 | }; 6 | -------------------------------------------------------------------------------- /src/components/main/sign-in-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const SignInDialogLazy = lazy(() => import('./sign-in-dialog')); 4 | 5 | export default SignInDialogLazy; 6 | -------------------------------------------------------------------------------- /src/components/main/sign-out-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const SignOutDialogLazy = lazy(() => import('./sign-out-dialog')); 4 | 5 | export default SignOutDialogLazy; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.worker.json" }, 6 | { "path": "./tsconfig.node.json" }, 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "prettier.prettier-vscode", 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "tailwindCSS.experimental.classRegex": ["tw`([^`]*)"] 5 | } 6 | -------------------------------------------------------------------------------- /src/components/composer/composer-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const ComposerDialogLazy = lazy(() => import('./composer-dialog')); 4 | 5 | export default ComposerDialogLazy; 6 | -------------------------------------------------------------------------------- /src/components/composer/dialogs/gif-alt-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const GifAltDialogLazy = lazy(() => import('./gif-alt-dialog')); 4 | 5 | export default GifAltDialogLazy; 6 | -------------------------------------------------------------------------------- /src/components/timeline/pin-post-prompt-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const PinPostPromptLazy = lazy(() => import('./pin-post-prompt')); 4 | 5 | export default PinPostPromptLazy; 6 | -------------------------------------------------------------------------------- /src/lib/preferences/global.ts: -------------------------------------------------------------------------------- 1 | export interface GlobalPreferenceSchema { 2 | $version: 1; 3 | ui: UiPreferences; 4 | } 5 | 6 | export interface UiPreferences { 7 | theme: 'system' | 'light' | 'dark'; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/composer/dialogs/image-alt-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const ImageAltDialogLazy = lazy(() => import('./image-alt-dialog')); 4 | 5 | export default ImageAltDialogLazy; 6 | -------------------------------------------------------------------------------- /src/components/composer/gifs/gif-search-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const GifSearchDialogLazy = lazy(() => import('./gif-search-dialog')); 4 | 5 | export default GifSearchDialogLazy; 6 | -------------------------------------------------------------------------------- /src/components/images/image-viewer-modal-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const ImageViewerModalLazy = lazy(() => import('./image-viewer-modal')); 4 | 5 | export default ImageViewerModalLazy; 6 | -------------------------------------------------------------------------------- /src/components/timeline/delete-post-prompt-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const DeletePostPromptLazy = lazy(() => import('./delete-post-prompt')); 4 | 5 | export default DeletePostPromptLazy; 6 | -------------------------------------------------------------------------------- /src/components/timeline/revise-post-prompt-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const RevisePostPromptLazy = lazy(() => import('./revise-post-prompt')); 4 | 5 | export default RevisePostPromptLazy; 6 | -------------------------------------------------------------------------------- /server/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_APP_NAME: string; 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/composer/drafts/draft-list-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const DraftListDialogLazy = lazy(() => import('./draft-list-dialog')); 4 | 5 | export default DraftListDialogLazy; 6 | -------------------------------------------------------------------------------- /src/components/moderation/mute-account-prompt-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const MuteAccountPromptLazy = lazy(() => import('./mute-account-prompt')); 4 | 5 | export default MuteAccountPromptLazy; 6 | -------------------------------------------------------------------------------- /src/components/profiles/edit-profile-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const EditProfileDialogLazy = lazy(() => import('./edit-profile-dialog')); 4 | 5 | export default EditProfileDialogLazy; 6 | -------------------------------------------------------------------------------- /src/lib/bluemoji/render.ts: -------------------------------------------------------------------------------- 1 | export const getCdnUrl = (did: string, cid: string, format: 'png' | 'jpeg' | 'webp' = 'webp') => { 2 | return `https://cdn.bsky.app/img/avatar_thumbnail/plain/${did}/${cid}@${format}`; 3 | }; 4 | -------------------------------------------------------------------------------- /src/components/main/manage-account-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const ManageAccountDialogLazy = lazy(() => import('./manage-account-dialog')); 4 | 5 | export default ManageAccountDialogLazy; 6 | -------------------------------------------------------------------------------- /src/components/moderation/block-account-prompt-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const BlockAccountPromptLazy = lazy(() => import('./block-account-prompt')); 4 | 5 | export default BlockAccountPromptLazy; 6 | -------------------------------------------------------------------------------- /src/components/moderation/label-details-prompt-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const LabelDetailsPromptLazy = lazy(() => import('./label-details-prompt')); 4 | 5 | export default LabelDetailsPromptLazy; 6 | -------------------------------------------------------------------------------- /src/components/composer/dialogs/gif-conversion-prompt-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const GifConversionPromptLazy = lazy(() => import('./gif-conversion-prompt')); 4 | 5 | export default GifConversionPromptLazy; 6 | -------------------------------------------------------------------------------- /src/globals/locales.ts: -------------------------------------------------------------------------------- 1 | import { unique } from '@mary/array-fns'; 2 | 3 | export const systemLanguages = unique(navigator.languages.map((lang) => lang.split('-')[0])); 4 | 5 | export const primarySystemLanguage = systemLanguages[0]; 6 | -------------------------------------------------------------------------------- /src/components/bookmarks/add-post-to-folder-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const AddPostToFolderDialogLazy = lazy(() => import('./add-post-to-folder-dialog')); 4 | 5 | export default AddPostToFolderDialogLazy; 6 | -------------------------------------------------------------------------------- /src/components/composer/dialogs/language-select-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const LanguageSelectDialogLazy = lazy(() => import('./language-select-dialog')); 4 | 5 | export default LanguageSelectDialogLazy; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.wrangler/ 2 | /.research/ 3 | 4 | node_modules/ 5 | dist/ 6 | 7 | .npm-*.log 8 | .pnpm-*.log 9 | .yarn-*.log 10 | npm-*.log 11 | pnpm-*.log 12 | yarn-*.log 13 | 14 | *.local 15 | *.local.json 16 | 17 | tsconfig.tsbuildinfo 18 | -------------------------------------------------------------------------------- /src/components/bookmarks/bookmark-folder-form-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const BookmarkFolderFormDialogLazy = lazy(() => import('./bookmark-folder-form-dialog')); 4 | 5 | export default BookmarkFolderFormDialogLazy; 6 | -------------------------------------------------------------------------------- /src/components/composer/dialogs/composed-interaction-dialog-lazy.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | const ComposedInteractionDialogLazy = lazy(() => import('./composed-interaction-dialog')); 4 | 5 | export default ComposedInteractionDialogLazy; 6 | -------------------------------------------------------------------------------- /src/components/end-of-list-view.tsx: -------------------------------------------------------------------------------- 1 | const EndOfListView = () => { 2 | return ( 3 |
4 |
5 |
6 | ); 7 | }; 8 | 9 | export default EndOfListView; 10 | -------------------------------------------------------------------------------- /src/views/main/messages.tsx: -------------------------------------------------------------------------------- 1 | import { useTitle } from '~/lib/navigation/router'; 2 | 3 | const MessagesPage = () => { 4 | useTitle(() => `Messages — ${import.meta.env.VITE_APP_NAME}`); 5 | 6 | return
messages
; 7 | }; 8 | 9 | export default MessagesPage; 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = tab 6 | trim_trailing_whitespace = true 7 | 8 | [*.{yml,yaml}] 9 | indent_style = space 10 | 11 | [*.md] 12 | indent_style = space 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/lib/hooks/resize-observer.ts: -------------------------------------------------------------------------------- 1 | import { onCleanup } from 'solid-js'; 2 | 3 | export const useResizeObserver = (node: HTMLElement, callback: () => void) => { 4 | const observer = new ResizeObserver(callback); 5 | 6 | onCleanup(() => observer.disconnect()); 7 | observer.observe(node); 8 | }; 9 | -------------------------------------------------------------------------------- /src/assets/default-labeler-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/main/main-sidebar-public.tsx: -------------------------------------------------------------------------------- 1 | import * as Sidebar from '../sidebar'; 2 | 3 | const MainSidebarPublic = () => { 4 | return ( 5 | <> 6 | 7 | 8 | 9 | ); 10 | }; 11 | 12 | export default MainSidebarPublic; 13 | -------------------------------------------------------------------------------- /src/globals/events.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from '@mary/events'; 2 | 3 | export const globalEvents = new EventEmitter<{ 4 | // User has published a post 5 | postpublished: []; 6 | // Media is being played 7 | mediaplay: []; 8 | // User initiated scroll to top 9 | softreset: []; 10 | }>(); 11 | -------------------------------------------------------------------------------- /src/lib/utils/hash.ts: -------------------------------------------------------------------------------- 1 | // TinySimpleHash, public domain 2 | // https://stackoverflow.com/a/52171480 3 | export const tinyhash = (str: string): number => { 4 | for (var i = str.length, h = 9; i; ) { 5 | h = Math.imul(h ^ str.charCodeAt(--i), 9 ** 9); 6 | } 7 | 8 | return h ^ (h >>> 9); 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/circular-progress-view.tsx: -------------------------------------------------------------------------------- 1 | import CircularProgress from './circular-progress'; 2 | 3 | const CircularProgressView = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default CircularProgressView; 12 | -------------------------------------------------------------------------------- /src/components/icons-central/play-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const PlaySolidIcon = createIcon(() => ( 4 | 5 | 6 | 7 | )); 8 | 9 | export default PlaySolidIcon; 10 | -------------------------------------------------------------------------------- /src/api/queries-cache/feed-precache.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedDefs } from '@atcute/bluesky'; 2 | import type { QueryClient } from '@mary/solid-query'; 3 | 4 | export const precacheFeed = (queryClient: QueryClient, feed: AppBskyFeedDefs.GeneratorView) => { 5 | queryClient.setQueryData(['feed-meta-precache', feed.uri], feed); 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/icons-central/pause-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const PauseSolidIcon = createIcon(() => ( 4 | 5 | 6 | 7 | )); 8 | 9 | export default PauseSolidIcon; 10 | -------------------------------------------------------------------------------- /src/assets/default-user-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/icons-central/bookmark-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const BookmarkSolidIcon = createIcon(() => ( 4 | 5 | 6 | 7 | )); 8 | 9 | export default BookmarkSolidIcon; 10 | -------------------------------------------------------------------------------- /src/api/defaults.ts: -------------------------------------------------------------------------------- 1 | import type { DataServer } from './types'; 2 | 3 | export const DEFAULT_APPVIEW_URL = 'https://public.api.bsky.app'; 4 | 5 | export const DEFAULT_DATA_SERVER: DataServer = { 6 | name: 'Bluesky Social', 7 | uri: 'https://bsky.social', 8 | }; 9 | 10 | export const BLUESKY_MODERATION_DID = 'did:plc:ar7c4by46qjdydhdevvrndac'; 11 | -------------------------------------------------------------------------------- /src/lib/utils/strings.ts: -------------------------------------------------------------------------------- 1 | export const truncateMiddle = (text: string, max: number): string => { 2 | const len = text.length; 3 | 4 | if (len <= max) { 5 | return text; 6 | } 7 | 8 | const left = Math.ceil((max - 1) / 2); 9 | const right = Math.floor((max - 1) / 2); 10 | 11 | return text.slice(0, left) + '…' + text.slice(len - right); 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/icons-central/home-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // home-open 4 | const HomeSolidIcon = createIcon(() => ( 5 | 6 | 7 | 8 | )); 9 | 10 | export default HomeSolidIcon; 11 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/wrangler@latest/config-schema.json", 3 | "name": "aglais", 4 | "compatibility_date": "2025-08-16", 5 | "main": "server/index.ts", 6 | "assets": { 7 | "not_found_handling": "single-page-application", 8 | "run_worker_first": ["/xrpc/*", "/oauth-client-metadata.json", "/oauth-jwks.json"], 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/intl/language-fallback.ts: -------------------------------------------------------------------------------- 1 | const LANGUAGE_FALLBACKS: Record = { 2 | he: ['iw'], // Hebrew 3 | id: ['in'], // Indonesian 4 | }; 5 | 6 | export const expandLanguage = (code: string): string[] => { 7 | const entry = LANGUAGE_FALLBACKS[code]; 8 | if (entry) { 9 | return entry.concat(code); 10 | } 11 | 12 | return [code]; 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/icons-central/filter-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // filter-1 4 | const FilterOutlinedIcon = createIcon(() => ( 5 | 6 | 7 | 8 | )); 9 | 10 | export default FilterOutlinedIcon; 11 | -------------------------------------------------------------------------------- /src/components/icons-central/home-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // home-open 4 | const HomeOutlinedIcon = createIcon(() => ( 5 | 6 | 7 | 8 | )); 9 | 10 | export default HomeOutlinedIcon; 11 | -------------------------------------------------------------------------------- /src/lib/hooks/derived-signal.ts: -------------------------------------------------------------------------------- 1 | import { type Accessor, type Signal, createMemo, createSignal } from 'solid-js'; 2 | 3 | export const createDerivedSignal = (accessor: Accessor): Signal => { 4 | const computable = createMemo(() => createSignal(accessor())); 5 | 6 | // @ts-expect-error 7 | return [() => computable()[0](), (next) => computable()[1](next)] as Signal; 8 | }; 9 | -------------------------------------------------------------------------------- /src/api/queries-cache/list-precache.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyGraphDefs } from '@atcute/bluesky'; 2 | import type { QueryClient } from '@mary/solid-query'; 3 | 4 | export const precacheList = ( 5 | queryClient: QueryClient, 6 | list: AppBskyGraphDefs.ListView | AppBskyGraphDefs.ListViewBasic, 7 | ) => { 8 | queryClient.setQueryData(['list-meta-precache', list.uri], list); 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/icons-central/bookmark-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const BookmarkOutlinedIcon = createIcon(() => ( 4 | 5 | 6 | 7 | )); 8 | 9 | export default BookmarkOutlinedIcon; 10 | -------------------------------------------------------------------------------- /src/globals/navigation.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from '~/lib/navigation/history'; 2 | import { createHistoryLogger } from '~/lib/navigation/logger'; 3 | 4 | export const history = createBrowserHistory(); 5 | export const logger = createHistoryLogger(history); 6 | 7 | export const getEntryAt = (delta: number) => { 8 | return logger.entries[logger.active + delta]; 9 | }; 10 | -------------------------------------------------------------------------------- /src/api/queries/blob.ts: -------------------------------------------------------------------------------- 1 | import { type Client, ok } from '@atcute/client'; 2 | import type { Blob as AtpBlob } from '@atcute/lexicons'; 3 | 4 | export const uploadBlob = async (client: Client, blob: Blob): Promise> => { 5 | const data = await ok( 6 | client.post('com.atproto.repo.uploadBlob', { 7 | input: blob, 8 | }), 9 | ); 10 | 11 | return data.blob; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/icons-central/arrow-left-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const ArrowLeftOutlinedIcon = createIcon(() => ( 4 | 5 | 6 | 7 | )); 8 | 9 | export default ArrowLeftOutlinedIcon; 10 | -------------------------------------------------------------------------------- /src/components/icons-central/cross-large-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const CrossLargeOutlinedIcon = createIcon(() => ( 4 | 5 | 6 | 7 | )); 8 | 9 | export default CrossLargeOutlinedIcon; 10 | -------------------------------------------------------------------------------- /src/components/icons-central/add-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // plus-large 4 | const AddOutlinedIcon = createIcon(() => ( 5 | 6 | 7 | 8 | )); 9 | 10 | export default AddOutlinedIcon; 11 | -------------------------------------------------------------------------------- /src/components/icons-central/check-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // checkmark-2-small 4 | const CheckOutlinedIcon = createIcon(() => ( 5 | 6 | 7 | 8 | )); 9 | 10 | export default CheckOutlinedIcon; 11 | -------------------------------------------------------------------------------- /src/components/icons-central/clipboard-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const ClipboardOutlinedIcon = createIcon(() => ( 4 | 5 | 6 | 7 | )); 8 | 9 | export default ClipboardOutlinedIcon; 10 | -------------------------------------------------------------------------------- /src/components/icons-central/flag-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // flag-1 4 | const FlagOutlinedIcon = createIcon(() => ( 5 | 6 | 7 | 8 | )); 9 | 10 | export default FlagOutlinedIcon; 11 | -------------------------------------------------------------------------------- /src/api/queries-cache/profile-precache.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyActorDefs } from '@atcute/bluesky'; 2 | import type { QueryClient } from '@mary/solid-query'; 3 | 4 | export const precacheProfile = ( 5 | queryClient: QueryClient, 6 | profile: AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView, 7 | ) => { 8 | queryClient.setQueryData(['profile-precache', profile.did], profile); 9 | }; 10 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Aglais 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/icons-central/mail-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // mail-3 4 | const MailSolidIcon = createIcon(() => ( 5 | 6 | 7 | 8 | 9 | )); 10 | 11 | export default MailSolidIcon; 12 | -------------------------------------------------------------------------------- /src/lib/preferences/sessions.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyActorDefs } from '@atcute/bluesky'; 2 | import type { Did } from '@atcute/lexicons'; 3 | 4 | export interface SessionPreferenceSchema { 5 | $version: 1; 6 | active: Did | undefined; 7 | accounts: AccountData[]; 8 | } 9 | 10 | export interface AccountData { 11 | /** Account DID */ 12 | readonly did: Did; 13 | profile: AppBskyActorDefs.ProfileViewDetailed; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/icons-central/hashtag-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const HashtagOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default HashtagOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/reply-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // bubble-2 4 | const ReplyOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default ReplyOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/keyed.tsx: -------------------------------------------------------------------------------- 1 | import { type JSX, createMemo } from 'solid-js'; 2 | 3 | import { on } from '~/lib/utils/misc'; 4 | 5 | export interface KeyedProps { 6 | value: T; 7 | children: (value: T) => JSX.Element; 8 | } 9 | 10 | const Keyed = (props: KeyedProps) => { 11 | const memo = createMemo(() => props.value); 12 | return on(memo, props.children) as unknown as JSX.Element; 13 | }; 14 | 15 | export default Keyed; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/menu-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // bars-three 4 | const MenuOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default MenuOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/pin-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // thumbtack 4 | const PinSolidIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default PinSolidIcon; 14 | -------------------------------------------------------------------------------- /src/lib/bsky/video.ts: -------------------------------------------------------------------------------- 1 | export const replaceVideoCdnUrl = (url: string) => { 2 | // Redirect all files directly to the CDN, skipping the watch time/retention tracking 3 | // 4 | // Worth noting, I don't think `session_id` is tied to your account in any way, this is 5 | // mostly an effort to get videos to load faster since this player is lazily-loaded 6 | return url.replace('https://video.bsky.app/watch/', 'https://video.cdn.bsky.app/hls/'); 7 | }; 8 | -------------------------------------------------------------------------------- /src/api/cache/types.ts: -------------------------------------------------------------------------------- 1 | // This isn't a real property, but it prevents T being compatible with Shadow. 2 | declare const IsShadow: unique symbol; 3 | 4 | export type Shadow = T & { [IsShadow]: true }; 5 | 6 | export const castAsShadow = (value: T): Shadow => { 7 | return value as any as Shadow; 8 | }; 9 | 10 | export interface PostCacheFindOptions { 11 | uri?: string; 12 | rootUri?: string; 13 | includeQuote?: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/icons-central/heart-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // heart-2 4 | const HeartOutlinedIcon = createIcon(() => ( 5 | 6 | 11 | 12 | )); 13 | 14 | export default HeartOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/leave-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // arrow-box-left 4 | const LeaveOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default LeaveOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/lib/styles.ts: -------------------------------------------------------------------------------- 1 | export const clsx = (classes: (string | false | null | undefined | 0)[]): string => { 2 | var result = ''; 3 | var subsequent = false; 4 | var temp: string | false | null | undefined | 0; 5 | 6 | for (var idx = 0, len = classes.length; idx < len; idx++) { 7 | if ((temp = classes[idx])) { 8 | subsequent && (result += ' '); 9 | result += temp; 10 | 11 | subsequent = true; 12 | } 13 | } 14 | 15 | return result; 16 | }; 17 | -------------------------------------------------------------------------------- /src/lib/validation.ts: -------------------------------------------------------------------------------- 1 | export type Validation = [(value: T) => boolean, message: string]; 2 | 3 | export const validate = (value: T, validations: Validation[]): false | string => { 4 | for (let idx = 0, len = validations.length; idx < len; idx++) { 5 | const validation = validations[idx]; 6 | const result = (0, validation[0])(value); 7 | 8 | if (!result) { 9 | return validation[1]; 10 | } 11 | } 12 | 13 | return false; 14 | }; 15 | -------------------------------------------------------------------------------- /src/views/_error/index.tsx: -------------------------------------------------------------------------------- 1 | import { useTitle } from '~/lib/navigation/router'; 2 | 3 | export interface ErrorPageProps { 4 | error: unknown; 5 | reset: () => void; 6 | } 7 | 8 | const ErrorPage = ({ error, reset: _retry }: ErrorPageProps) => { 9 | useTitle(() => `Something went wrong :( — ${import.meta.env.VITE_APP_NAME}`); 10 | 11 | console.error(error); 12 | return
something went wrong
; 13 | }; 14 | 15 | export default ErrorPage; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/basket-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // basket-1 4 | const BasketSolidIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default BasketSolidIcon; 14 | -------------------------------------------------------------------------------- /src/components/icons-central/block-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // circle-ban-sign 4 | const BlockOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default BlockOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/expand-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // expand-45 4 | const ExpandOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default ExpandOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/pencil-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const PencilOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default PencilOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/_icon.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentProps, type JSX } from 'solid-js'; 2 | import { spread } from 'solid-js/web'; 3 | 4 | /*#__NO_SIDE_EFFECTS__*/ 5 | export const createIcon = (path: () => JSX.Element) => { 6 | // @ts-expect-error 7 | return Icon.bind(path); 8 | }; 9 | 10 | function Icon(this: () => Element, props: ComponentProps<'svg'>) { 11 | const svg = this(); 12 | spread(svg, props, true, true); 13 | 14 | return svg; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/icons-central/download-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // arrow-inbox 4 | const DownloadOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default DownloadOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/open-in-new-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // square-arrow-top-right 4 | const OpenInNewOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default OpenInNewOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/person-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const PersonSolidIcon = createIcon(() => ( 4 | 5 | 9 | 10 | )); 11 | 12 | export default PersonSolidIcon; 13 | -------------------------------------------------------------------------------- /src/components/icons-central/problem-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // triangle-exclamation 4 | const ProblemOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default ProblemOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/folder-add-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // folder-add-left 4 | const FolderAddOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default FolderAddOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/shield-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const ShieldOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default ShieldOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/bell-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const BellOutlinedIcon = createIcon(() => ( 4 | 5 | 10 | 11 | )); 12 | 13 | export default BellOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/components/icons-central/magnifying-glass-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const MagnifyingGlassOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default MagnifyingGlassOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/repeat-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // arrows-repeat-right-left 4 | const RepeatOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default RepeatOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/api/queries-cache/profile.ts: -------------------------------------------------------------------------------- 1 | import { AppBskyActorDefs } from '@atcute/bluesky'; 2 | import type { Did } from '@atcute/lexicons'; 3 | 4 | import type { CacheMatcher } from '../cache/utils'; 5 | 6 | export const findAllProfiles = (did: Did): CacheMatcher => { 7 | return { 8 | filter: { 9 | queryKey: ['profile', did], 10 | }, 11 | *iterate(data: AppBskyActorDefs.ProfileViewDetailed) { 12 | yield data; 13 | }, 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/bullet-list-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const BulletListOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default BulletListOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/list-sparkle-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const ListSparkleOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default ListSparkleOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/search/search-posts.tsx: -------------------------------------------------------------------------------- 1 | import type { SearchTimelineParams } from '~/api/queries/timeline'; 2 | 3 | import TimelineList from '~/components/timeline/timeline-list'; 4 | 5 | export interface SearchPostsProps { 6 | q: string; 7 | sort: SearchTimelineParams['sort']; 8 | } 9 | 10 | const SearchPosts = (props: SearchPostsProps) => { 11 | return ; 12 | }; 13 | 14 | export default SearchPosts; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/chevron-right-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // chevron-right-small 4 | const ChevronRightOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default ChevronRightOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/directional-pad-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // gamepad-controls 4 | const DirectionalPadSolidIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default DirectionalPadSolidIcon; 14 | -------------------------------------------------------------------------------- /src/components/icons-central/image-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // images-3 4 | const ImageOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default ImageOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/step-back-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const StepBackOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default StepBackOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/composer/drafts/draft-list-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as Dialog from '../../dialog'; 2 | 3 | const DraftListDialog = () => { 4 | return ( 5 | <> 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default DraftListDialog; 21 | -------------------------------------------------------------------------------- /src/components/icons-central/pin-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // thumbtack 4 | const PinOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default PinOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/repeat-off-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // arrows-repeat-right-left-off 4 | const RepeatOffOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default RepeatOffOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/shield-check-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const ShieldCheckOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default ShieldCheckOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/link-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // chain-link-3 4 | const LinkOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default LinkOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/audio-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const AudioSolidIcon = createIcon(() => ( 4 | 5 | 9 | 10 | )); 11 | 12 | export default AudioSolidIcon; 13 | -------------------------------------------------------------------------------- /src/components/icons-central/more-horiz-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // dot-grid-1x3-horizontal 4 | const MoreHorizOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default MoreHorizOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/lib/pragmatic-dnd/DraggablePreview.tsx: -------------------------------------------------------------------------------- 1 | import { type JSX, Show } from 'solid-js'; 2 | import { Portal } from 'solid-js/web'; 3 | 4 | export interface DraggablePreviewProps { 5 | container: HTMLElement | undefined; 6 | children: JSX.Element; 7 | } 8 | 9 | const DraggablePreview = (props: DraggablePreviewProps) => { 10 | return ( 11 | 12 | {(container) => {props.children}} 13 | 14 | ); 15 | }; 16 | 17 | export default DraggablePreview; 18 | -------------------------------------------------------------------------------- /src/components/icons-central/volume-full-outlined.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const VolumeFullOutlinedIcon = createIcon(() => ( 4 | 5 | 10 | 11 | )); 12 | 13 | export default VolumeFullOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/components/icons-central/circle-placeholder-dashed-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const CirclePlaceholderDashedOutlinedIcon = createIcon(() => ( 4 | 5 | 13 | 14 | )); 15 | 16 | export default CirclePlaceholderDashedOutlinedIcon; 17 | -------------------------------------------------------------------------------- /src/lib/hooks/abortable.ts: -------------------------------------------------------------------------------- 1 | import { onCleanup } from 'solid-js'; 2 | 3 | type Abortable = [signal: () => AbortSignal, cleanup: () => void]; 4 | 5 | export const makeAbortable = (): Abortable => { 6 | let controller: AbortController | undefined; 7 | 8 | const cleanup = () => { 9 | return controller?.abort(); 10 | }; 11 | 12 | const signal = () => { 13 | cleanup(); 14 | 15 | controller = new AbortController(); 16 | return controller.signal; 17 | }; 18 | 19 | onCleanup(cleanup); 20 | 21 | return [signal, cleanup]; 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/composer/lib/cid.ts: -------------------------------------------------------------------------------- 1 | import { encode } from '@atcute/cbor'; 2 | import * as CID from '@atcute/cid'; 3 | 4 | // Sanity-check by requiring a $type here, this is because the records are 5 | // expected to be encoded with it, even though the PDS accepts record writes 6 | // without the field. 7 | export const serializeRecordCid = async (record: { $type: string }) => { 8 | const bytes = encode(record); 9 | 10 | const cid = await CID.create(0x71, bytes); 11 | const serialized = CID.toString(cid); 12 | 13 | return serialized; 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/circle-check-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const CircleCheckSolidIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default CircleCheckSolidIcon; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/earth-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const EarthOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default EarthOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/lib/intl/number.ts: -------------------------------------------------------------------------------- 1 | const long = new Intl.NumberFormat('en-US'); 2 | const compact = new Intl.NumberFormat('en-US', { notation: 'compact' }); 3 | 4 | export const formatCompact = (value: number) => { 5 | if (value < 1_000) { 6 | return '' + value; 7 | } 8 | 9 | if (value < 100_000) { 10 | return long.format(value); 11 | } 12 | 13 | return compact.format(value); 14 | }; 15 | 16 | export const formatLong = (value: number) => { 17 | if (value < 1_000) { 18 | return '' + value; 19 | } 20 | 21 | return long.format(value); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/icons-central/globe-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // globus 4 | const GlobeOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default GlobeOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/icons-central/share-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const ShareOutlinedIcon = createIcon(() => ( 4 | 5 | 10 | 11 | )); 12 | 13 | export default ShareOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/components/icons-central/heart-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // heart-2 4 | const HeartSolidIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default HeartSolidIcon; 14 | -------------------------------------------------------------------------------- /src/components/icons-central/bell-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const BellSolidIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default BellSolidIcon; 15 | -------------------------------------------------------------------------------- /src/components/main/main-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'solid-js'; 2 | 3 | import { useSession } from '~/lib/states/session'; 4 | 5 | const MainSidebarAuthenticatedLazy = lazy(() => import('./main-sidebar-authenticated')); 6 | const MainSidebarPublicLazy = lazy(() => import('./main-sidebar-public')); 7 | 8 | const MainSidebar = () => { 9 | const { currentAccount } = useSession(); 10 | 11 | if (currentAccount) { 12 | return ; 13 | } else { 14 | return ; 15 | } 16 | }; 17 | 18 | export default MainSidebar; 19 | -------------------------------------------------------------------------------- /src/components/composer/compose-fab.tsx: -------------------------------------------------------------------------------- 1 | import { openModal } from '~/globals/modals'; 2 | 3 | import FAB from '../fab'; 4 | import WriteOutlinedIcon from '../icons-central/write-outline'; 5 | 6 | import ComposerDialogLazy from './composer-dialog-lazy'; 7 | 8 | export interface ComposeFABProps {} 9 | 10 | const ComposeFAB = ({}: ComposeFABProps) => { 11 | return ( 12 | { 16 | openModal(() => ); 17 | }} 18 | /> 19 | ); 20 | }; 21 | 22 | export default ComposeFAB; 23 | -------------------------------------------------------------------------------- /src/components/icons-central/graduation-cap-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // graduate-cap 4 | const GraduationCapSolidIcon = createIcon(() => ( 5 | 6 | 7 | 8 | 13 | 14 | 15 | )); 16 | 17 | export default GraduationCapSolidIcon; 18 | -------------------------------------------------------------------------------- /src/components/icons-central/circle-info-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const CircleInfoOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 17 | 18 | )); 19 | 20 | export default CircleInfoOutlinedIcon; 21 | -------------------------------------------------------------------------------- /src/components/icons-central/mute-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const MuteOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | 13 | )); 14 | 15 | export default MuteOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/api/moderation/entities/generic.ts: -------------------------------------------------------------------------------- 1 | import type { ComAtprotoLabelDefs } from '@atcute/atproto'; 2 | import type { Did } from '@atcute/lexicons'; 3 | 4 | import { type ModerationCause, type ModerationOptions, decideLabelModeration } from '..'; 5 | import { TargetContent } from '../constants'; 6 | 7 | export const moderateGeneric = ( 8 | item: { labels?: ComAtprotoLabelDefs.Label[] }, 9 | userDid: Did, 10 | opts: ModerationOptions, 11 | ) => { 12 | const accu: ModerationCause[] = []; 13 | 14 | decideLabelModeration(accu, TargetContent, item.labels, userDid, opts); 15 | 16 | return accu; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/icons-central/drag-indicator-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // dot-grid-2x3 4 | const DragIndicatorOutlinedIcon = createIcon(() => ( 5 | 6 | 11 | 12 | )); 13 | 14 | export default DragIndicatorOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/mail-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // mail-3 4 | const MailOutlinedIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default MailOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/api/utils/did.ts: -------------------------------------------------------------------------------- 1 | import { type Client, ok } from '@atcute/client'; 2 | import type { Did, Handle } from '@atcute/lexicons'; 3 | import { isDid } from '@atcute/lexicons/syntax'; 4 | 5 | const getDid = async (client: Client, actor: Handle, signal?: AbortSignal) => { 6 | let did: Did; 7 | if (isDid(actor)) { 8 | did = actor; 9 | } else { 10 | const data = await ok( 11 | client.get('com.atproto.identity.resolveHandle', { 12 | signal: signal, 13 | params: { handle: actor }, 14 | }), 15 | ); 16 | 17 | did = data.did; 18 | } 19 | 20 | return did; 21 | }; 22 | 23 | export default getDid; 24 | -------------------------------------------------------------------------------- /src/components/icons-central/at-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const AtOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default AtOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/components/input/char-counter-accessory.tsx: -------------------------------------------------------------------------------- 1 | import { createMemo } from 'solid-js'; 2 | 3 | import { formatLong } from '~/lib/intl/number'; 4 | 5 | export interface CharCounterAccessoryProps { 6 | value: number; 7 | max: number; 8 | } 9 | 10 | const CharCounterAccessory = (props: CharCounterAccessoryProps) => { 11 | const isOver = createMemo(() => props.value > props.max); 12 | 13 | return ( 14 | 15 | {formatLong(props.value)}/{formatLong(props.max)} 16 | 17 | ); 18 | }; 19 | 20 | export default CharCounterAccessory; 21 | -------------------------------------------------------------------------------- /src/components/icons-central/brush-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const BrushSolidIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default BrushSolidIcon; 15 | -------------------------------------------------------------------------------- /src/components/icons-central/translate-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const TranslateOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 17 | 18 | )); 19 | 20 | export default TranslateOutlinedIcon; 21 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | /// 5 | /// 6 | 7 | interface ImportMetaEnv { 8 | readonly VITE_APP_NAME: string; 9 | readonly VITE_OAUTH_CLIENT_ID: string; 10 | readonly VITE_OAUTH_REDIRECT_URL: string; 11 | readonly VITE_OAUTH_SCOPE: string; 12 | } 13 | 14 | interface ImportMeta { 15 | readonly env: ImportMetaEnv; 16 | } 17 | 18 | declare module 'hls.js/dist/hls.light.js' { 19 | export * from 'hls.js'; 20 | export { default } from 'hls.js'; 21 | } 22 | -------------------------------------------------------------------------------- /src/assets/default-list-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/lib/bsky/url.ts: -------------------------------------------------------------------------------- 1 | export const BSKY_FEED_LINK_RE = /^\/profile\/([^/]+)\/feed\/([^/]+)\/?$/; 2 | export const BSKY_HASHTAG_LINK_RE = /^\/hashtag\/([^/]+)\/?$/; 3 | export const BSKY_LIST_LINK_RE = /^\/profile\/([^/]+)\/lists\/([^/]+)\/?$/; 4 | export const BSKY_POST_LINK_RE = /^\/profile\/([^/]+)\/post\/([^/]+)\/?$/; 5 | export const BSKY_PROFILE_LINK_RE = /^\/profile\/([^/]+)\/?$/; 6 | export const BSKY_SEARCH_LINK_RE = /^\/search\/?$/; 7 | export const BSKY_STARTERPACK_LINK_RE = /^\/(starter-pack|start)\/([^/]+)\/([^/]+)\/?$/; 8 | 9 | // go.bsky.app/ 10 | export const BSKY_GO_SHORTLINK_RE = /^\/([1-9A-HJ-NP-Za-km-z]{1,22})\/?$/; 11 | -------------------------------------------------------------------------------- /src/api/queries-cache/profile-autocomplete.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyActorDefs, AppBskyActorSearchActorsTypeahead } from '@atcute/bluesky'; 2 | import type { Did } from '@atcute/lexicons'; 3 | 4 | import type { CacheMatcher } from '../cache/utils'; 5 | 6 | export const findAllProfiles = (did: Did): CacheMatcher => { 7 | return { 8 | filter: { 9 | queryKey: ['profile-autocomplete'], 10 | }, 11 | *iterate(data: AppBskyActorSearchActorsTypeahead.$output) { 12 | for (const profile of data.actors) { 13 | if (profile.did === did) { 14 | yield profile; 15 | } 16 | } 17 | }, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/icons-central/color-palette-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const ColorPaletteOutlinedIcon = createIcon(() => ( 4 | 5 | 11 | 12 | )); 13 | 14 | export default ColorPaletteOutlinedIcon; 15 | -------------------------------------------------------------------------------- /src/lib/hooks/escape.ts: -------------------------------------------------------------------------------- 1 | import { createEffect } from 'solid-js'; 2 | 3 | import { createEventListener } from './event-listener'; 4 | 5 | export const useEscape = (callback: () => void, enabled: () => boolean) => { 6 | createEffect(() => { 7 | if (!enabled()) { 8 | return; 9 | } 10 | 11 | createEventListener(window, 'keydown', (ev) => { 12 | if (ev.key === 'Escape' && !ev.defaultPrevented) { 13 | ev.preventDefault(); 14 | 15 | const focused = document.activeElement; 16 | if (focused !== null && focused !== document.body) { 17 | (focused as any).blur(); 18 | } 19 | 20 | callback(); 21 | } 22 | }); 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/icons-central/gear-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // settings-gear-2 4 | const GearOutlinedIcon = createIcon(() => ( 5 | 6 | 11 | 12 | 13 | )); 14 | 15 | export default GearOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/components/timeline/post-deleted-gate.tsx: -------------------------------------------------------------------------------- 1 | import { type JSX, Show } from 'solid-js'; 2 | 3 | export interface PostDeletedGateProps { 4 | deleted: boolean; 5 | bypass: boolean; 6 | children: JSX.Element; 7 | } 8 | 9 | const PostDeletedGate = (props: PostDeletedGateProps) => { 10 | if (props.bypass) { 11 | return props.children; 12 | } 13 | 14 | return ( 15 | 16 |
17 |
18 |

Post deleted

19 |
20 |
21 |
22 | ); 23 | }; 24 | 25 | export default PostDeletedGate; 26 | -------------------------------------------------------------------------------- /src/components/icons-central/trending-line-outlined.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // trending-2; 0px round 4 | const TrendingLineOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default TrendingLineOutlinedIcon; 16 | -------------------------------------------------------------------------------- /src/lib/hooks/debounced-value.ts: -------------------------------------------------------------------------------- 1 | import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js'; 2 | 3 | export const createDebouncedValue = ( 4 | accessor: Accessor, 5 | delay: number, 6 | equals?: false | ((prev: T, next: T) => boolean), 7 | ): Accessor => { 8 | const initial = accessor(); 9 | const [state, setState] = createSignal(initial, { equals }); 10 | 11 | createEffect((prev: T) => { 12 | const next = accessor(); 13 | 14 | if (prev !== next) { 15 | const timeout = setTimeout(() => setState(() => next), delay); 16 | onCleanup(() => clearTimeout(timeout)); 17 | } 18 | 19 | return next; 20 | }, initial); 21 | 22 | return state; 23 | }; 24 | -------------------------------------------------------------------------------- /src/lib/observer.ts: -------------------------------------------------------------------------------- 1 | import { batch } from 'solid-js'; 2 | 3 | export const intersectionCallback: IntersectionObserverCallback = (entries, observer) => { 4 | batch(() => { 5 | for (let idx = 0, len = entries.length; idx < len; idx++) { 6 | const entry = entries[idx]; 7 | 8 | const target = entry.target as any; 9 | const listener = target.$onintersect; 10 | 11 | if (listener) { 12 | listener(entry); 13 | } else { 14 | observer.unobserve(target); 15 | } 16 | } 17 | }); 18 | }; 19 | 20 | declare module 'solid-js' { 21 | namespace JSX { 22 | interface ExplicitProperties { 23 | $onintersect: (entry: IntersectionObserverEntry) => void; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/assets/default-feed-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/circular-progress.tsx: -------------------------------------------------------------------------------- 1 | export interface CircularProgressProps { 2 | size?: number; 3 | } 4 | 5 | const CircularProgress = (props: CircularProgressProps) => { 6 | return ( 7 | 12 | 13 | 23 | 24 | ); 25 | }; 26 | 27 | export default CircularProgress; 28 | -------------------------------------------------------------------------------- /src/components/icons-central/fire-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // fire-2 4 | const FireSolidIcon = createIcon(() => ( 5 | 6 | 12 | 13 | )); 14 | 15 | export default FireSolidIcon; 16 | -------------------------------------------------------------------------------- /src/lib/element-refs.ts: -------------------------------------------------------------------------------- 1 | import { createEffect, onCleanup } from 'solid-js'; 2 | 3 | import { intersectionCallback } from './observer'; 4 | 5 | export const ifIntersect = ( 6 | node: HTMLElement, 7 | enabled: () => boolean | undefined, 8 | onIntersect: () => void, 9 | options?: IntersectionObserverInit, 10 | ) => { 11 | const observer = new IntersectionObserver(intersectionCallback, options); 12 | 13 | // @ts-expect-error 14 | node.$onintersect = (entry: IntersectionObserverEntry) => { 15 | if (entry.isIntersecting) { 16 | onIntersect(); 17 | } 18 | }; 19 | 20 | createEffect(() => { 21 | if (enabled()) { 22 | observer.observe(node); 23 | onCleanup(() => observer.disconnect()); 24 | } 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/icons-central/shield-off-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // shield-break 4 | const ShieldOffOutlinedIcon = createIcon(() => ( 5 | 6 | 12 | 16 | 17 | )); 18 | 19 | export default ShieldOffOutlinedIcon; 20 | -------------------------------------------------------------------------------- /src/components/main/sign-out-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { createProfileQuery } from '~/api/queries/profile'; 2 | 3 | import { useSession } from '~/lib/states/session'; 4 | 5 | import * as Prompt from '../prompt'; 6 | 7 | const SignOutDialog = () => { 8 | const { currentAccount, getAccounts, logout } = useSession(); 9 | 10 | const profile = createProfileQuery(() => currentAccount!.did); 11 | 12 | return ( 13 | 1 ? <>You'll still be signed in to your other accounts. : <>} 16 | confirmLabel="Sign out" 17 | onConfirm={() => logout()} 18 | /> 19 | ); 20 | }; 21 | 22 | export default SignOutDialog; 23 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | 5 | "target": "ESNext", 6 | "lib": ["ESNext"], 7 | "types": ["node"], 8 | "skipLibCheck": true, 9 | 10 | "module": "ESNext", 11 | "moduleResolution": "bundler", 12 | "moduleDetection": "force", 13 | "allowImportingTsExtensions": true, 14 | "noEmit": true, 15 | 16 | "incremental": true, 17 | "strict": true, 18 | "verbatimModuleSyntax": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true, 23 | 24 | "useDefineForClassFields": false 25 | }, 26 | "include": ["vite.config.ts"] 27 | } 28 | -------------------------------------------------------------------------------- /src/api/queries/bookmark-entry.ts: -------------------------------------------------------------------------------- 1 | import { createQuery } from '@mary/solid-query'; 2 | 3 | import { inject } from '~/lib/states/singleton'; 4 | import BookmarksService from '~/lib/states/singletons/bookmarks'; 5 | 6 | export const createBookmarkEntryQuery = (postUri: () => string) => { 7 | const bookmarks = inject(BookmarksService); 8 | 9 | const entry = createQuery(() => { 10 | const $postUri = postUri(); 11 | 12 | return { 13 | queryKey: ['bookmark-entry', $postUri], 14 | async queryFn() { 15 | const db = await bookmarks.open(); 16 | const item = await db.get('bookmarks', $postUri); 17 | 18 | return { item }; 19 | }, 20 | initialData: { 21 | item: undefined, 22 | }, 23 | }; 24 | }); 25 | 26 | return entry; 27 | }; 28 | -------------------------------------------------------------------------------- /tsconfig.worker.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | 5 | "target": "ESNext", 6 | "lib": ["ESNext"], 7 | "types": ["./worker-configuration.d.ts", "vite/client"], 8 | "skipLibCheck": true, 9 | 10 | "module": "ESNext", 11 | "moduleResolution": "bundler", 12 | "moduleDetection": "force", 13 | "allowImportingTsExtensions": true, 14 | "noEmit": true, 15 | 16 | "incremental": true, 17 | "strict": true, 18 | "verbatimModuleSyntax": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true, 23 | 24 | "useDefineForClassFields": false 25 | }, 26 | "include": ["server"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/icons-central/write-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // edit-big 4 | const WriteOutlinedIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default WriteOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/api/utils/url.ts: -------------------------------------------------------------------------------- 1 | import { safeUrlParse } from './strings'; 2 | 3 | const TRIM_HOST_RE = /^www\./; 4 | const PATH_MAX_LENGTH = 16; 5 | 6 | export const toShortUrl = (href: string): string => { 7 | const url = safeUrlParse(href); 8 | 9 | if (url !== null) { 10 | const host = 11 | (url.username ? url.username + (url.password ? ':' + url.password : '') + '@' : '') + 12 | url.host.replace(TRIM_HOST_RE, ''); 13 | 14 | const path = 15 | (url.pathname === '/' ? '' : url.pathname) + 16 | (url.search.length > 1 ? url.search : '') + 17 | (url.hash.length > 1 ? url.hash : ''); 18 | 19 | if (path.length > PATH_MAX_LENGTH) { 20 | return host + path.slice(0, PATH_MAX_LENGTH - 1) + '…'; 21 | } 22 | 23 | return host + path; 24 | } 25 | 26 | return href; 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/fab.tsx: -------------------------------------------------------------------------------- 1 | import type { Component } from 'solid-js'; 2 | 3 | export interface FABProps { 4 | label: string; 5 | icon: Component; 6 | onClick?: () => void; 7 | } 8 | 9 | const FAB = (props: FABProps) => { 10 | return ( 11 |
12 |
13 | 23 |
24 |
25 | ); 26 | }; 27 | 28 | export default FAB; 29 | -------------------------------------------------------------------------------- /src/components/icons-central/person-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // people 4 | const PersonOutlinedIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default PersonOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/components/icons-central/trash-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // trash-can 4 | const TrashOutlinedIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default TrashOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/lib/utils/invariant.ts: -------------------------------------------------------------------------------- 1 | export const assert: { 2 | (condition: any, message?: string): asserts condition; 3 | } = (condition, message): asserts condition => { 4 | if (import.meta.env.DEV && !condition) { 5 | throw new Error(`Assertion failed` + (message ? `: ${message}` : ``)); 6 | } 7 | }; 8 | 9 | export const assertStrong: { 10 | (condition: any, message?: string): asserts condition; 11 | } = (condition, message): asserts condition => { 12 | if (!condition) { 13 | if (import.meta.env.DEV) { 14 | throw new Error(`Assertion failed` + (message ? `: ${message}` : ``)); 15 | } 16 | 17 | throw new Error(`Assertion failed`); 18 | } 19 | }; 20 | 21 | export const assertUnreachable: { 22 | (_: never, message?: string): never; 23 | } = (_, message) => { 24 | assertStrong(false, message); 25 | }; 26 | -------------------------------------------------------------------------------- /src/api/types/at-uri.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type ActorIdentifier, 3 | type Nsid, 4 | type ParsedCanonicalResourceUri, 5 | type RecordKey, 6 | type ResourceUri, 7 | parseCanonicalResourceUri, 8 | } from '@atcute/lexicons'; 9 | import type { Records } from '@atcute/lexicons/ambient'; 10 | 11 | import { assert } from '~/lib/utils/invariant'; 12 | 13 | // #__NO_SIDE_EFFECTS__ 14 | export const assertCanonicalResourceUri = (input: string): ParsedCanonicalResourceUri => { 15 | const result = parseCanonicalResourceUri(input); 16 | if (!result.ok) { 17 | assert(false, result.error); 18 | } 19 | 20 | return result.value; 21 | }; 22 | 23 | export const makeAtUri = ( 24 | repo: ActorIdentifier, 25 | collection: keyof Records | (Nsid & {}), 26 | rkey: RecordKey, 27 | ): ResourceUri => { 28 | return `at://${repo}/${collection}/${rkey}`; 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/icons-central/eye-open-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const EyeOpenOutlinedIcon = createIcon(() => ( 4 | 5 | 9 | 10 | )); 11 | 12 | export default EyeOpenOutlinedIcon; 13 | -------------------------------------------------------------------------------- /src/views/likes.tsx: -------------------------------------------------------------------------------- 1 | import { useTitle } from '~/lib/navigation/router'; 2 | import { useSession } from '~/lib/states/session'; 3 | 4 | import * as Page from '~/components/page'; 5 | import TimelineList from '~/components/timeline/timeline-list'; 6 | 7 | const LikesPage = () => { 8 | const { currentAccount } = useSession(); 9 | 10 | const did = currentAccount!.did; 11 | 12 | useTitle(() => `My likes — ${import.meta.env.VITE_APP_NAME}`); 13 | 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 31 | 32 | ); 33 | }; 34 | 35 | export default LikesPage; 36 | -------------------------------------------------------------------------------- /src/components/icons-central/bookmark-check-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const BookmarkCheckOutlinedIcon = createIcon(() => ( 4 | 5 | 9 | 10 | )); 11 | 12 | export default BookmarkCheckOutlinedIcon; 13 | -------------------------------------------------------------------------------- /src/components/icons-central/american-football-solid.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const AmericanFootballSolidIcon = createIcon(() => ( 4 | 5 | 9 | 15 | 19 | 20 | )); 21 | 22 | export default AmericanFootballSolidIcon; 23 | -------------------------------------------------------------------------------- /src/lib/bsky/crop.ts: -------------------------------------------------------------------------------- 1 | export type CropResult = [offsetX: number, offsetY: number, width: number, height: number]; 2 | export type CropFunction = (pW: number, pH: number, cW: number, cH: number) => CropResult; 3 | 4 | export const contain = (pW: number, pH: number, cW: number, cH: number): CropResult => { 5 | const cR = cW / cH; 6 | const pR = pW / pH; 7 | 8 | let w = pW; 9 | let h = pH; 10 | 11 | if (cR > pR) { 12 | h = w / cR; 13 | } else { 14 | w = h * cR; 15 | } 16 | 17 | return [(pW - w) * 0.5, (pH - h) * 0.5, w, h]; 18 | }; 19 | 20 | export const cover = (pW: number, pH: number, cW: number, cH: number): CropResult => { 21 | const cR = cW / cH; 22 | const pR = pW / pH; 23 | 24 | let w = pW; 25 | let h = pH; 26 | 27 | if (cR < pR) { 28 | h = w / cR; 29 | } else { 30 | w = h * cR; 31 | } 32 | 33 | return [(pW - w) * 0.5, (pH - h) * 0.5, w, h]; 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/embeds/lib/image-utils.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyEmbedImages } from '@atcute/bluesky'; 2 | 3 | const clamp = (value: number, min: number, max: number): number => { 4 | return Math.max(min, Math.min(max, value)); 5 | }; 6 | 7 | export const getAspectRatio = (image: AppBskyEmbedImages.ViewImage): number => { 8 | const dims = image.aspectRatio; 9 | 10 | const width = dims ? dims.width : 1; 11 | const height = dims ? dims.height : 1; 12 | const ratio = width / height; 13 | 14 | return ratio; 15 | }; 16 | 17 | export const clampBetween9_16And16_9 = (ratio: number): number => { 18 | return clamp(ratio, 9 / 16, 16 / 9); 19 | }; 20 | 21 | export const clampBetween3_4And16_9 = (ratio: number): number => { 22 | return clamp(ratio, 3 / 4, 16 / 9); 23 | }; 24 | 25 | export const isRatioMismatching = (a: number, b: number): boolean => { 26 | return Math.abs(a - b) > 0.03; 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/divider.tsx: -------------------------------------------------------------------------------- 1 | type Gutter = false | 'sm' | 'md'; 2 | 3 | export interface DividerProps { 4 | gutter?: Gutter; 5 | gutterTop?: Gutter; 6 | gutterBottom?: Gutter; 7 | class?: string; 8 | } 9 | 10 | const Divider = (props: DividerProps) => { 11 | return
; 12 | }; 13 | 14 | const dividerClassNames = ({ 15 | gutter = false, 16 | gutterBottom = gutter, 17 | gutterTop = gutter, 18 | class: className, 19 | }: DividerProps) => { 20 | let cn = `border-outline`; 21 | 22 | if (gutterBottom === 'sm') { 23 | cn += ` mb-1`; 24 | } else if (gutterBottom === 'md') { 25 | cn += ` mb-3`; 26 | } 27 | 28 | if (gutterTop === 'sm') { 29 | cn += ` mt-1`; 30 | } else if (gutterTop === 'md') { 31 | cn += ` mt-3`; 32 | } 33 | 34 | if (className) { 35 | return `${cn} ${className}`; 36 | } 37 | 38 | return cn; 39 | }; 40 | 41 | export default Divider; 42 | -------------------------------------------------------------------------------- /src/lib/bsky/video-upload.ts: -------------------------------------------------------------------------------- 1 | export interface KnownVideoMetadata { 2 | width: number; 3 | height: number; 4 | duration: number; 5 | } 6 | 7 | export const getVideoMetadata = (blob: Blob): Promise => { 8 | return new Promise((resolve, reject) => { 9 | const video = document.createElement('video'); 10 | const blobUrl = URL.createObjectURL(blob); 11 | 12 | const cleanup = () => { 13 | URL.revokeObjectURL(blobUrl); 14 | }; 15 | 16 | video.preload = 'metadata'; 17 | video.src = blobUrl; 18 | 19 | video.onloadedmetadata = () => { 20 | cleanup(); 21 | resolve({ 22 | width: video.videoWidth, 23 | height: video.videoHeight, 24 | duration: video.duration, 25 | }); 26 | }; 27 | video.onerror = (_ev, _source, _lineno, _colno, error) => { 28 | cleanup(); 29 | reject(new Error(`failed to grab video metadata`, { cause: error })); 30 | }; 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/icons-central/megaphone-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const MegaphoneOutlinedIcon = createIcon(() => ( 4 | 5 | 9 | 10 | )); 11 | 12 | export default MegaphoneOutlinedIcon; 13 | -------------------------------------------------------------------------------- /src/components/lists/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyGraphDefs } from '@atcute/bluesky'; 2 | 3 | import { assertCanonicalResourceUri } from '~/api/types/at-uri'; 4 | 5 | export const getListPurposeLabel = (purpose: AppBskyGraphDefs.ListPurpose) => { 6 | switch (purpose) { 7 | case 'app.bsky.graph.defs#curatelist': 8 | return `Curation list`; 9 | case 'app.bsky.graph.defs#modlist': 10 | return `Moderation list`; 11 | } 12 | 13 | return `Unknown list`; 14 | }; 15 | 16 | export const getListUrl = (list: AppBskyGraphDefs.ListView) => { 17 | const did = list.creator.did; 18 | const { rkey } = assertCanonicalResourceUri(list.uri); 19 | 20 | switch (list.purpose) { 21 | case 'app.bsky.graph.defs#curatelist': 22 | return `/${did}/curation-lists/${rkey}`; 23 | case 'app.bsky.graph.defs#modlist': 24 | return `/${did}/moderation-lists/${rkey}`; 25 | } 26 | 27 | return `/${did}/lists/${rkey}`; 28 | }; 29 | -------------------------------------------------------------------------------- /src/lib/aglais-bookmarks/db.ts: -------------------------------------------------------------------------------- 1 | import type { DBSchema } from 'idb'; 2 | 3 | import type { AppBskyFeedDefs } from '@atcute/bluesky'; 4 | 5 | export interface BookmarkDBSchema extends DBSchema { 6 | tags: { 7 | key: string; 8 | value: TagItem; 9 | indexes: { 10 | created_at: number; 11 | }; 12 | }; 13 | bookmarks: { 14 | key: string; 15 | value: BookmarkItem; 16 | indexes: { 17 | bookmarked_at: number; 18 | tags: string; 19 | }; 20 | }; 21 | } 22 | 23 | export interface TagItem { 24 | id: string; 25 | name: string; 26 | color: string | undefined; 27 | icon: string | undefined; 28 | created_at: number; 29 | } 30 | 31 | export interface BookmarkItem { 32 | view: AppBskyFeedDefs.PostView; 33 | bookmarked_at: number; 34 | tags: string[]; 35 | } 36 | 37 | export interface HydratedBookmarkItem { 38 | post: AppBskyFeedDefs.PostView; 39 | stale: boolean; 40 | bookmarkedAt: number; 41 | } 42 | -------------------------------------------------------------------------------- /src/api/queries/handle.ts: -------------------------------------------------------------------------------- 1 | import { type Client, ok } from '@atcute/client'; 2 | import type { Handle } from '@atcute/lexicons'; 3 | import { createQuery } from '@mary/solid-query'; 4 | 5 | import { useAgent } from '~/lib/states/agent'; 6 | 7 | export const useResolveHandleQuery = (handle: () => Handle) => { 8 | const { client } = useAgent(); 9 | 10 | return createQuery(() => { 11 | const $handle = handle(); 12 | 13 | return { 14 | queryKey: ['resolve-handle', $handle], 15 | async queryFn(ctx) { 16 | return resolveHandle(client, $handle, ctx.signal); 17 | }, 18 | }; 19 | }); 20 | }; 21 | 22 | export const resolveHandle = async (client: Client, handle: Handle, signal?: AbortSignal) => { 23 | const data = await ok( 24 | client.get('com.atproto.identity.resolveHandle', { 25 | signal: signal, 26 | params: { 27 | handle: handle, 28 | }, 29 | }), 30 | ); 31 | 32 | return data.did; 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/alt-button.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'solid-js'; 2 | 3 | import { useFieldset } from './fieldset'; 4 | import CheckOutlinedIcon from './icons-central/check-outline'; 5 | 6 | export interface AltButtonProps { 7 | checked?: boolean; 8 | title: string; 9 | onClick?: JSX.EventHandler; 10 | } 11 | 12 | const AltButton = (props: AltButtonProps) => { 13 | const fieldset = useFieldset(); 14 | 15 | return ( 16 | 27 | ); 28 | }; 29 | 30 | export default AltButton; 31 | -------------------------------------------------------------------------------- /src/api/utils/unicode.ts: -------------------------------------------------------------------------------- 1 | const encoder = new TextEncoder(); 2 | 3 | const segmenter = new Intl.Segmenter(); 4 | 5 | export const graphemeLen = (text: string): number => { 6 | var length = asciiLen(text); 7 | 8 | if (length === undefined) { 9 | return _graphemeLen(text); 10 | } 11 | 12 | return length; 13 | }; 14 | 15 | export const asciiLen = (str: string): number | undefined => { 16 | for (var idx = 0, len = str.length; idx < len; idx++) { 17 | const char = str.charCodeAt(idx); 18 | 19 | if (char > 127) { 20 | return undefined; 21 | } 22 | } 23 | 24 | return len; 25 | }; 26 | 27 | export const getUtf8Length = (str: string): number => { 28 | return encoder.encode(str).byteLength; 29 | }; 30 | 31 | const _graphemeLen = (text: string): number => { 32 | var iterator = segmenter.segment(text)[Symbol.iterator](); 33 | var count = 0; 34 | 35 | while (!iterator.next().done) { 36 | count++; 37 | } 38 | 39 | return count; 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/search/search-feeds.tsx: -------------------------------------------------------------------------------- 1 | import { createSearchFeedsQuery } from '~/api/queries/search-feeds'; 2 | 3 | import FeedItem from '~/components/feeds/feed-item'; 4 | import PagedList from '~/components/paged-list'; 5 | import VirtualItem from '~/components/virtual-item'; 6 | 7 | export interface SearchFeedsProps { 8 | q: string; 9 | } 10 | 11 | const SearchFeeds = (props: SearchFeedsProps) => { 12 | const feeds = createSearchFeedsQuery(() => props.q); 13 | 14 | return ( 15 | page.feeds)} 17 | error={feeds.error} 18 | render={(item) => { 19 | return ( 20 | 21 | 22 | 23 | ); 24 | }} 25 | hasNextPage={feeds.hasNextPage} 26 | isFetchingNextPage={feeds.isFetching} 27 | onEndReached={() => feeds.fetchNextPage()} 28 | /> 29 | ); 30 | }; 31 | 32 | export default SearchFeeds; 33 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": true, 4 | "tabWidth": 2, 5 | "printWidth": 110, 6 | "semi": true, 7 | "singleQuote": true, 8 | "bracketSpacing": true, 9 | "plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], 10 | "tailwindFunctions": ["tw"], 11 | "importOrder": [ 12 | "^node:", 13 | "", 14 | "^@(atcute|mary)/", 15 | "^~/api/", 16 | "^~/globals/", 17 | "^~/lib/", 18 | "^~/components/", 19 | "^~/views/", 20 | "^~/assets/", 21 | "^~", 22 | "^\\.{2}(?:/(?:.*)(? void; 15 | } 16 | 17 | const DateAutocompletionView = (props: DateAutocompletionViewProps) => { 18 | return ( 19 | <> 20 |
21 | { 26 | props.onCompletion(isoDateFormatter.format(next)); 27 | }} 28 | /> 29 |
30 | 31 | ); 32 | }; 33 | 34 | export default DateAutocompletionView; 35 | -------------------------------------------------------------------------------- /patches/solid-js.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/solid.js b/dist/solid.js 2 | index a36e31312a461cdf9999e22aa893ef29162750c8..6de1f7f7d608e07baa9edbc6f0ee394f7f9b4411 100644 3 | --- a/dist/solid.js 4 | +++ b/dist/solid.js 5 | @@ -1468,7 +1468,6 @@ function Show(props) { 6 | const child = props.children; 7 | const fn = typeof child === "function" && child.length > 0; 8 | return fn ? untrack(() => child(keyed ? c : () => { 9 | - if (!untrack(condition)) throw narrowedError("Show"); 10 | return conditionValue(); 11 | })) : child; 12 | } 13 | @@ -1500,7 +1499,6 @@ function Switch(props) { 14 | const child = mp.children; 15 | const fn = typeof child === "function" && child.length > 0; 16 | return fn ? untrack(() => child(mp.keyed ? conditionValue() : () => { 17 | - if (untrack(switchFunc)()?.[0] !== index) throw narrowedError("Match"); 18 | return conditionValue(); 19 | })) : child; 20 | }, undefined, undefined); 21 | -------------------------------------------------------------------------------- /src/components/inline-link.tsx: -------------------------------------------------------------------------------- 1 | import type { ParentProps } from 'solid-js'; 2 | 3 | import { useFieldset } from './fieldset'; 4 | 5 | export interface InlineLinkProps extends ParentProps { 6 | href?: string; 7 | disabled?: boolean; 8 | onClick?: () => void; 9 | } 10 | 11 | const InlineLink = (props: InlineLinkProps) => { 12 | const fieldset = useFieldset(); 13 | const isDisabled = (): boolean => fieldset.disabled || !!props.disabled; 14 | 15 | return ( 16 | 24 | ); 25 | }; 26 | 27 | const inlineLinkClassNames = (isDisabled: () => boolean): string => { 28 | var cn = `text-accent text-left text-de`; 29 | 30 | if (isDisabled()) { 31 | cn += ` opacity-50`; 32 | } else { 33 | cn += ` hover:underline`; 34 | } 35 | 36 | return cn; 37 | }; 38 | 39 | export default InlineLink; 40 | -------------------------------------------------------------------------------- /src/api/moderation/entities/post.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/bluesky'; 2 | 3 | import { unwrapPostEmbedText } from '~/api/utils/post'; 4 | 5 | import { 6 | type ModerationCause, 7 | type ModerationOptions, 8 | decideLabelModeration, 9 | decideMutedKeywordModeration, 10 | } from '..'; 11 | import { PreferenceWarn, TargetContent } from '../constants'; 12 | 13 | import { moderateProfile } from './profile'; 14 | 15 | export const moderatePost = (post: AppBskyFeedDefs.PostView, opts: ModerationOptions) => { 16 | const author = post.author; 17 | const record = post.record as AppBskyFeedPost.Main; 18 | const text = record.text + unwrapPostEmbedText(record.embed); 19 | 20 | const accu: ModerationCause[] = moderateProfile(author, opts); 21 | 22 | decideLabelModeration(accu, TargetContent, post.labels, author.did, opts); 23 | decideMutedKeywordModeration(accu, text, !!author.viewer?.following, PreferenceWarn, opts); 24 | 25 | return accu; 26 | }; 27 | -------------------------------------------------------------------------------- /src/api/moderation/entities/quote.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; 2 | 3 | import { unwrapPostEmbedText } from '~/api/utils/post'; 4 | 5 | import { 6 | type ModerationCause, 7 | type ModerationOptions, 8 | decideLabelModeration, 9 | decideMutedKeywordModeration, 10 | } from '..'; 11 | import { PreferenceWarn, TargetContent } from '../constants'; 12 | 13 | import { moderateProfile } from './profile'; 14 | 15 | export const moderateQuote = (quote: AppBskyEmbedRecord.ViewRecord, opts: ModerationOptions) => { 16 | const author = quote.author; 17 | const record = quote.value as AppBskyFeedPost.Main; 18 | const text = record.text + unwrapPostEmbedText(record.embed); 19 | 20 | const accu: ModerationCause[] = moderateProfile(author, opts); 21 | 22 | decideLabelModeration(accu, TargetContent, quote.labels, author.did, opts); 23 | decideMutedKeywordModeration(accu, text, !!author.viewer?.following, PreferenceWarn, opts); 24 | 25 | return accu; 26 | }; 27 | -------------------------------------------------------------------------------- /src/api/queries-cache/post-quotes.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedDefs, AppBskyFeedGetQuotes } from '@atcute/bluesky'; 2 | import type { InfiniteData } from '@mary/solid-query'; 3 | 4 | import type { CacheMatcher } from '../cache/utils'; 5 | import { embedViewRecordToPostView, getEmbeddedPost } from '../utils/post'; 6 | 7 | export const findAllPosts = (uri: string, includeQuote = false): CacheMatcher => { 8 | return { 9 | filter: { 10 | queryKey: ['post-quotes'], 11 | }, 12 | *iterate(data: InfiniteData) { 13 | for (const page of data.pages) { 14 | for (const post of page.posts) { 15 | if (post.uri === uri) { 16 | yield post; 17 | } 18 | 19 | if (includeQuote) { 20 | const embeddedPost = getEmbeddedPost(post.embed); 21 | if (embeddedPost && embeddedPost.uri === uri) { 22 | yield embedViewRecordToPostView(embeddedPost); 23 | } 24 | } 25 | } 26 | } 27 | }, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/views/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { history, logger } from '~/globals/navigation'; 2 | 3 | import { useTitle } from '~/lib/navigation/router'; 4 | 5 | import Button from '~/components/button'; 6 | 7 | const NotFoundPage = () => { 8 | useTitle(() => `Page not found — ${import.meta.env.VITE_APP_NAME}`); 9 | 10 | return ( 11 | <> 12 |
13 |

Page not found

14 |

15 | We're sorry, but the link you followed might be broken, or the page may have been removed. 16 |

17 | 18 |
19 | 32 |
33 |
34 | 35 | ); 36 | }; 37 | 38 | export default NotFoundPage; 39 | -------------------------------------------------------------------------------- /src/globals/preferences.ts: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'solid-js'; 2 | 3 | import { createReactiveLocalStorage } from '~/lib/hooks/local-storage'; 4 | import type { GlobalPreferenceSchema } from '~/lib/preferences/global'; 5 | import type { SessionPreferenceSchema } from '~/lib/preferences/sessions'; 6 | 7 | export const sessions = createRoot(() => { 8 | return createReactiveLocalStorage('sessions', (version, prev) => { 9 | if (version === 0) { 10 | return { 11 | $version: 1, 12 | active: undefined, 13 | accounts: [], 14 | }; 15 | } 16 | 17 | return prev; 18 | }); 19 | }); 20 | 21 | export const global = createRoot(() => { 22 | return createReactiveLocalStorage('global', (version, prev) => { 23 | if (version === 0) { 24 | const prefs: GlobalPreferenceSchema = { 25 | $version: 1, 26 | ui: { 27 | theme: 'system', 28 | }, 29 | }; 30 | 31 | return prefs; 32 | } 33 | 34 | return prev; 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/views/_signed-out/index.tsx: -------------------------------------------------------------------------------- 1 | import { openModal } from '~/globals/modals'; 2 | 3 | import { useTitle } from '~/lib/navigation/router'; 4 | 5 | import Button from '~/components/button'; 6 | import SignInDialogLazy from '~/components/main/sign-in-dialog-lazy'; 7 | 8 | const SignedOutPage = () => { 9 | useTitle(() => import.meta.env.VITE_APP_NAME); 10 | 11 | return ( 12 | <> 13 |
14 |

here's where i would've put a logo

15 |

IF I HAD ONE

16 |
17 |
18 | 27 | 30 |
31 | 32 | ); 33 | }; 34 | 35 | export default SignedOutPage; 36 | -------------------------------------------------------------------------------- /src/lib/hooks/media-query.ts: -------------------------------------------------------------------------------- 1 | import { type Accessor, createSignal, onCleanup } from 'solid-js'; 2 | 3 | interface MediaStore { 4 | /** State backing */ 5 | a: Accessor; 6 | /** Amount of subscriptions */ 7 | n: number; 8 | /** Cleanup function */ 9 | c: () => void; 10 | } 11 | 12 | const map: Record = {}; 13 | 14 | /*#__NO_SIDE_EFFECTS__*/ 15 | export const useMediaQuery = (query: string): Accessor => { 16 | let media = map[query]; 17 | 18 | if (!media) { 19 | const matcher = window.matchMedia(query); 20 | const [state, setState] = createSignal(matcher.matches); 21 | 22 | const callback = () => setState(matcher.matches); 23 | matcher.onchange = callback; 24 | 25 | media = map[query] = { 26 | n: 0, 27 | a: state, 28 | c: () => { 29 | if (--media.n < 1) { 30 | matcher.onchange = null; 31 | delete map[query]; 32 | } 33 | }, 34 | }; 35 | } 36 | 37 | media.n++; 38 | onCleanup(media.c); 39 | 40 | return media.a; 41 | }; 42 | -------------------------------------------------------------------------------- /src/api/cache/utils.ts: -------------------------------------------------------------------------------- 1 | import { type QueryClient, type QueryFilters, matchQuery } from '@mary/solid-query'; 2 | 3 | export interface CacheMatcher { 4 | filter: QueryFilters | QueryFilters[]; 5 | iterate: (data: any) => Generator; 6 | } 7 | 8 | export function* iterateQueryCache(queryClient: QueryClient, matchers: CacheMatcher[]): Generator { 9 | const queries = queryClient.getQueryCache().getAll(); 10 | queries.sort((a, b) => b.state.dataUpdatedAt - a.state.dataUpdatedAt); 11 | 12 | for (let i = 0, ilen = queries.length; i < ilen; i++) { 13 | const query = queries[i]; 14 | const data = query.state.data; 15 | 16 | if (data === undefined) { 17 | continue; 18 | } 19 | 20 | for (let j = 0, jlen = matchers.length; j < jlen; j++) { 21 | const matcher = matchers[j]; 22 | const filter = matcher.filter; 23 | 24 | if (Array.isArray(filter) ? filter.some((f) => matchQuery(f, query)) : matchQuery(filter, query)) { 25 | yield* matcher.iterate(data); 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/utils/state.ts: -------------------------------------------------------------------------------- 1 | import { $RAW } from 'solid-js/store'; 2 | 3 | export const snapshot = (value: T, map: WeakMap = new WeakMap()): T => { 4 | const v = value as any; 5 | 6 | if (v == null || typeof v !== 'object') { 7 | return v; 8 | } 9 | 10 | if (!v[$RAW]) { 11 | return v; 12 | } 13 | 14 | const cached = map.get(v); 15 | if (cached) { 16 | return cached; 17 | } 18 | 19 | const proto = Object.getPrototypeOf(v); 20 | switch (proto) { 21 | case Array.prototype: { 22 | const result: unknown[] = new Array(v.length); 23 | for (let idx = 0, len = v.length; idx < len; idx++) { 24 | result[idx] = snapshot(v[idx], map); 25 | } 26 | 27 | map.set(v, result); 28 | return result as T; 29 | } 30 | case Object.prototype: { 31 | const result: Record = {}; 32 | for (const key in v) { 33 | result[key] = snapshot(v[key], map); 34 | } 35 | 36 | map.set(v, result); 37 | return result as T; 38 | } 39 | } 40 | 41 | return v; 42 | }; 43 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | 5 | "target": "ESNext", 6 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 7 | "types": [ 8 | "dom-close-watcher", 9 | "dom-webcodecs", 10 | "@atcute/atproto", 11 | "@atcute/bluemoji", 12 | "@atcute/bluesky", 13 | "@kelinci/basa-lexicons" 14 | ], 15 | "skipLibCheck": true, 16 | 17 | "module": "ESNext", 18 | "moduleResolution": "bundler", 19 | "moduleDetection": "force", 20 | "allowImportingTsExtensions": true, 21 | "noEmit": true, 22 | "jsx": "preserve", 23 | "jsxImportSource": "solid-js", 24 | 25 | "incremental": true, 26 | "strict": true, 27 | "verbatimModuleSyntax": true, 28 | "noUnusedLocals": true, 29 | "noUnusedParameters": true, 30 | "noFallthroughCasesInSwitch": true, 31 | "noUncheckedSideEffectImports": true, 32 | 33 | "useDefineForClassFields": false, 34 | 35 | "paths": { 36 | "~/*": ["./src/*"] 37 | } 38 | }, 39 | "include": ["src"] 40 | } 41 | -------------------------------------------------------------------------------- /src/components/icons-central/person-check-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // people-added 4 | const PersonCheckOutlinedIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default PersonCheckOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/api/queries-cache/profile-list.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyActorDefs } from '@atcute/bluesky'; 2 | import type { Did } from '@atcute/lexicons'; 3 | import type { InfiniteData } from '@mary/solid-query'; 4 | 5 | import type { CacheMatcher } from '../cache/utils'; 6 | import type { ProfilesListPage, ProfilesListWithSubjectPage } from '../types/profile-response'; 7 | 8 | export const findAllProfiles = (did: Did): CacheMatcher => { 9 | return { 10 | filter: [ 11 | { queryKey: ['profile-followers'] }, 12 | { queryKey: ['profile-following'] }, 13 | { queryKey: ['profile-known-followers'] }, 14 | { queryKey: ['search-profiles'] }, 15 | { queryKey: ['subject-likers'] }, 16 | { queryKey: ['subject-reposters'] }, 17 | ], 18 | *iterate(data: InfiniteData) { 19 | for (const page of data.pages) { 20 | for (const profile of page.profiles) { 21 | if (profile.did === did) { 22 | yield profile; 23 | } 24 | } 25 | } 26 | }, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/timeline/repost-menu.tsx: -------------------------------------------------------------------------------- 1 | import { useModalContext } from '~/globals/modals'; 2 | 3 | import RepeatOutlinedIcon from '../icons-central/repeat-outline'; 4 | import WriteOutlinedIcon from '../icons-central/write-outline'; 5 | import * as Menu from '../menu'; 6 | 7 | export interface RepostMenuProps { 8 | anchor: HTMLElement; 9 | isReposted: boolean; 10 | onRepost: () => void; 11 | onQuote: () => void; 12 | } 13 | 14 | const RepostMenu = (props: RepostMenuProps) => { 15 | const { close } = useModalContext(); 16 | 17 | return ( 18 | 19 | { 23 | close(); 24 | props.onRepost(); 25 | }} 26 | /> 27 | 28 | { 32 | close(); 33 | props.onQuote(); 34 | }} 35 | /> 36 | 37 | ); 38 | }; 39 | 40 | export default RepostMenu; 41 | -------------------------------------------------------------------------------- /src/components/search/suggestions/search-autocompletion-view.tsx: -------------------------------------------------------------------------------- 1 | import { For } from 'solid-js'; 2 | 3 | import { createProfileAutocompleteQuery } from '~/api/queries/profile-autocomplete'; 4 | 5 | import { useIsFocused } from '~/lib/navigation/router'; 6 | 7 | import ProfileItem from '~/components/profiles/profile-item'; 8 | 9 | import { useSearchBar } from '../context'; 10 | 11 | const HAS_FILTER_RE = /[a-z]:/; 12 | 13 | const SearchAutocompletionView = () => { 14 | const { query } = useSearchBar(); 15 | 16 | const isFocused = useIsFocused(); 17 | 18 | const profiles = createProfileAutocompleteQuery(query, { 19 | get enabled() { 20 | const $query = query(); 21 | return isFocused() && $query.length > 0 && $query.length < 128 && !HAS_FILTER_RE.test($query); 22 | }, 23 | }); 24 | 25 | return ( 26 | 30 | ); 31 | }; 32 | 33 | export default SearchAutocompletionView; 34 | -------------------------------------------------------------------------------- /server/lexicons.ts: -------------------------------------------------------------------------------- 1 | import type {} from '@atcute/lexicons/ambient'; 2 | import * as v from '@atcute/lexicons/validations'; 3 | 4 | export const requestAssertionSchema = v.procedure('x.aglais.requestAssertion', { 5 | params: null, 6 | input: { 7 | type: 'lex', 8 | schema: v.object({ 9 | jkt: v.string(), 10 | aud: v.string(), 11 | }), 12 | }, 13 | output: { 14 | type: 'lex', 15 | schema: v.object({ 16 | assertion: v.string(), 17 | }), 18 | }, 19 | }); 20 | 21 | export const resolveIdentitySchema = v.query('x.aglais.resolveIdentity', { 22 | params: v.object({ 23 | identifier: v.actorIdentifierString(), 24 | }), 25 | output: { 26 | type: 'lex', 27 | schema: v.object({ 28 | did: v.didString(), 29 | handle: v.handleString(), 30 | pds: v.genericUriString(), 31 | }), 32 | }, 33 | }); 34 | 35 | declare module '@atcute/lexicons/ambient' { 36 | interface XRPCProcedures { 37 | 'x.aglais.requestAssertion': typeof requestAssertionSchema; 38 | } 39 | 40 | interface XRPCQueries { 41 | 'x.aglais.resolveIdentity': typeof resolveIdentitySchema; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/api/queries/list-members.ts: -------------------------------------------------------------------------------- 1 | import { ok } from '@atcute/client'; 2 | import type { ResourceUri } from '@atcute/lexicons'; 3 | import { type QueryFunctionContext as QC, createInfiniteQuery } from '@mary/solid-query'; 4 | 5 | import { useAgent } from '~/lib/states/agent'; 6 | 7 | export const createListMembersQuery = (listUri: () => ResourceUri) => { 8 | const { client } = useAgent(); 9 | 10 | return createInfiniteQuery(() => { 11 | const $listUri = listUri(); 12 | 13 | return { 14 | queryKey: ['list-members', $listUri], 15 | async queryFn(ctx: QC) { 16 | const data = await ok( 17 | client.get('app.bsky.graph.getList', { 18 | signal: ctx.signal, 19 | params: { 20 | list: $listUri, 21 | limit: 50, 22 | cursor: ctx.pageParam, 23 | }, 24 | }), 25 | ); 26 | 27 | return { 28 | cursor: data.cursor, 29 | members: data.items, 30 | }; 31 | }, 32 | structuralSharing: false, 33 | initialPageParam: undefined, 34 | getNextPageParam: (last) => last.cursor, 35 | }; 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/api/queries/post-quotes.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedGetQuotes } from '@atcute/bluesky'; 2 | import { ok } from '@atcute/client'; 3 | import type { ResourceUri } from '@atcute/lexicons'; 4 | import { type QueryFunctionContext as QC, createInfiniteQuery } from '@mary/solid-query'; 5 | 6 | import { useAgent } from '~/lib/states/agent'; 7 | 8 | export const createPostQuotesQuery = (uri: () => ResourceUri) => { 9 | const { client } = useAgent(); 10 | 11 | return createInfiniteQuery(() => { 12 | const $uri = uri(); 13 | 14 | return { 15 | queryKey: ['post-quotes', $uri], 16 | structuralSharing: false, 17 | async queryFn(ctx: QC): Promise { 18 | const data = await ok( 19 | client.get('app.bsky.feed.getQuotes', { 20 | signal: ctx.signal, 21 | params: { 22 | uri: $uri, 23 | limit: 50, 24 | cursor: ctx.pageParam, 25 | }, 26 | }), 27 | ); 28 | 29 | return data; 30 | }, 31 | initialPageParam: undefined, 32 | getNextPageParam: (last) => last.cursor, 33 | }; 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/api/queries/search-profiles.ts: -------------------------------------------------------------------------------- 1 | import { ok } from '@atcute/client'; 2 | import { type QueryFunctionContext as QC, createInfiniteQuery } from '@mary/solid-query'; 3 | 4 | import { useAgent } from '~/lib/states/agent'; 5 | 6 | import { type ProfilesListPage, toProfilesListPage } from '../types/profile-response'; 7 | 8 | export const createSearchProfilesQuery = (query: () => string) => { 9 | const { client } = useAgent(); 10 | 11 | return createInfiniteQuery(() => { 12 | const q = query(); 13 | 14 | return { 15 | queryKey: ['search-profiles', q], 16 | async queryFn(ctx: QC): Promise { 17 | const data = await ok( 18 | client.get('app.bsky.actor.searchActors', { 19 | signal: ctx.signal, 20 | params: { 21 | q: q, 22 | limit: 50, 23 | cursor: ctx.pageParam, 24 | }, 25 | }), 26 | ); 27 | 28 | return toProfilesListPage(data, 'actors'); 29 | }, 30 | structuralSharing: false, 31 | initialPageParam: undefined, 32 | getNextPageParam: (last) => last.cursor, 33 | }; 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/images/image-upload-menu.tsx: -------------------------------------------------------------------------------- 1 | import { useModalContext } from '~/globals/modals'; 2 | 3 | import CrossLargeOutlinedIcon from '../icons-central/cross-large-outline'; 4 | import ImageOutlinedIcon from '../icons-central/image-outline'; 5 | import * as Menu from '../menu'; 6 | 7 | export interface ImageUploadMenuProps { 8 | anchor: HTMLElement; 9 | onRemove: () => void; 10 | onUpload: () => void; 11 | } 12 | 13 | const ImageUploadMenu = (props: ImageUploadMenuProps) => { 14 | const { close } = useModalContext(); 15 | 16 | const onRemove = props.onRemove; 17 | const onUpload = props.onUpload; 18 | 19 | return ( 20 | 21 | { 25 | close(); 26 | onUpload(); 27 | }} 28 | /> 29 | { 33 | close(); 34 | onRemove(); 35 | }} 36 | /> 37 | 38 | ); 39 | }; 40 | 41 | export default ImageUploadMenu; 42 | -------------------------------------------------------------------------------- /src/components/icons-central/people-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // group-3 4 | const PeopleOutlinedIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default PeopleOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/api/queries/search-feeds.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyUnspeccedGetPopularFeedGenerators } from '@atcute/bluesky'; 2 | import { ok } from '@atcute/client'; 3 | import { type QueryFunctionContext as QC, createInfiniteQuery } from '@mary/solid-query'; 4 | 5 | import { useAgent } from '~/lib/states/agent'; 6 | 7 | export const createSearchFeedsQuery = (query: () => string) => { 8 | const { client } = useAgent(); 9 | 10 | return createInfiniteQuery(() => { 11 | const q = query(); 12 | 13 | return { 14 | queryKey: ['search-feeds', q], 15 | async queryFn( 16 | ctx: QC, 17 | ): Promise { 18 | const data = await ok( 19 | client.get('app.bsky.unspecced.getPopularFeedGenerators', { 20 | signal: ctx.signal, 21 | params: { 22 | query: q, 23 | limit: 50, 24 | cursor: ctx.pageParam, 25 | }, 26 | }), 27 | ); 28 | 29 | return data; 30 | }, 31 | structuralSharing: false, 32 | initialPageParam: undefined, 33 | getNextPageParam: (last) => last.cursor, 34 | }; 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/api/moderation/entities/profile.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyActorDefs } from '@atcute/bluesky'; 2 | 3 | import { 4 | type ModerationCause, 5 | type ModerationOptions, 6 | decideLabelModeration, 7 | decideMutedPermanentModeration, 8 | } from '..'; 9 | import { TargetAccount, TargetProfile } from '../constants'; 10 | 11 | type AllProfileView = 12 | | AppBskyActorDefs.ProfileView 13 | | AppBskyActorDefs.ProfileViewBasic 14 | | AppBskyActorDefs.ProfileViewDetailed; 15 | 16 | export const moderateProfile = (profile: AllProfileView, opts: ModerationOptions) => { 17 | const accu: ModerationCause[] = []; 18 | const did = profile.did; 19 | 20 | const labels = profile.labels; 21 | const profileLabels = labels?.filter((label) => label.uri.endsWith('/app.bsky.actor.profile/self')); 22 | const accountLabels = labels?.filter((label) => !label.uri.endsWith('/app.bsky.actor.profile/self')); 23 | 24 | decideLabelModeration(accu, TargetProfile, profileLabels, did, opts); 25 | decideLabelModeration(accu, TargetAccount, accountLabels, did, opts); 26 | decideMutedPermanentModeration(accu, profile.viewer?.muted); 27 | 28 | return accu; 29 | }; 30 | -------------------------------------------------------------------------------- /src/api/queries/profile-feeds.ts: -------------------------------------------------------------------------------- 1 | import { ok } from '@atcute/client'; 2 | import type { ActorIdentifier } from '@atcute/lexicons'; 3 | import { type QueryFunctionContext as QC, createInfiniteQuery } from '@mary/solid-query'; 4 | 5 | import { useAgent } from '~/lib/states/agent'; 6 | 7 | export const createProfileFeedsQuery = (didOrHandle: () => ActorIdentifier) => { 8 | const { client } = useAgent(); 9 | 10 | return createInfiniteQuery(() => { 11 | const $didOrHandle = didOrHandle(); 12 | 13 | return { 14 | queryKey: ['profile-feeds', $didOrHandle], 15 | async queryFn(ctx: QC) { 16 | const data = await ok( 17 | client.get('app.bsky.feed.getActorFeeds', { 18 | signal: ctx.signal, 19 | params: { 20 | actor: $didOrHandle, 21 | limit: 100, 22 | cursor: ctx.pageParam, 23 | }, 24 | }), 25 | ); 26 | 27 | data.feeds.sort((a, b) => (b.likeCount ?? 0) - (a.likeCount ?? 0)); 28 | return data; 29 | }, 30 | structuralSharing: false, 31 | initialPageParam: undefined, 32 | getNextPageParam: (last) => last.cursor, 33 | }; 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/components/search/search-profiles.tsx: -------------------------------------------------------------------------------- 1 | import { createSearchProfilesQuery } from '~/api/queries/search-profiles'; 2 | 3 | import PagedList from '~/components/paged-list'; 4 | import ProfileFollowButton from '~/components/profiles/profile-follow-button'; 5 | import ProfileItem from '~/components/profiles/profile-item'; 6 | import VirtualItem from '~/components/virtual-item'; 7 | 8 | export interface SearchProfilesProps { 9 | q: string; 10 | } 11 | 12 | const SearchProfiles = (props: SearchProfilesProps) => { 13 | const profiles = createSearchProfilesQuery(() => props.q); 14 | 15 | return ( 16 | page.profiles)} 18 | error={profiles.error} 19 | render={(item) => { 20 | return ( 21 | 22 | } /> 23 | 24 | ); 25 | }} 26 | hasNextPage={profiles.hasNextPage} 27 | isFetchingNextPage={profiles.isFetching} 28 | onEndReached={() => profiles.fetchNextPage()} 29 | /> 30 | ); 31 | }; 32 | 33 | export default SearchProfiles; 34 | -------------------------------------------------------------------------------- /src/components/tab-bar.tsx: -------------------------------------------------------------------------------- 1 | export interface TabBarProps { 2 | value: T; 3 | items: { value: T; label: string }[]; 4 | onChange: (next: T) => void; 5 | } 6 | 7 | const TabBar = (props: TabBarProps) => { 8 | return ( 9 |
10 | {props.items.map(({ value, label }) => ( 11 | 23 | ))} 24 |
25 | ); 26 | }; 27 | 28 | export default TabBar; 29 | -------------------------------------------------------------------------------- /src/api/utils/dequal.ts: -------------------------------------------------------------------------------- 1 | const keys = Object.keys; 2 | 3 | export const dequal = (a: any, b: any): boolean => { 4 | let ctor: any; 5 | let len: number; 6 | 7 | if (a === b) { 8 | return true; 9 | } 10 | 11 | if (a && b && (ctor = a.constructor) === b.constructor) { 12 | if (ctor === Array) { 13 | if ((len = a.length) === b.length) { 14 | while (len--) { 15 | if (!dequal(a[len], b[len])) { 16 | return false; 17 | } 18 | } 19 | } 20 | 21 | return len === -1; 22 | } else if (!ctor || ctor === Object) { 23 | len = 0; 24 | 25 | for (ctor in a) { 26 | len++; 27 | 28 | if (!(ctor in b) || !dequal(a[ctor], b[ctor])) { 29 | return false; 30 | } 31 | } 32 | 33 | return keys(b).length === len; 34 | } 35 | } 36 | 37 | return a !== a && b !== b; 38 | }; 39 | 40 | export const EQUALS_DEQUAL = { equals: dequal } as const; 41 | 42 | export const sequal = (a: any[], b: any[]): boolean => { 43 | let len = a.length; 44 | 45 | if (len === b.length) { 46 | while (len--) { 47 | if (a[len] !== b[len]) { 48 | return false; 49 | } 50 | } 51 | } 52 | 53 | return len === -1; 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/icons-central/person-remove-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | // people-remove 4 | const PersonRemoveOutlinedIcon = createIcon(() => ( 5 | 6 | 10 | 11 | )); 12 | 13 | export default PersonRemoveOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/components/fieldset.tsx: -------------------------------------------------------------------------------- 1 | import { type ParentProps, createContext, createMemo, useContext } from 'solid-js'; 2 | 3 | export interface FieldsetContext { 4 | readonly disabled: boolean; 5 | } 6 | 7 | const DEFAULT_FIELDSET: FieldsetContext = { 8 | disabled: false, 9 | }; 10 | 11 | const Context = createContext(DEFAULT_FIELDSET); 12 | 13 | export const useFieldset = (): FieldsetContext => { 14 | return useContext(Context); 15 | }; 16 | 17 | export interface FieldsetProps extends ParentProps { 18 | standalone?: boolean; 19 | disabled?: boolean; 20 | } 21 | 22 | export const Fieldset = (props: FieldsetProps) => { 23 | let context: FieldsetContext; 24 | 25 | if (!('disabled' in props) && props.standalone) { 26 | context = DEFAULT_FIELDSET; 27 | } else { 28 | const parent = useFieldset(); 29 | 30 | const isDisabled = createMemo((): boolean => { 31 | return (!props.standalone && parent.disabled) || !!props.disabled; 32 | }); 33 | 34 | context = { 35 | get disabled() { 36 | return isDisabled(); 37 | }, 38 | }; 39 | } 40 | 41 | return {props.children}; 42 | }; 43 | -------------------------------------------------------------------------------- /src/api/queries/profile-lists.ts: -------------------------------------------------------------------------------- 1 | import { ok } from '@atcute/client'; 2 | import type { ActorIdentifier } from '@atcute/lexicons'; 3 | import { type QueryFunctionContext as QC, createInfiniteQuery } from '@mary/solid-query'; 4 | 5 | import { useAgent } from '~/lib/states/agent'; 6 | 7 | export const createProfileListsQuery = (didOrHandle: () => ActorIdentifier) => { 8 | const { client } = useAgent(); 9 | 10 | const collator = new Intl.Collator('en-US'); 11 | 12 | return createInfiniteQuery(() => { 13 | const $didOrHandle = didOrHandle(); 14 | 15 | return { 16 | queryKey: ['profile-lists', $didOrHandle], 17 | async queryFn(ctx: QC) { 18 | const data = await ok( 19 | client.get('app.bsky.graph.getLists', { 20 | signal: ctx.signal, 21 | params: { 22 | actor: $didOrHandle, 23 | limit: 100, 24 | cursor: ctx.pageParam, 25 | }, 26 | }), 27 | ); 28 | 29 | data.lists.sort((a, b) => collator.compare(a.name, b.name)); 30 | return data; 31 | }, 32 | structuralSharing: false, 33 | initialPageParam: undefined, 34 | getNextPageParam: (last) => last.cursor, 35 | }; 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/api/queries/profile-autocomplete.ts: -------------------------------------------------------------------------------- 1 | import { ok } from '@atcute/client'; 2 | import { createQuery, keepPreviousData } from '@mary/solid-query'; 3 | 4 | import { useAgent } from '~/lib/states/agent'; 5 | 6 | export interface ProfileAutocompleteQueryOptions { 7 | readonly enabled?: boolean; 8 | } 9 | 10 | export const createProfileAutocompleteQuery = ( 11 | query: () => string, 12 | opts?: ProfileAutocompleteQueryOptions, 13 | ) => { 14 | const { client } = useAgent(); 15 | 16 | return createQuery(() => { 17 | const $query = query(); 18 | const isEnabled = opts?.enabled ?? true; 19 | 20 | const trimmed = $query 21 | .trim() 22 | .replace(/^\p{P}+|\p{P}+$/gu, '') 23 | .toLowerCase(); 24 | 25 | return { 26 | queryKey: ['profile-autocomplete', trimmed], 27 | enabled: isEnabled, 28 | placeholderData: isEnabled ? keepPreviousData : undefined, 29 | async queryFn({ signal }) { 30 | const data = await ok( 31 | client.get('app.bsky.actor.searchActorsTypeahead', { 32 | signal, 33 | params: { 34 | q: trimmed, 35 | limit: 10, 36 | }, 37 | }), 38 | ); 39 | 40 | return data; 41 | }, 42 | }; 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/icons-central/gif-square-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const GifSquareOutlinedIcon = createIcon(() => ( 4 | 5 | 9 | 10 | 11 | )); 12 | 13 | export default GifSquareOutlinedIcon; 14 | -------------------------------------------------------------------------------- /src/api/utils/error.ts: -------------------------------------------------------------------------------- 1 | import { ClientResponseError } from '@atcute/client'; 2 | import { TokenRefreshError } from '@atcute/oauth-browser-client'; 3 | 4 | export const formatXRPCError = (err: ClientResponseError): string => { 5 | const name = err.error; 6 | return (name ? name + ': ' : '') + err.description; 7 | }; 8 | 9 | export const formatQueryError = (err: unknown) => { 10 | if (err instanceof TokenRefreshError) { 11 | return `Account session is no longer valid`; 12 | } 13 | 14 | if (err instanceof ClientResponseError) { 15 | const kind = err.error; 16 | 17 | if (kind === 'invalid_token') { 18 | return `Account session is no longer valid`; 19 | } 20 | 21 | if (kind === 'UpstreamFailure') { 22 | return `Server appears to be experiencing issues, try again later`; 23 | } 24 | 25 | if (kind === 'InternalServerError') { 26 | return `Server is having issues processing this request, try again later`; 27 | } 28 | 29 | return formatXRPCError(err); 30 | } 31 | 32 | if (err instanceof Error) { 33 | if (/NetworkError|Failed to fetch|timed out|abort/.test(err.message)) { 34 | return `Unable to access the internet, try again later`; 35 | } 36 | } 37 | 38 | return '' + err; 39 | }; 40 | -------------------------------------------------------------------------------- /src/api/queries/subject-reposters.ts: -------------------------------------------------------------------------------- 1 | import { ok } from '@atcute/client'; 2 | import type { ResourceUri } from '@atcute/lexicons'; 3 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 4 | import { createInfiniteQuery } from '@mary/solid-query'; 5 | 6 | import { useAgent } from '~/lib/states/agent'; 7 | 8 | import { type ProfilesListPage, toProfilesListPage } from '../types/profile-response'; 9 | 10 | export const createSubjectRepostersQuery = (uri: () => ResourceUri) => { 11 | const { client } = useAgent(); 12 | 13 | return createInfiniteQuery(() => { 14 | const $uri = uri(); 15 | 16 | return { 17 | queryKey: ['subject-reposters', $uri], 18 | structuralSharing: false, 19 | async queryFn(ctx: QC): Promise { 20 | const data = await ok( 21 | client.get('app.bsky.feed.getRepostedBy', { 22 | signal: ctx.signal, 23 | params: { 24 | uri: $uri, 25 | limit: 50, 26 | cursor: ctx.pageParam, 27 | }, 28 | }), 29 | ); 30 | 31 | return toProfilesListPage(data, 'repostedBy'); 32 | }, 33 | initialPageParam: undefined, 34 | getNextPageParam: (last) => last.cursor, 35 | }; 36 | }); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/search-input.tsx: -------------------------------------------------------------------------------- 1 | import { autofocusIfEnabled, modelText } from '~/lib/input-refs'; 2 | 3 | import MagnifyingGlassOutlinedIcon from './icons-central/magnifying-glass-outline'; 4 | 5 | export interface SearchInputProps { 6 | value: string; 7 | onChange: (next: string) => void; 8 | placeholder?: string; 9 | autofocus?: boolean; 10 | } 11 | 12 | const SearchInput = (props: SearchInputProps) => { 13 | return ( 14 |
15 |
16 | 17 |
18 | 19 | { 21 | modelText(node, () => props.value, props.onChange); 22 | 23 | if ('autofocus' in props) { 24 | autofocusIfEnabled(node, () => props.autofocus ?? false); 25 | } 26 | }} 27 | type="text" 28 | placeholder={props.placeholder ?? `Search`} 29 | class="h-10 w-full rounded-full border border-outline-md bg-background px-3 pl-10 text-sm text-contrast outline-2 -outline-offset-2 outline-accent placeholder:text-contrast-muted focus:outline" 30 | /> 31 |
32 | ); 33 | }; 34 | 35 | export default SearchInput; 36 | -------------------------------------------------------------------------------- /src/lib/atproto/labeler.ts: -------------------------------------------------------------------------------- 1 | import { type FetchHandler, type FetchHandlerObject, buildFetchHandler } from '@atcute/client'; 2 | import type { Did } from '@atcute/lexicons'; 3 | 4 | export interface Labeler { 5 | did: Did; 6 | redact: boolean; 7 | } 8 | 9 | export const attachLabelerHeaders = ( 10 | handler: FetchHandler | FetchHandlerObject, 11 | labelers: () => Labeler[], 12 | ): FetchHandler => { 13 | const next = buildFetchHandler(handler); 14 | 15 | return (pathname, init) => { 16 | return next(pathname, { 17 | ...init, 18 | headers: mergeHeaders(init.headers, { 19 | 'atproto-accept-labelers': labelers() 20 | .map((labeler) => labeler.did + (labeler.redact ? `;redact` : ``)) 21 | .join(', '), 22 | }), 23 | }); 24 | }; 25 | }; 26 | 27 | const mergeHeaders = ( 28 | init: HeadersInit | undefined, 29 | defaults: Record, 30 | ): HeadersInit | undefined => { 31 | let headers: Headers | undefined; 32 | 33 | for (const name in defaults) { 34 | const value = defaults[name]; 35 | 36 | if (value !== null) { 37 | headers ??= new Headers(init); 38 | 39 | if (!headers.has(name)) { 40 | headers.set(name, value); 41 | } 42 | } 43 | } 44 | 45 | return headers ?? init; 46 | }; 47 | -------------------------------------------------------------------------------- /src/api/queries/subject-likers.ts: -------------------------------------------------------------------------------- 1 | import { ok } from '@atcute/client'; 2 | import type { ResourceUri } from '@atcute/lexicons'; 3 | import type { QueryFunctionContext as QC } from '@mary/solid-query'; 4 | import { createInfiniteQuery } from '@mary/solid-query'; 5 | 6 | import { useAgent } from '~/lib/states/agent'; 7 | 8 | import type { ProfilesListPage } from '../types/profile-response'; 9 | 10 | export const createSubjectLikersQuery = (uri: () => ResourceUri) => { 11 | const { client } = useAgent(); 12 | 13 | return createInfiniteQuery(() => { 14 | const $uri = uri(); 15 | 16 | return { 17 | queryKey: ['subject-likers', $uri], 18 | structuralSharing: false, 19 | async queryFn(ctx: QC): Promise { 20 | const data = await ok( 21 | client.get('app.bsky.feed.getLikes', { 22 | signal: ctx.signal, 23 | params: { 24 | uri: $uri, 25 | limit: 50, 26 | cursor: ctx.pageParam, 27 | }, 28 | }), 29 | ); 30 | 31 | return { 32 | cursor: data.cursor, 33 | profiles: data.likes.map((like) => like.actor), 34 | }; 35 | }, 36 | initialPageParam: undefined, 37 | getNextPageParam: (last) => last.cursor, 38 | }; 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /src/api/types/profile-response.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyActorDefs } from '@atcute/bluesky'; 2 | 3 | export interface ProfilesListPage { 4 | cursor: string | undefined; 5 | profiles: AppBskyActorDefs.ProfileView[]; 6 | } 7 | 8 | export interface ProfilesListWithSubjectPage { 9 | cursor: string | undefined; 10 | profiles: AppBskyActorDefs.ProfileView[]; 11 | subject: AppBskyActorDefs.ProfileView; 12 | } 13 | 14 | type ProfileableProperties = { 15 | [K in keyof T]: T[K] extends AppBskyActorDefs.ProfileView[] ? K : never; 16 | }[keyof T]; 17 | 18 | type BarePage = { cursor?: string }; 19 | export const toProfilesListPage = ( 20 | page: T, 21 | key: ProfileableProperties, 22 | ): ProfilesListPage => { 23 | return { 24 | cursor: page.cursor, 25 | profiles: page[key] as AppBskyActorDefs.ProfileView[], 26 | }; 27 | }; 28 | 29 | type BareWithSubjectPage = { cursor?: string; subject: AppBskyActorDefs.ProfileView }; 30 | export const toProfilesListWithSubjectPage = ( 31 | page: T, 32 | key: ProfileableProperties, 33 | ): ProfilesListWithSubjectPage => { 34 | return { 35 | cursor: page.cursor, 36 | subject: page.subject, 37 | profiles: page[key] as AppBskyActorDefs.ProfileView[], 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/api/queries/labeler.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyLabelerDefs } from '@atcute/bluesky'; 2 | import { ClientResponseError, ok } from '@atcute/client'; 3 | import type { Did } from '@atcute/lexicons'; 4 | import { createQuery } from '@mary/solid-query'; 5 | 6 | import { useAgent } from '~/lib/states/agent'; 7 | 8 | import { interpretLabelerDefinition } from '../moderation/labeler'; 9 | 10 | export const createLabelerMetaQuery = (did: () => Did) => { 11 | const { client } = useAgent(); 12 | 13 | const query = createQuery(() => { 14 | const $did = did(); 15 | 16 | return { 17 | queryKey: ['labeler-definition', $did], 18 | async queryFn(ctx) { 19 | const data = await ok( 20 | client.get('app.bsky.labeler.getServices', { 21 | signal: ctx.signal, 22 | params: { 23 | dids: [$did], 24 | detailed: true, 25 | }, 26 | }), 27 | ); 28 | 29 | const service = data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed; 30 | 31 | if (!service) { 32 | throw new ClientResponseError({ 33 | status: 400, 34 | data: { error: `NotFound`, message: `Labeler not found: ${$did}` }, 35 | }); 36 | } 37 | 38 | return interpretLabelerDefinition(service); 39 | }, 40 | }; 41 | }); 42 | 43 | return query; 44 | }; 45 | -------------------------------------------------------------------------------- /src/api/queries/notification-count.tsx: -------------------------------------------------------------------------------- 1 | import { ok } from '@atcute/client'; 2 | import { createQuery } from '@mary/solid-query'; 3 | 4 | import { useAgent } from '~/lib/states/agent'; 5 | import { useSession } from '~/lib/states/session'; 6 | 7 | export const createNotificationCountQuery = (options?: { readonly disabled?: boolean }) => { 8 | const { currentAccount } = useSession(); 9 | const { client } = useAgent(); 10 | 11 | const query = createQuery(() => ({ 12 | queryKey: ['notification', 'count'], 13 | enabled: currentAccount !== undefined && !options?.disabled, 14 | async queryFn() { 15 | const data = await ok( 16 | client.get('app.bsky.notification.getUnreadCount', { 17 | params: {}, 18 | }), 19 | ); 20 | 21 | return data; 22 | }, 23 | refetchInterval(query) { 24 | const count = query.state.data?.count; 25 | 26 | if (count !== undefined) { 27 | if (count >= 30) { 28 | return 90_000; 29 | } 30 | 31 | if (count > 0 && Math.random() >= 0.5) { 32 | return 60_000; 33 | } 34 | } 35 | 36 | return 30_000; 37 | }, 38 | initialData: { count: 0 }, 39 | initialDataUpdatedAt: 0, 40 | staleTime: 30_000, 41 | refetchOnReconnect: true, 42 | refetchOnWindowFocus: true, 43 | })); 44 | 45 | return query; 46 | }; 47 | -------------------------------------------------------------------------------- /src/views/lists.tsx: -------------------------------------------------------------------------------- 1 | import { createMyListsQuery } from '~/api/queries/my-lists'; 2 | 3 | import { useTitle } from '~/lib/navigation/router'; 4 | 5 | import IconButton from '~/components/icon-button'; 6 | import AddOutlinedIcon from '~/components/icons-central/add-outline'; 7 | import List from '~/components/list'; 8 | import ListItem from '~/components/lists/list-item'; 9 | import * as Page from '~/components/page'; 10 | 11 | const ListsPage = () => { 12 | const lists = createMyListsQuery('curation'); 13 | 14 | useTitle(() => `My lists — ${import.meta.env.VITE_APP_NAME}`); 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | { 30 | // 31 | }} 32 | /> 33 | 34 | 35 | 36 | { 40 | return ; 41 | }} 42 | isFetchingNextPage={lists.isFetching} 43 | /> 44 | 45 | ); 46 | }; 47 | 48 | export default ListsPage; 49 | -------------------------------------------------------------------------------- /src/components/timeline/timeline-list.tsx: -------------------------------------------------------------------------------- 1 | import type { Did } from '@atcute/lexicons'; 2 | 3 | import { type TimelineParams, useTimelineQuery } from '~/api/queries/timeline'; 4 | 5 | import PagedList from '../paged-list'; 6 | import VirtualItem from '../virtual-item'; 7 | 8 | import PostFeedItem from './post-feed-item'; 9 | 10 | export interface TimelineListProps { 11 | params: TimelineParams; 12 | timelineDid?: Did; 13 | } 14 | 15 | const TimelineList = (props: TimelineListProps) => { 16 | const { timeline, isStale, reset } = useTimelineQuery(() => props.params); 17 | const timelineDid = props.timelineDid; 18 | 19 | return ( 20 | page.items)} 22 | error={timeline.error} 23 | render={(item) => { 24 | return ( 25 | 26 | 27 | 28 | ); 29 | }} 30 | hasNewData={isStale()} 31 | hasNextPage={timeline.hasNextPage} 32 | isFetchingNextPage={timeline.isFetchingNextPage || timeline.isLoading} 33 | isRefreshing={timeline.isRefetching} 34 | onEndReached={() => timeline.fetchNextPage()} 35 | onRefresh={reset} 36 | extraBottomGutter 37 | /> 38 | ); 39 | }; 40 | 41 | export default TimelineList; 42 | -------------------------------------------------------------------------------- /src/lib/utils/blob.ts: -------------------------------------------------------------------------------- 1 | import { onCleanup } from 'solid-js'; 2 | 3 | export const SUPPORTED_IMAGE_FORMATS = ['image/png', 'image/jpeg', 'image/webp', 'image/avif', 'image/gif']; 4 | export const SUPPORTED_VIDEO_FORMATS = ['video/mp4', 'video/webm', 'video/mpeg', 'video/quicktime']; 5 | 6 | export const convertBlobToUrl = (blob: Blob | string): string => { 7 | if (typeof blob === 'string') { 8 | return blob; 9 | } 10 | 11 | const blobUrl = URL.createObjectURL(blob); 12 | onCleanup(() => URL.revokeObjectURL(blobUrl)); 13 | 14 | return blobUrl; 15 | }; 16 | 17 | export const openImagePicker = (callback: (files: File[]) => void, multiple: boolean) => { 18 | const input = document.createElement('input'); 19 | 20 | input.type = 'file'; 21 | input.multiple = multiple; 22 | input.accept = SUPPORTED_IMAGE_FORMATS.join(','); 23 | input.oninput = () => callback(Array.from(input.files!)); 24 | 25 | input.click(); 26 | }; 27 | 28 | export const openMediaPicker = (callback: (files: File[]) => void, multiple: boolean) => { 29 | const input = document.createElement('input'); 30 | 31 | input.type = 'file'; 32 | input.multiple = multiple; 33 | input.accept = [...SUPPORTED_IMAGE_FORMATS, ...SUPPORTED_VIDEO_FORMATS].join(','); 34 | input.oninput = () => callback(Array.from(input.files!)); 35 | 36 | input.click(); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/threads/thread-lines.tsx: -------------------------------------------------------------------------------- 1 | import { type JSX, createMemo } from 'solid-js'; 2 | 3 | import { mapDefined } from '@mary/array-fns'; 4 | 5 | import { LineType } from '~/api/models/post-thread'; 6 | import { EQUALS_DEQUAL } from '~/api/utils/dequal'; 7 | 8 | import { on } from '~/lib/utils/misc'; 9 | 10 | export interface ThreadLinesProps { 11 | lines: LineType[] | undefined; 12 | } 13 | 14 | const ThreadLines = (props: ThreadLinesProps) => { 15 | const get = createMemo(() => props.lines, EQUALS_DEQUAL); 16 | 17 | return on(get, (lines) => { 18 | if (!lines?.length) { 19 | return undefined; 20 | } 21 | 22 | return mapDefined(lines, (line) => { 23 | const drawVertical = line === LineType.VERTICAL || line === LineType.VERTICAL_RIGHT; 24 | const drawRight = line === LineType.UP_RIGHT || line === LineType.VERTICAL_RIGHT; 25 | 26 | return ( 27 |
28 | {drawRight && ( 29 |
30 | )} 31 | {drawVertical && ( 32 |
33 | )} 34 |
35 | ); 36 | }); 37 | }) as unknown as JSX.Element; 38 | }; 39 | 40 | export default ThreadLines; 41 | -------------------------------------------------------------------------------- /src/service-worker.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from 'solid-js'; 2 | import { registerSW } from 'virtual:pwa-register'; 3 | 4 | const shouldInstall = async (): Promise => { 5 | if (matchMedia('(display-mode: standalone)').matches) { 6 | return true; 7 | } 8 | 9 | // Just in case. 10 | const registration = await navigator.serviceWorker.getRegistration(); 11 | return !!registration; 12 | }; 13 | 14 | export const enum SWStatus { 15 | NOT_INSTALLED = 0, 16 | INSTALLING = 1, 17 | UPDATING = 2, 18 | NEED_REFRESH = 3, 19 | INSTALLED = 4, 20 | } 21 | 22 | const [swStatus, setSwStatus] = createSignal(SWStatus.NOT_INSTALLED); 23 | 24 | let updateSW = () => {}; 25 | 26 | shouldInstall().then(async (canInstall) => { 27 | if (!canInstall) { 28 | return; 29 | } 30 | 31 | let alreadyInstalled = !!(await navigator.serviceWorker.getRegistration()); 32 | 33 | updateSW = registerSW({ 34 | onRegisteredSW() { 35 | setSwStatus(SWStatus.INSTALLED); 36 | }, 37 | onBeginUpdate() { 38 | setSwStatus(alreadyInstalled ? SWStatus.UPDATING : SWStatus.INSTALLING); 39 | }, 40 | onNeedRefresh() { 41 | setSwStatus(SWStatus.NEED_REFRESH); 42 | }, 43 | onOfflineReady() { 44 | setSwStatus(SWStatus.INSTALLED); 45 | alreadyInstalled = true; 46 | }, 47 | }); 48 | }); 49 | 50 | export { swStatus, updateSW }; 51 | -------------------------------------------------------------------------------- /patches/vite-plugin-pwa.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/client/build/register.js b/dist/client/build/register.js 2 | index 95340c19195a56fb0ff3f9a24b00e4ed8ce08858..dc9fb67b8e1dc3ace58cf0322afbf4f2a73dacf7 100644 3 | --- a/dist/client/build/register.js 4 | +++ b/dist/client/build/register.js 5 | @@ -6,6 +6,7 @@ var autoDestroy = selfDestroying === "true"; 6 | function registerSW(options = {}) { 7 | const { 8 | immediate = false, 9 | + onBeginUpdate, 10 | onNeedRefresh, 11 | onOfflineReady, 12 | onRegistered, 13 | @@ -77,6 +78,12 @@ function registerSW(options = {}) { 14 | } 15 | } 16 | wb.register({ immediate }).then((r) => { 17 | + if (onBeginUpdate) { 18 | + r?.addEventListener('updatefound', () => { 19 | + onBeginUpdate(); 20 | + }); 21 | + } 22 | + 23 | if (onRegisteredSW) 24 | onRegisteredSW("__SW__", r); 25 | else 26 | diff --git a/types/index.d.ts b/types/index.d.ts 27 | index c2553517a12c98f4f7d1b0ef10a2dd203842d45e..ea9006e2d44617f80ed7dd51ce6dd83d16819ad0 100644 28 | --- a/types/index.d.ts 29 | +++ b/types/index.d.ts 30 | @@ -1,5 +1,6 @@ 31 | export interface RegisterSWOptions { 32 | immediate?: boolean 33 | + onBeginUpdate?: () => void 34 | onNeedRefresh?: () => void 35 | onOfflineReady?: () => void 36 | /** 37 | -------------------------------------------------------------------------------- /src/components/main/account-overflow-menu.tsx: -------------------------------------------------------------------------------- 1 | import { openModal, useModalContext } from '~/globals/modals'; 2 | 3 | import type { AccountData } from '~/lib/preferences/sessions'; 4 | import { useSession } from '~/lib/states/session'; 5 | 6 | import LeaveOutlinedIcon from '~/components/icons-central/leave-outline'; 7 | import * as Menu from '~/components/menu'; 8 | import * as Prompt from '~/components/prompt'; 9 | 10 | export interface AccountOverflowMenuProps { 11 | anchor: HTMLElement; 12 | account: AccountData; 13 | } 14 | 15 | const AccountOverflowMenu = (props: AccountOverflowMenuProps) => { 16 | const { close } = useModalContext(); 17 | const { removeAccount } = useSession(); 18 | 19 | const account = props.account; 20 | 21 | return ( 22 | 23 | { 27 | close(); 28 | 29 | const profile = () => account.profile; 30 | 31 | openModal(() => ( 32 | } 35 | confirmLabel="Sign out" 36 | onConfirm={() => removeAccount(account.did)} 37 | /> 38 | )); 39 | }} 40 | /> 41 | 42 | ); 43 | }; 44 | 45 | export default AccountOverflowMenu; 46 | -------------------------------------------------------------------------------- /src/views/moderation-lists.tsx: -------------------------------------------------------------------------------- 1 | import { createMyListsQuery } from '~/api/queries/my-lists'; 2 | 3 | import { useTitle } from '~/lib/navigation/router'; 4 | 5 | import IconButton from '~/components/icon-button'; 6 | import AddOutlinedIcon from '~/components/icons-central/add-outline'; 7 | import List from '~/components/list'; 8 | import ListItem from '~/components/lists/list-item'; 9 | import * as Page from '~/components/page'; 10 | 11 | const ModerationListsPage = () => { 12 | const lists = createMyListsQuery('moderation'); 13 | 14 | useTitle(() => `My moderation lists — ${import.meta.env.VITE_APP_NAME}`); 15 | 16 | return ( 17 | <> 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | { 30 | // 31 | }} 32 | /> 33 | 34 | 35 | 36 | { 40 | return ; 41 | }} 42 | isFetchingNextPage={lists.isFetching} 43 | /> 44 | 45 | ); 46 | }; 47 | 48 | export default ModerationListsPage; 49 | -------------------------------------------------------------------------------- /src/lib/hooks/outside-click.ts: -------------------------------------------------------------------------------- 1 | import { createEffect } from 'solid-js'; 2 | 3 | import { createEventListener } from './event-listener'; 4 | 5 | export const useOutsideClick = (container: HTMLElement, callback: () => void, enabled: () => boolean) => { 6 | createEffect(() => { 7 | if (!enabled()) { 8 | return; 9 | } 10 | 11 | let initialTarget: HTMLElement | null = null; 12 | 13 | createEventListener(document, 'pointerdown', (ev) => { 14 | // We'd like to know where the click initially started from, not where the 15 | // click ended up, this prevents closing the modal prematurely from the 16 | // user (accidentally) overshooting their mouse cursor. 17 | 18 | initialTarget = ev.target as HTMLElement | null; 19 | }); 20 | 21 | createEventListener( 22 | document, 23 | 'click', 24 | () => { 25 | // Don't do anything if `initialTarget` is somehow missing 26 | if (!initialTarget) { 27 | return; 28 | } 29 | 30 | // Unset `initialTarget` now that we're here 31 | const target = initialTarget; 32 | initialTarget = null; 33 | 34 | // Don't do anything if `target` is inside `container` 35 | if (container.contains(target)) { 36 | return; 37 | } 38 | 39 | // Call back since this click happened outside `container`. 40 | callback(); 41 | }, 42 | true, 43 | ); 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /src/lib/interaction.ts: -------------------------------------------------------------------------------- 1 | export const isMac = /^Mac/i.test(navigator.platform); 2 | 3 | const DEFAULT_EXCLUSION = 'a, button, img, video, dialog, [role=button]'; 4 | export const INTERACTION_TAGS = 'a, button, [role=button]'; 5 | 6 | export const hasSelectionRange = () => { 7 | const selection = window.getSelection(); 8 | return selection !== null && selection.type === 'Range'; 9 | }; 10 | 11 | export const isElementClicked = (ev: Event, exclusion = DEFAULT_EXCLUSION) => { 12 | const target = ev.currentTarget as HTMLElement; 13 | const path = ev.composedPath() as HTMLElement[]; 14 | 15 | if ( 16 | !path.includes(target) || 17 | (ev.type === 'keydown' && (ev as KeyboardEvent).key !== 'Enter') || 18 | (ev.type === 'auxclick' && (ev as MouseEvent).button !== 1) 19 | ) { 20 | return false; 21 | } 22 | 23 | for (let idx = 0, len = path.length; idx < len; idx++) { 24 | const node = path[idx]; 25 | 26 | if (node == target) { 27 | break; 28 | } 29 | 30 | if (node.matches(exclusion)) { 31 | return false; 32 | } 33 | } 34 | 35 | return !hasSelectionRange(); 36 | }; 37 | 38 | export const isElementAltClicked = (ev: MouseEvent | KeyboardEvent) => { 39 | return ev.type === 'auxclick' || isCtrlKeyPressed(ev); 40 | }; 41 | 42 | export const isCtrlKeyPressed = (ev: MouseEvent | KeyboardEvent) => { 43 | return isMac ? ev.metaKey : ev.ctrlKey; 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/filter-bar.tsx: -------------------------------------------------------------------------------- 1 | import { createMemo } from 'solid-js'; 2 | 3 | import CheckOutlinedIcon from './icons-central/check-outline'; 4 | 5 | export interface FilterOption { 6 | value: T; 7 | label: string; 8 | } 9 | 10 | export interface FilterBarProps { 11 | value: T; 12 | options: FilterOption>[]; 13 | onChange: (next: T) => void; 14 | } 15 | 16 | const FilterBar = (props: FilterBarProps) => { 17 | const onChange = props.onChange; 18 | 19 | return ( 20 |
21 | {props.options.map((option) => { 22 | const isSelected = createMemo(() => props.value === option.value); 23 | 24 | return ( 25 | 37 | ); 38 | })} 39 |
40 | ); 41 | }; 42 | 43 | export default FilterBar; 44 | -------------------------------------------------------------------------------- /src/components/embeds/quote-blocked-embed.tsx: -------------------------------------------------------------------------------- 1 | import type { AppBskyEmbedRecord } from '@atcute/bluesky'; 2 | import type { ParsedCanonicalResourceUri } from '@atcute/lexicons'; 3 | 4 | import BlockOutlinedIcon from '../icons-central/block-outline'; 5 | 6 | export interface QuoteBlockedEmbedProps { 7 | embed: AppBskyEmbedRecord.ViewBlocked; 8 | uri: ParsedCanonicalResourceUri; 9 | } 10 | 11 | const QuoteBlockedEmbed = ({ embed, uri }: QuoteBlockedEmbedProps) => { 12 | const viewer = embed.author.viewer; 13 | 14 | const blocking = !!viewer?.blocking; 15 | const blockedBy = !!viewer?.blockedBy; 16 | 17 | return ( 18 | 22 |
23 | 24 |
25 | 26 | 27 | {blockedBy 28 | ? `You're blocked by this user` 29 | : blocking 30 | ? `You've blocked this account` 31 | : `Interaction blocked`} 32 | 33 | 34 | View 35 |
36 | ); 37 | }; 38 | 39 | export default QuoteBlockedEmbed; 40 | -------------------------------------------------------------------------------- /src/api/utils/query.ts: -------------------------------------------------------------------------------- 1 | import { batch } from 'solid-js'; 2 | 3 | import { 4 | type InfiniteData, 5 | type QueryClient, 6 | type QueryFunctionContext, 7 | type QueryKey, 8 | } from '@mary/solid-query'; 9 | 10 | export const resetInfiniteData = (client: QueryClient, queryKey: QueryKey) => { 11 | batch(() => { 12 | client.setQueriesData>({ queryKey }, (data) => { 13 | if (data && data.pages.length > 1) { 14 | return { 15 | pages: data.pages.slice(0, 1), 16 | pageParams: data.pageParams.slice(0, 1), 17 | }; 18 | } 19 | 20 | return data; 21 | }); 22 | 23 | client.invalidateQueries({ queryKey }); 24 | }); 25 | }; 26 | 27 | const errorMap = new WeakMap(); 28 | 29 | export const wrapQuery = ( 30 | fn: (ctx: QueryFunctionContext) => Promise, 31 | ) => { 32 | return async (ctx: QueryFunctionContext): Promise => { 33 | try { 34 | return await fn(ctx); 35 | } catch (err) { 36 | // @ts-expect-error 37 | errorMap.set(err as any, { pageParam: ctx.pageParam, direction: ctx.direction }); 38 | throw err; 39 | } 40 | }; 41 | }; 42 | 43 | export const getQueryErrorInfo = (err: unknown) => { 44 | const info = errorMap.get(err as any); 45 | return info; 46 | }; 47 | -------------------------------------------------------------------------------- /src/components/settings/moderation/labeling/labeler-overflow-menu.tsx: -------------------------------------------------------------------------------- 1 | import type { ModerationLabeler } from '~/api/moderation'; 2 | 3 | import { useModalContext } from '~/globals/modals'; 4 | import { history } from '~/globals/navigation'; 5 | 6 | import PersonOutlinedIcon from '~/components/icons-central/person-outline'; 7 | import ShieldOffOutlinedIcon from '~/components/icons-central/shield-off-outline'; 8 | import * as Menu from '~/components/menu'; 9 | 10 | export interface LabelerOverflowMenuProps { 11 | anchor: HTMLElement; 12 | labeler: ModerationLabeler; 13 | isSubscribed: boolean; 14 | onUnsubscribe: () => void; 15 | } 16 | 17 | const LabelerOverflowMenu = (props: LabelerOverflowMenuProps) => { 18 | const { close } = useModalContext(); 19 | 20 | const labeler = () => props.labeler; 21 | const did = labeler().did; 22 | 23 | const onUnsubscribe = props.onUnsubscribe; 24 | 25 | return ( 26 | 27 | { 31 | close(); 32 | history.navigate(`/${did}`); 33 | }} 34 | /> 35 | 36 | {props.isSubscribed && ( 37 | { 41 | close(); 42 | onUnsubscribe(); 43 | }} 44 | /> 45 | )} 46 | 47 | ); 48 | }; 49 | 50 | export default LabelerOverflowMenu; 51 | -------------------------------------------------------------------------------- /src/api/queries/bookmark.ts: -------------------------------------------------------------------------------- 1 | import { createQuery } from '@mary/solid-query'; 2 | 3 | import type { TagItem } from '~/lib/aglais-bookmarks/db'; 4 | import { inject } from '~/lib/states/singleton'; 5 | import BookmarksService from '~/lib/states/singletons/bookmarks'; 6 | 7 | export interface HydratedTagItem extends TagItem { 8 | count: number; 9 | } 10 | 11 | export const createBookmarkMetaQuery = () => { 12 | const bookmarks = inject(BookmarksService); 13 | 14 | const query = createQuery(() => { 15 | return { 16 | queryKey: ['bookmark-meta'], 17 | async queryFn() { 18 | const db = await bookmarks.open(); 19 | const tx = db.transaction(['tags', 'bookmarks'], 'readonly'); 20 | 21 | const tags = await tx.objectStore('tags').getAll(); 22 | const bookmarksStore = tx.objectStore('bookmarks'); 23 | 24 | const [totalCount, ...counts] = await Promise.all([ 25 | bookmarksStore.count(), 26 | ...tags.map((tag) => { 27 | return bookmarksStore.index('tags').count(tag.id); 28 | }), 29 | ]); 30 | 31 | const hydrated = tags.map((tag, idx): HydratedTagItem => { 32 | return { 33 | ...tag, 34 | count: counts[idx], 35 | }; 36 | }); 37 | 38 | { 39 | const collator = new Intl.Collator('en-US'); 40 | hydrated.sort((a, b) => collator.compare(a.name, b.name)); 41 | } 42 | 43 | return { totalCount, tags: hydrated }; 44 | }, 45 | }; 46 | }); 47 | 48 | return query; 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/search/context.tsx: -------------------------------------------------------------------------------- 1 | import { type JSX, createContext, createSignal, useContext } from 'solid-js'; 2 | 3 | import { assert } from '~/lib/utils/invariant'; 4 | 5 | interface SearchBarContext { 6 | inputEl: () => HTMLInputElement | null; 7 | setInputEl: (next: HTMLInputElement) => void; 8 | 9 | query: () => string; 10 | setQuery: (next: string) => void; 11 | 12 | onSearch: (next: string) => void; 13 | onFocus: () => void; 14 | } 15 | 16 | const Context = createContext(); 17 | 18 | export interface SearchBarProviderProps { 19 | query: string; 20 | onFocus?: () => void; 21 | onQueryChange: (next: string) => void; 22 | onSearch: (next: string) => void; 23 | children: JSX.Element; 24 | } 25 | 26 | export const SearchBarProvider = (props: SearchBarProviderProps) => { 27 | const [inputEl, setInputEl] = createSignal(null); 28 | 29 | const context: SearchBarContext = { 30 | inputEl, 31 | setInputEl, 32 | 33 | query() { 34 | return props.query; 35 | }, 36 | setQuery: props.onQueryChange, 37 | 38 | onSearch: props.onSearch, 39 | onFocus: props.onFocus ?? (() => {}), 40 | }; 41 | 42 | return {props.children}; 43 | }; 44 | 45 | export const useSearchBar = () => { 46 | const context = useContext(Context); 47 | assert(context !== undefined, `Expected useSearch to be called under `); 48 | 49 | return context; 50 | }; 51 | -------------------------------------------------------------------------------- /src/api/utils/richtext-stringify.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyRichtextFacet } from '@atcute/bluesky'; 2 | import { segmentize } from '@atcute/bluesky-richtext-segmenter'; 3 | 4 | import { isLinkValid } from './strings'; 5 | 6 | const MDLINK_ESCAPE_RE = /([\\\]])/g; 7 | const ESCAPE_RE = /([@@#:\\\[])/g; 8 | 9 | export const serializeRichText = (text: string, facets: AppBskyRichtextFacet.Main[] | undefined): string => { 10 | const segments = segmentize(text, facets); 11 | 12 | let result = ''; 13 | 14 | for (let i = 0, ilen = segments.length; i < ilen; i++) { 15 | const segment = segments[i]; 16 | 17 | const features = segment.features; 18 | const subtext = segment.text; 19 | 20 | let substitute: string | undefined; 21 | 22 | if (features) { 23 | for (let j = 0, jlen = features.length; j < jlen; j++) { 24 | const feature = features[j]; 25 | const type = feature.$type; 26 | 27 | if (type === 'app.bsky.richtext.facet#link') { 28 | const uri = feature.uri; 29 | 30 | substitute = !isLinkValid(uri, subtext) 31 | ? `[${subtext.replace(MDLINK_ESCAPE_RE, '\\$1')}](${uri})` 32 | : uri; 33 | } else if ( 34 | type === 'app.bsky.richtext.facet#mention' || 35 | type === 'app.bsky.richtext.facet#tag' || 36 | type === 'blue.moji.richtext.facet' 37 | ) { 38 | substitute = subtext; 39 | } 40 | } 41 | } 42 | 43 | result += substitute ?? subtext.replace(ESCAPE_RE, '\\$1'); 44 | } 45 | 46 | return result; 47 | }; 48 | -------------------------------------------------------------------------------- /patches/@floating-ui__utils.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/floating-ui.utils.dom.mjs b/dist/floating-ui.utils.dom.mjs 2 | index 3dcfbb2f6e69e892f06618adf26d710c4e435eac..3c7b5c12c4a72ecc54346099184aa885689cb467 100644 3 | --- a/dist/floating-ui.utils.dom.mjs 4 | +++ b/dist/floating-ui.utils.dom.mjs 5 | @@ -1,6 +1,3 @@ 6 | -function hasWindow() { 7 | - return typeof window !== 'undefined'; 8 | -} 9 | function getNodeName(node) { 10 | if (isNode(node)) { 11 | return (node.nodeName || '').toLowerCase(); 12 | @@ -19,27 +16,15 @@ function getDocumentElement(node) { 13 | return (_ref = (isNode(node) ? node.ownerDocument : node.document) || window.document) == null ? void 0 : _ref.documentElement; 14 | } 15 | function isNode(value) { 16 | - if (!hasWindow()) { 17 | - return false; 18 | - } 19 | return value instanceof Node || value instanceof getWindow(value).Node; 20 | } 21 | function isElement(value) { 22 | - if (!hasWindow()) { 23 | - return false; 24 | - } 25 | return value instanceof Element || value instanceof getWindow(value).Element; 26 | } 27 | function isHTMLElement(value) { 28 | - if (!hasWindow()) { 29 | - return false; 30 | - } 31 | return value instanceof HTMLElement || value instanceof getWindow(value).HTMLElement; 32 | } 33 | function isShadowRoot(value) { 34 | - if (!hasWindow() || typeof ShadowRoot === 'undefined') { 35 | - return false; 36 | - } 37 | return value instanceof ShadowRoot || value instanceof getWindow(value).ShadowRoot; 38 | } 39 | function isOverflowElement(element) { 40 | -------------------------------------------------------------------------------- /src/components/moderation/label-details-prompt.tsx: -------------------------------------------------------------------------------- 1 | import { type LabelModerationCause, type ModerationLabeler, getLocalizedLabel } from '~/api/moderation'; 2 | 3 | import { useModalContext } from '~/globals/modals'; 4 | 5 | import * as Prompt from '../prompt'; 6 | 7 | export interface LabelDetailsPrompt { 8 | cause: LabelModerationCause; 9 | } 10 | 11 | const LabelDetailsPrompt = ({ cause }: LabelDetailsPrompt) => { 12 | const { close } = useModalContext(); 13 | 14 | const locale = getLocalizedLabel(cause.d); 15 | const service = cause.s; 16 | 17 | return ( 18 | 19 | {/* @once */ locale.n} 20 | {/* @once */ locale.d} 21 | 22 |

23 | Label applied by{' '} 24 | {service ? ( 25 | 26 | {/* @once */ renderLabelService(service)} 27 | 28 | ) : ( 29 | `the author` 30 | )} 31 | . 32 |

33 | 34 | 35 | Dismiss 36 | 37 |
38 | ); 39 | }; 40 | 41 | export default LabelDetailsPrompt; 42 | 43 | const renderLabelService = (source: ModerationLabeler) => { 44 | const profile = source.profile; 45 | 46 | if (profile) { 47 | return profile.displayName || `@${profile.handle.toLowerCase()}`; 48 | } 49 | 50 | return source.did; 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/time-ago.tsx: -------------------------------------------------------------------------------- 1 | import { type Accessor, type JSX, createRenderEffect, createSignal } from 'solid-js'; 2 | 3 | import { formatAbsDateTime, formatReltime } from '~/lib/intl/time'; 4 | import { requestIdle } from '~/lib/utils/misc'; 5 | 6 | export interface TimeAgoProps { 7 | value: string | number; 8 | /** Expected to be static */ 9 | absolute?: (time: number) => string; 10 | /** Expected to be static */ 11 | relative?: (time: number) => string; 12 | children: (relative: Accessor, absolute: Accessor) => JSX.Element; 13 | } 14 | 15 | const [watch, tick] = createSignal(undefined, { equals: false }); 16 | 17 | const tickForward = () => { 18 | tick(); 19 | setTimeout(() => requestIdle(tickForward), 60_000); 20 | }; 21 | 22 | const TimeAgo = (props: TimeAgoProps) => { 23 | const formatAbsolute = props.absolute ?? formatAbsDateTime; 24 | const formatRelative = props.relative ?? formatReltime; 25 | 26 | const [absolute, setAbsolute] = createSignal(''); 27 | const [relative, setRelative] = createSignal(''); 28 | 29 | createRenderEffect(() => { 30 | const time = toInt(props.value); 31 | 32 | setAbsolute(formatAbsolute(time)); 33 | 34 | createRenderEffect(() => { 35 | watch(); 36 | return setRelative(formatRelative(time)); 37 | }); 38 | }); 39 | 40 | return props.children(relative, absolute); 41 | }; 42 | 43 | const toInt = (date: string | number): number => { 44 | return typeof date !== 'number' ? new Date(date).getTime() : date; 45 | }; 46 | 47 | export default TimeAgo; 48 | tickForward(); 49 | -------------------------------------------------------------------------------- /src/lib/states/singleton.tsx: -------------------------------------------------------------------------------- 1 | import { type ParentProps, createContext, createRoot, getOwner, useContext } from 'solid-js'; 2 | 3 | import { assert } from '../utils/invariant'; 4 | 5 | interface Singleton { 6 | n: string; 7 | c: () => T; 8 | } 9 | 10 | interface SingletonContext { 11 | inject(singleton: Singleton): T; 12 | } 13 | 14 | const Context = createContext(); 15 | 16 | export const SingletonProvider = (props: ParentProps) => { 17 | const owner = getOwner(); 18 | const registry = new Map< 19 | string, 20 | { 21 | c: any; 22 | v: any; 23 | d: () => void; 24 | } 25 | >(); 26 | 27 | const context: SingletonContext = { 28 | inject({ n: name, c: construct }) { 29 | let registered = registry.get(name); 30 | if (registered === undefined || registered.c !== construct) { 31 | registered?.d(); 32 | registered = createRoot((dispose) => ({ c: construct, d: dispose, v: construct() }), owner); 33 | 34 | registry.set(name, registered); 35 | } 36 | 37 | return registered.v; 38 | }, 39 | }; 40 | 41 | return {props.children}; 42 | }; 43 | 44 | export const define = (name: string, construct: () => T): Singleton => { 45 | return { n: name, c: construct }; 46 | }; 47 | 48 | export const inject = (singleton: Singleton): T => { 49 | const context = useContext(Context); 50 | assert(context !== undefined, `Expected inject to be called under `); 51 | 52 | return context.inject(singleton); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/composer/lib/link-detection.ts: -------------------------------------------------------------------------------- 1 | import type { ActorIdentifier } from '@atcute/lexicons'; 2 | 3 | import { makeAtUri } from '~/api/types/at-uri'; 4 | import { safeUrlParse } from '~/api/utils/strings'; 5 | 6 | import { BSKY_FEED_LINK_RE, BSKY_LIST_LINK_RE, BSKY_POST_LINK_RE } from '~/lib/bsky/url'; 7 | 8 | import { type PostRecordEmbed } from './state'; 9 | 10 | export const getRecordEmbedFromLink = (href: string): PostRecordEmbed | undefined => { 11 | const url = safeUrlParse(href); 12 | 13 | if (url !== null) { 14 | const host = url.host; 15 | const path = url.pathname; 16 | let match: RegExpExecArray | null; 17 | 18 | if (host === 'bsky.app') { 19 | if ((match = BSKY_POST_LINK_RE.exec(path))) { 20 | const didOrHandle = match[1] as ActorIdentifier; 21 | const rkey = match[2]; 22 | 23 | return { 24 | type: 'quote', 25 | uri: makeAtUri(didOrHandle, 'app.bsky.feed.post', rkey), 26 | origin: false, 27 | }; 28 | } 29 | 30 | if ((match = BSKY_FEED_LINK_RE.exec(path))) { 31 | const didOrHandle = match[1] as ActorIdentifier; 32 | const rkey = match[2]; 33 | 34 | return { 35 | type: 'feed', 36 | uri: makeAtUri(didOrHandle, 'app.bsky.feed.generator', rkey), 37 | }; 38 | } 39 | 40 | if ((match = BSKY_LIST_LINK_RE.exec(path))) { 41 | const didOrHandle = match[1] as ActorIdentifier; 42 | const rkey = match[2]; 43 | 44 | return { 45 | type: 'list', 46 | uri: makeAtUri(didOrHandle, 'app.bsky.graph.list', rkey), 47 | }; 48 | } 49 | } 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /scripts/generate-oauth-keys.js: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | 3 | import * as v from '@badrap/valita'; 4 | 5 | import * as TID from '@atcute/tid'; 6 | 7 | const jwksSchema = v.object({ 8 | keys: v.array( 9 | v.object({ 10 | privateKey: v.unknown(), 11 | publicKey: v.unknown(), 12 | }), 13 | ), 14 | }); 15 | 16 | /** @type {v.Infer | undefined} */ 17 | let jwks; 18 | try { 19 | const raw = await fs.readFile('./oauth-credentials.local.json', 'utf-8'); 20 | const json = JSON.parse(raw); 21 | 22 | jwks = jwksSchema.parse(json, { mode: 'passthrough' }); 23 | } catch (err) { 24 | if (err.code !== 'ENOENT') { 25 | throw err; 26 | } 27 | 28 | jwks = { 29 | keys: [], 30 | }; 31 | } 32 | 33 | const { publicKey, privateKey } = await crypto.subtle.generateKey( 34 | { 35 | name: 'ECDSA', 36 | namedCurve: 'P-256', 37 | }, 38 | true, 39 | ['sign', 'verify'], 40 | ); 41 | 42 | const kid = `aglais-${TID.now()}`; 43 | const privateJWK = await crypto.subtle.exportKey('jwk', privateKey); 44 | const publicJWK = await crypto.subtle.exportKey('jwk', publicKey); 45 | 46 | jwks = { 47 | keys: [ 48 | { 49 | privateKey: { 50 | ...privateJWK, 51 | kid: kid, 52 | }, 53 | publicKey: { 54 | kty: publicJWK.kty, 55 | crv: publicJWK.crv, 56 | x: publicJWK.x, 57 | y: publicJWK.y, 58 | use: 'sig', 59 | alg: 'ES256', 60 | kid: kid, 61 | }, 62 | }, 63 | ...jwks.keys, 64 | ], 65 | }; 66 | 67 | await fs.writeFile('./oauth-credentials.local.json', JSON.stringify(jwks, null, '\t') + '\n'); 68 | -------------------------------------------------------------------------------- /src/components/icons-central/eye-slash-outline.tsx: -------------------------------------------------------------------------------- 1 | import { createIcon } from './_icon'; 2 | 3 | const EyeSlashOutlinedIcon = createIcon(() => ( 4 | 5 | 9 | 10 | )); 11 | 12 | export default EyeSlashOutlinedIcon; 13 | -------------------------------------------------------------------------------- /src/api/queries/post.ts: -------------------------------------------------------------------------------- 1 | import { ok } from '@atcute/client'; 2 | import { type Did } from '@atcute/lexicons'; 3 | import { isDid } from '@atcute/lexicons/syntax'; 4 | import { createQuery } from '@mary/solid-query'; 5 | 6 | import { useAgent } from '~/lib/states/agent'; 7 | 8 | import { findPostsInCache } from '../cache/post-shadow'; 9 | import { assertCanonicalResourceUri, makeAtUri } from '../types/at-uri'; 10 | 11 | import { resolveHandle } from './handle'; 12 | 13 | export const createPostQuery = (postUri: () => string) => { 14 | const { client } = useAgent(); 15 | 16 | return createQuery((queryClient) => { 17 | const $postUri = postUri(); 18 | 19 | return { 20 | queryKey: ['post', $postUri], 21 | async queryFn(ctx) { 22 | const uri = assertCanonicalResourceUri($postUri); 23 | 24 | let did: Did; 25 | if (isDid(uri.repo)) { 26 | did = uri.repo; 27 | } else { 28 | did = await resolveHandle(client, uri.repo, ctx.signal); 29 | } 30 | 31 | const data = await ok( 32 | client.get('app.bsky.feed.getPosts', { 33 | signal: ctx.signal, 34 | params: { 35 | uris: [makeAtUri(did, uri.collection, uri.rkey)], 36 | }, 37 | }), 38 | ); 39 | 40 | const post = data.posts[0]; 41 | 42 | if (!post) { 43 | throw new Error(`Post not found`); 44 | } 45 | 46 | return post; 47 | }, 48 | initialData() { 49 | for (const post of findPostsInCache(queryClient, $postUri, true)) { 50 | return post; 51 | } 52 | 53 | return undefined; 54 | }, 55 | }; 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /src/lib/hooks/guard.ts: -------------------------------------------------------------------------------- 1 | import { createMemo, createSignal, onCleanup } from 'solid-js'; 2 | 3 | export type GuardFunction = () => boolean; 4 | type GuardKind = 'some' | 'every'; 5 | 6 | export const createGuard = (kind: GuardKind = 'some', memoize = false) => { 7 | const [guards, setGuards] = createSignal([]); 8 | 9 | const isGuarded = createMemoMaybe(memoize, createIsGuarded(kind, guards)); 10 | 11 | const addGuard = (guard: GuardFunction) => { 12 | setGuards((guards) => guards.concat(guard)); 13 | onCleanup(() => setGuards((guards) => guards.toSpliced(guards.indexOf(guard), 1))); 14 | }; 15 | 16 | return [isGuarded, addGuard] as const; 17 | }; 18 | 19 | const createIsGuarded = (kind: GuardKind, guards: () => GuardFunction[]) => { 20 | if (kind === 'every') { 21 | return () => { 22 | const $guards = guards(); 23 | 24 | for (let idx = 0, len = $guards.length; idx < len; idx++) { 25 | const guard = $guards[idx]; 26 | if (!guard()) { 27 | return false; 28 | } 29 | } 30 | 31 | return true; 32 | }; 33 | } else if (kind === 'some') { 34 | return () => { 35 | const $guards = guards(); 36 | 37 | for (let idx = 0, len = $guards.length; idx < len; idx++) { 38 | const guard = $guards[idx]; 39 | if (guard()) { 40 | return true; 41 | } 42 | } 43 | 44 | return false; 45 | }; 46 | } 47 | 48 | return () => false; 49 | }; 50 | 51 | const createMemoMaybe = (memoize: boolean, fn: () => T): (() => T) => { 52 | if (memoize) { 53 | return createMemo(fn); 54 | } else { 55 | return fn; 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Mary 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /src/components/moderation/labels-on-me.tsx: -------------------------------------------------------------------------------- 1 | import { Show } from 'solid-js'; 2 | 3 | import type { ComAtprotoLabelDefs } from '@atcute/atproto'; 4 | 5 | import { useSession } from '~/lib/states/session'; 6 | 7 | import CircleInfoOutlinedIcon from '../icons-central/circle-info-outline'; 8 | 9 | export interface LabelsOnMeProps { 10 | type: 'content' | 'account'; 11 | labels: ComAtprotoLabelDefs.Label[] | undefined; 12 | large?: boolean; 13 | class?: string; 14 | } 15 | 16 | const LabelsOnMe = (props: LabelsOnMeProps) => { 17 | const { currentAccount } = useSession(); 18 | 19 | return ( 20 | { 22 | const did = currentAccount?.did; 23 | const labels = props.labels?.filter((l) => l.src !== did && l.val[0] !== '!'); 24 | 25 | if (labels && labels.length > 0) { 26 | return labels; 27 | } 28 | })()} 29 | > 30 | {(labels) => ( 31 |
32 | 44 |
45 | )} 46 |
47 | ); 48 | }; 49 | 50 | export default LabelsOnMe; 51 | -------------------------------------------------------------------------------- /src/lib/hooks/local-storage.ts: -------------------------------------------------------------------------------- 1 | import { createEffect } from 'solid-js'; 2 | import { type StoreNode, createMutable, modifyMutable, reconcile } from 'solid-js/store'; 3 | 4 | import { createEventListener } from '../hooks/event-listener'; 5 | 6 | type MigrateFn = (version: number, prev: any) => T; 7 | 8 | /** Useful for knowing whether an effect occured by external writes */ 9 | export let isExternalWriting = false; 10 | 11 | const parse = (raw: string | null, migrate: MigrateFn): T => { 12 | if (raw !== null) { 13 | try { 14 | const persisted = JSON.parse(raw); 15 | 16 | if (persisted != null) { 17 | return migrate(persisted.$version || 0, persisted); 18 | } 19 | } catch {} 20 | } 21 | 22 | return migrate(0, null); 23 | }; 24 | 25 | export const createReactiveLocalStorage = (name: string, migrate: MigrateFn) => { 26 | const mutable = createMutable(parse(localStorage.getItem(name), migrate)); 27 | 28 | createEffect((inited) => { 29 | const json = JSON.stringify(mutable); 30 | 31 | if (inited && !isExternalWriting) { 32 | localStorage.setItem(name, json); 33 | } 34 | 35 | return true; 36 | }, false); 37 | 38 | createEventListener(window, 'storage', (ev) => { 39 | if (ev.key === name) { 40 | // Prevent our own effects from running, since this is already persisted. 41 | 42 | try { 43 | isExternalWriting = true; 44 | modifyMutable(mutable, reconcile(parse(ev.newValue, migrate), { merge: true })); 45 | } finally { 46 | isExternalWriting = false; 47 | } 48 | } 49 | }); 50 | 51 | return mutable; 52 | }; 53 | -------------------------------------------------------------------------------- /src/lib/states/theme.tsx: -------------------------------------------------------------------------------- 1 | import { type ParentProps, createContext, createRenderEffect, createSignal, useContext } from 'solid-js'; 2 | 3 | import * as preferences from '~/globals/preferences'; 4 | 5 | import { useMediaQuery } from '../hooks/media-query'; 6 | import { assert } from '../utils/invariant'; 7 | 8 | type Theme = 'light' | 'dark'; 9 | 10 | export interface ThemeContext { 11 | readonly currentTheme: Theme; 12 | } 13 | 14 | const Context = createContext(); 15 | 16 | export const useTheme = (): ThemeContext => { 17 | const context = useContext(Context); 18 | assert(context !== undefined, `Expected useTheme to be used under ThemeProvider`); 19 | 20 | return context; 21 | }; 22 | 23 | export const ThemeProvider = (props: ParentProps) => { 24 | const [theme, setTheme] = createSignal('light'); 25 | 26 | const context: ThemeContext = { 27 | get currentTheme() { 28 | return theme(); 29 | }, 30 | }; 31 | 32 | createRenderEffect(() => { 33 | const theme = preferences.global.ui.theme; 34 | 35 | if (theme === 'system') { 36 | const isDark = useMediaQuery('(prefers-color-scheme: dark)'); 37 | createRenderEffect(() => setTheme(!isDark() ? 'light' : 'dark')); 38 | } else { 39 | setTheme(theme); 40 | } 41 | }); 42 | 43 | createRenderEffect(() => { 44 | const cl = document.documentElement.classList; 45 | const $theme = theme(); 46 | 47 | cl.toggle('theme-light', $theme === 'light'); 48 | cl.toggle('theme-dark', $theme === 'dark'); 49 | }); 50 | 51 | return {props.children}; 52 | }; 53 | -------------------------------------------------------------------------------- /src/globals/modals.tsx: -------------------------------------------------------------------------------- 1 | import { type JSX, createContext, createSignal, useContext } from 'solid-js'; 2 | 3 | import { assert } from '~/lib/utils/invariant'; 4 | 5 | type ModalRenderer = (context: ModalContext) => JSX.Element; 6 | 7 | export interface ModalState { 8 | id: number; 9 | render: ModalRenderer; 10 | } 11 | 12 | const [modals, _setModals] = createSignal([]); 13 | let _id = 0; 14 | 15 | export const hasModals = (): boolean => { 16 | return modals().length !== 0; 17 | }; 18 | 19 | export const openModal = (fn: ModalRenderer): number => { 20 | const id = _id++; 21 | 22 | _setModals(($modals) => $modals.concat({ id, render: fn })); 23 | return id; 24 | }; 25 | 26 | export const closeModal = (id: number): void => { 27 | _setModals(($modals) => { 28 | const index = $modals.findIndex((v) => v.id === id); 29 | 30 | if (index === -1) { 31 | return $modals; 32 | } 33 | 34 | return $modals.toSpliced(index, 1); 35 | }); 36 | }; 37 | 38 | export const closeAllModals = (): void => { 39 | _setModals([]); 40 | }; 41 | 42 | export interface ModalContext { 43 | id: number; 44 | /** Whether this dialog is currently the top-most dialog presented */ 45 | isActive(): boolean; 46 | /** Close this dialog */ 47 | close(): void; 48 | } 49 | 50 | const Context = createContext(); 51 | 52 | export const useModalContext = (): ModalContext => { 53 | const context = useContext(Context); 54 | assert(context !== undefined, `Expected useModalContext to be used under a modal`); 55 | 56 | return context; 57 | }; 58 | 59 | export { Context as INTERNAL_ModalContext, modals as INTERNAL_modals }; 60 | -------------------------------------------------------------------------------- /src/styles/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | body { 7 | @apply overflow-y-scroll bg-background text-contrast; 8 | } 9 | 10 | body:has(div[data-modal]) { 11 | @apply overflow-hidden pr-[--sb-width]; 12 | } 13 | 14 | ::selection { 15 | @apply bg-accent text-accent-fg; 16 | } 17 | 18 | .a-dialog-desktop { 19 | max-height: min(100dvh - 88px, 652px); 20 | } 21 | 22 | [hidden] { 23 | display: none !important; 24 | } 25 | } 26 | 27 | @layer base { 28 | :root { 29 | --p-accent: 16 131 254; 30 | --p-accent-hover: 0 125 247; 31 | --p-accent-active: 0 119 236; 32 | --p-accent-fg: 255 255 255; 33 | --p-repost: 19 195 113; 34 | --p-like: 246 60 103; 35 | } 36 | 37 | .accent-scarlet { 38 | --p-accent: 201 0 37; 39 | --p-accent-hover: 191 0 35; 40 | --p-accent-active: 181 0 32; 41 | --p-accent-fg: 255 255 255; 42 | } 43 | 44 | .theme-light { 45 | --p-background: 255 255 255; 46 | --p-contrast: 20 20 20; 47 | --p-contrast-hinted: 25 25 25; 48 | --p-contrast-muted: 100 100 100; 49 | --p-contrast-overlay: 0 0 0; 50 | --p-outline: 243 243 243; 51 | --p-outline-md: 217 217 217; 52 | --p-outline-lg: 217 217 217; 53 | --p-error: 220 38 38; /* red-600 */ 54 | } 55 | 56 | .theme-dark { 57 | --p-background: 0 0 0; 58 | --p-contrast: 255 255 255; 59 | --p-contrast-hinted: 244 244 244; 60 | --p-contrast-muted: 123 123 123; 61 | --p-contrast-overlay: 33 34 41; 62 | --p-outline: 54 54 54; 63 | --p-outline-md: 57 57 57; 64 | --p-outline-lg: 113 113 113; 65 | --p-error: 248 113 113; /* red-400 */ 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/views/main/home.tsx: -------------------------------------------------------------------------------- 1 | import { useTitle } from '~/lib/navigation/router'; 2 | 3 | import ComposeFAB from '~/components/composer/compose-fab'; 4 | import IconButton from '~/components/icon-button'; 5 | import ChevronRightOutlinedIcon from '~/components/icons-central/chevron-right-outline'; 6 | import GearOutlinedIcon from '~/components/icons-central/gear-outline'; 7 | import * as Page from '~/components/page'; 8 | import TimelineList from '~/components/timeline/timeline-list'; 9 | 10 | const HomePage = () => { 11 | useTitle(() => `Home — ${import.meta.env.VITE_APP_NAME}`); 12 | 13 | return ( 14 | <> 15 | 16 | 17 | 18 | 19 | 20 |
21 | 27 |
28 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 44 | 45 | ); 46 | }; 47 | 48 | export default HomePage; 49 | -------------------------------------------------------------------------------- /src/views/post-quotes.tsx: -------------------------------------------------------------------------------- 1 | import type { Did, RecordKey } from '@atcute/lexicons'; 2 | 3 | import { createPostQuotesQuery } from '~/api/queries/post-quotes'; 4 | import { makeAtUri } from '~/api/types/at-uri'; 5 | 6 | import { useParams, useTitle } from '~/lib/navigation/router'; 7 | 8 | import * as Page from '~/components/page'; 9 | import PagedList from '~/components/paged-list'; 10 | import PostFeedItem from '~/components/timeline/post-feed-item'; 11 | import VirtualItem from '~/components/virtual-item'; 12 | 13 | const PostQuotesPage = () => { 14 | const { did, rkey } = useParams<{ 15 | did: Did; 16 | rkey: RecordKey; 17 | }>(); 18 | 19 | const uri = makeAtUri(did, 'app.bsky.feed.post', rkey); 20 | const quotes = createPostQuotesQuery(() => uri); 21 | 22 | useTitle(() => `Users quoting this post — ${import.meta.env.VITE_APP_NAME}`); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | page.posts)} 36 | error={quotes.error} 37 | render={(post) => { 38 | return ( 39 | 40 | 41 | 42 | ); 43 | }} 44 | hasNextPage={quotes.hasNextPage} 45 | isFetchingNextPage={quotes.isFetching} 46 | onEndReached={() => quotes.fetchNextPage()} 47 | /> 48 | 49 | ); 50 | }; 51 | 52 | export default PostQuotesPage; 53 | -------------------------------------------------------------------------------- /src/lib/pragmatic-dnd/DropIndicator.tsx: -------------------------------------------------------------------------------- 1 | import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types'; 2 | import { createMemo } from 'solid-js'; 3 | 4 | import Keyed from '~/components/keyed'; 5 | 6 | export interface DropIndicatorProps { 7 | edge: Edge | undefined; 8 | gap?: string; 9 | thickness?: string; 10 | } 11 | 12 | type Orientation = 'horizontal' | 'vertical'; 13 | 14 | const edgeToOrientation: Record = { 15 | top: 'horizontal', 16 | bottom: 'horizontal', 17 | left: 'vertical', 18 | right: 'vertical', 19 | }; 20 | 21 | const orientationStyles: Record = { 22 | horizontal: 'left-4 right-4', 23 | vertical: 'top-4 bottom-4', 24 | }; 25 | 26 | const DropIndicator = (props: DropIndicatorProps) => { 27 | return ( 28 | 29 | {(isVisible) => { 30 | if (!isVisible) { 31 | return null; 32 | } 33 | 34 | const gap = createMemo(() => props.gap || '0px'); 35 | const thickness = createMemo(() => props.thickness || '3px'); 36 | 37 | const orientation = createMemo((): Orientation => { 38 | const edge = props.edge; 39 | if (edge == null) { 40 | return 'horizontal'; 41 | } 42 | 43 | return edgeToOrientation[edge]; 44 | }); 45 | 46 | return ( 47 |
54 | ); 55 | }} 56 |
57 | ); 58 | }; 59 | 60 | export default DropIndicator; 61 | -------------------------------------------------------------------------------- /src/views/post-likes.tsx: -------------------------------------------------------------------------------- 1 | import type { Did, RecordKey } from '@atcute/lexicons'; 2 | 3 | import { createSubjectLikersQuery } from '~/api/queries/subject-likers'; 4 | import { makeAtUri } from '~/api/types/at-uri'; 5 | 6 | import { useParams, useTitle } from '~/lib/navigation/router'; 7 | 8 | import * as Page from '~/components/page'; 9 | import PagedList from '~/components/paged-list'; 10 | import ProfileFollowButton from '~/components/profiles/profile-follow-button'; 11 | import ProfileItem from '~/components/profiles/profile-item'; 12 | import VirtualItem from '~/components/virtual-item'; 13 | 14 | const PostLikesPage = () => { 15 | const { did, rkey } = useParams<{ 16 | did: Did; 17 | rkey: RecordKey; 18 | }>(); 19 | 20 | const uri = makeAtUri(did, 'app.bsky.feed.post', rkey); 21 | const likers = createSubjectLikersQuery(() => uri); 22 | 23 | useTitle(() => `Users liking this post — ${import.meta.env.VITE_APP_NAME}`); 24 | 25 | return ( 26 | <> 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | page.profiles)} 37 | error={likers.error} 38 | render={(item) => { 39 | return ( 40 | 41 | } /> 42 | 43 | ); 44 | }} 45 | hasNextPage={likers.hasNextPage} 46 | isFetchingNextPage={likers.isFetching} 47 | onEndReached={() => likers.fetchNextPage()} 48 | /> 49 | 50 | ); 51 | }; 52 | 53 | export default PostLikesPage; 54 | -------------------------------------------------------------------------------- /src/components/composer/embeds/feed-embed.tsx: -------------------------------------------------------------------------------- 1 | import { Match, Switch } from 'solid-js'; 2 | 3 | import { createFeedMetaQuery } from '~/api/queries/feed'; 4 | 5 | import CircularProgress from '~/components/circular-progress'; 6 | import FeedEmbedContent from '~/components/embeds/feed-embed'; 7 | import ErrorView from '~/components/error-view'; 8 | import IconButton from '~/components/icon-button'; 9 | import CrossLargeOutlinedIcon from '~/components/icons-central/cross-large-outline'; 10 | 11 | import type { PostFeedEmbed } from '../lib/state'; 12 | 13 | export interface FeedEmbedProps { 14 | embed: PostFeedEmbed; 15 | active: boolean; 16 | onRemove: () => void; 17 | } 18 | 19 | const FeedEmbed = (props: FeedEmbedProps) => { 20 | const query = createFeedMetaQuery(() => props.embed.uri); 21 | 22 | return ( 23 |
24 | 25 | 26 | {(data) => { 27 | return ; 28 | }} 29 | 30 | 31 | 32 | {(error) => ( 33 |
34 | query.refetch()} /> 35 |
36 | )} 37 |
38 | 39 | 40 |
41 | 42 |
43 |
44 |
45 | 46 | 54 |
55 | ); 56 | }; 57 | 58 | export default FeedEmbed; 59 | -------------------------------------------------------------------------------- /src/components/composer/embeds/list-embed.tsx: -------------------------------------------------------------------------------- 1 | import { Match, Switch } from 'solid-js'; 2 | 3 | import { createListMetaQuery } from '~/api/queries/list'; 4 | 5 | import CircularProgress from '~/components/circular-progress'; 6 | import ListEmbedContent from '~/components/embeds/list-embed'; 7 | import ErrorView from '~/components/error-view'; 8 | import IconButton from '~/components/icon-button'; 9 | import CrossLargeOutlinedIcon from '~/components/icons-central/cross-large-outline'; 10 | 11 | import type { PostListEmbed } from '../lib/state'; 12 | 13 | export interface ListEmbedProps { 14 | embed: PostListEmbed; 15 | active: boolean; 16 | onRemove: () => void; 17 | } 18 | 19 | const ListEmbed = (props: ListEmbedProps) => { 20 | const query = createListMetaQuery(() => props.embed.uri); 21 | 22 | return ( 23 |
24 | 25 | 26 | {(data) => { 27 | return ; 28 | }} 29 | 30 | 31 | 32 | {(error) => ( 33 |
34 | query.refetch()} /> 35 |
36 | )} 37 |
38 | 39 | 40 |
41 | 42 |
43 |
44 |
45 | 46 | 54 |
55 | ); 56 | }; 57 | 58 | export default ListEmbed; 59 | -------------------------------------------------------------------------------- /src/views/post-reposts.tsx: -------------------------------------------------------------------------------- 1 | import type { Did, RecordKey } from '@atcute/lexicons'; 2 | 3 | import { createSubjectRepostersQuery } from '~/api/queries/subject-reposters'; 4 | import { makeAtUri } from '~/api/types/at-uri'; 5 | 6 | import { useParams, useTitle } from '~/lib/navigation/router'; 7 | 8 | import * as Page from '~/components/page'; 9 | import PagedList from '~/components/paged-list'; 10 | import ProfileFollowButton from '~/components/profiles/profile-follow-button'; 11 | import ProfileItem from '~/components/profiles/profile-item'; 12 | import VirtualItem from '~/components/virtual-item'; 13 | 14 | const PostLikesPage = () => { 15 | const { did, rkey } = useParams<{ 16 | did: Did; 17 | rkey: RecordKey; 18 | }>(); 19 | 20 | const uri = makeAtUri(did, 'app.bsky.feed.post', rkey); 21 | const reposters = createSubjectRepostersQuery(() => uri); 22 | 23 | useTitle(() => `Users reposting this post — ${import.meta.env.VITE_APP_NAME}`); 24 | 25 | return ( 26 | <> 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | page.profiles)} 37 | error={reposters.error} 38 | render={(item) => { 39 | return ( 40 | 41 | } /> 42 | 43 | ); 44 | }} 45 | hasNextPage={reposters.hasNextPage} 46 | isFetchingNextPage={reposters.isFetching} 47 | onEndReached={() => reposters.fetchNextPage()} 48 | /> 49 | 50 | ); 51 | }; 52 | 53 | export default PostLikesPage; 54 | -------------------------------------------------------------------------------- /src/views/profile-lists.tsx: -------------------------------------------------------------------------------- 1 | import type { Did } from '@atcute/lexicons'; 2 | 3 | import { createProfileQuery } from '~/api/queries/profile'; 4 | import { createProfileListsQuery } from '~/api/queries/profile-lists'; 5 | 6 | import { useParams, useTitle } from '~/lib/navigation/router'; 7 | 8 | import ListItem from '~/components/lists/list-item'; 9 | import * as Page from '~/components/page'; 10 | import PagedList from '~/components/paged-list'; 11 | 12 | const ProfileListsPage = () => { 13 | const { did } = useParams<{ did: Did }>(); 14 | 15 | const lists = createProfileListsQuery(() => did); 16 | const profile = createProfileQuery(() => did); 17 | 18 | useTitle(() => { 19 | const data = profile.data; 20 | if (data) { 21 | const handle = data.handle.toLowerCase(); 22 | 23 | return `Lists by @${handle} — ${import.meta.env.VITE_APP_NAME}`; 24 | } 25 | 26 | return `Lists by user — ${import.meta.env.VITE_APP_NAME}`; 27 | }); 28 | 29 | return ( 30 | <> 31 | 32 | 33 | 34 | 35 | 36 | { 39 | const subject = profile.data; 40 | if (subject) { 41 | return '@' + subject.handle.toLowerCase(); 42 | } 43 | })()} 44 | /> 45 | 46 | 47 | page.lists)} 49 | error={lists.error} 50 | render={(item) => { 51 | return ; 52 | }} 53 | hasNextPage={lists.hasNextPage} 54 | isFetchingNextPage={lists.isFetching} 55 | onEndReached={() => lists.fetchNextPage()} 56 | /> 57 | 58 | ); 59 | }; 60 | 61 | export default ProfileListsPage; 62 | --------------------------------------------------------------------------------