├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── pl-api.yaml │ ├── pl-fe.yaml │ └── pl-hooks.yaml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmrc ├── .vscode ├── extensions.json ├── pl-fe.code-snippets └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── package.json ├── packages ├── pl-api │ ├── .eslintignore │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── lib │ │ ├── client.ts │ │ ├── directory-client.ts │ │ ├── entities │ │ │ ├── account-warning.ts │ │ │ ├── account.ts │ │ │ ├── admin │ │ │ │ ├── account.ts │ │ │ │ ├── announcement.ts │ │ │ │ ├── canonical-email-block.ts │ │ │ │ ├── cohort.ts │ │ │ │ ├── custom-emoji.ts │ │ │ │ ├── dimension.ts │ │ │ │ ├── domain-allow.ts │ │ │ │ ├── domain-block.ts │ │ │ │ ├── domain.ts │ │ │ │ ├── email-domain-block.ts │ │ │ │ ├── ip-block.ts │ │ │ │ ├── ip.ts │ │ │ │ ├── measure.ts │ │ │ │ ├── moderation-log-entry.ts │ │ │ │ ├── pleroma-config.ts │ │ │ │ ├── relay.ts │ │ │ │ ├── report.ts │ │ │ │ ├── rule.ts │ │ │ │ └── tag.ts │ │ │ ├── announcement-reaction.ts │ │ │ ├── announcement.ts │ │ │ ├── antenna.ts │ │ │ ├── application.ts │ │ │ ├── backup.ts │ │ │ ├── bookmark-folder.ts │ │ │ ├── chat-message.ts │ │ │ ├── chat.ts │ │ │ ├── circle.ts │ │ │ ├── context.ts │ │ │ ├── conversation.ts │ │ │ ├── custom-emoji.ts │ │ │ ├── directory │ │ │ │ ├── category.ts │ │ │ │ ├── language.ts │ │ │ │ ├── server.ts │ │ │ │ └── statistics-period.ts │ │ │ ├── domain-block.ts │ │ │ ├── drive-file.ts │ │ │ ├── drive-folder.ts │ │ │ ├── emoji-reaction.ts │ │ │ ├── extended-description.ts │ │ │ ├── familiar-followers.ts │ │ │ ├── featured-tag.ts │ │ │ ├── filter-result.ts │ │ │ ├── filter.ts │ │ │ ├── group-member.ts │ │ │ ├── group-relationship.ts │ │ │ ├── group.ts │ │ │ ├── grouped-notifications-results.ts │ │ │ ├── index.ts │ │ │ ├── instance.ts │ │ │ ├── interaction-policy.ts │ │ │ ├── interaction-request.ts │ │ │ ├── list.ts │ │ │ ├── location.ts │ │ │ ├── marker.ts │ │ │ ├── media-attachment.ts │ │ │ ├── mention.ts │ │ │ ├── notification-policy.ts │ │ │ ├── notification-request.ts │ │ │ ├── notification.ts │ │ │ ├── oauth-token.ts │ │ │ ├── poll.ts │ │ │ ├── preview-card-author.ts │ │ │ ├── preview-card.ts │ │ │ ├── privacy-policy.ts │ │ │ ├── quote.ts │ │ │ ├── relationship-severance-event.ts │ │ │ ├── relationship.ts │ │ │ ├── report.ts │ │ │ ├── role.ts │ │ │ ├── rss-feed.ts │ │ │ ├── rule.ts │ │ │ ├── scheduled-status.ts │ │ │ ├── scrobble.ts │ │ │ ├── search.ts │ │ │ ├── shout-message.ts │ │ │ ├── status-edit.ts │ │ │ ├── status-source.ts │ │ │ ├── status.ts │ │ │ ├── story-carousel-item.ts │ │ │ ├── story-media.ts │ │ │ ├── story-profile.ts │ │ │ ├── streaming-event.ts │ │ │ ├── subscription-details.ts │ │ │ ├── subscription-invoice.ts │ │ │ ├── subscription-option.ts │ │ │ ├── suggestion.ts │ │ │ ├── tag.ts │ │ │ ├── terms-of-service.ts │ │ │ ├── token.ts │ │ │ ├── translation.ts │ │ │ ├── trends-link.ts │ │ │ ├── utils.ts │ │ │ └── web-push-subscription.ts │ │ ├── features.ts │ │ ├── main.ts │ │ ├── params │ │ │ ├── accounts.ts │ │ │ ├── admin.ts │ │ │ ├── antennas.ts │ │ │ ├── apps.ts │ │ │ ├── chats.ts │ │ │ ├── circles.ts │ │ │ ├── common.ts │ │ │ ├── drive.ts │ │ │ ├── events.ts │ │ │ ├── filtering.ts │ │ │ ├── grouped-notifications.ts │ │ │ ├── groups.ts │ │ │ ├── index.ts │ │ │ ├── instance.ts │ │ │ ├── interaction-requests.ts │ │ │ ├── lists.ts │ │ │ ├── media.ts │ │ │ ├── my-account.ts │ │ │ ├── notifications.ts │ │ │ ├── oauth.ts │ │ │ ├── push-notifications.ts │ │ │ ├── scheduled-statuses.ts │ │ │ ├── search.ts │ │ │ ├── settings.ts │ │ │ ├── statuses.ts │ │ │ ├── stories.ts │ │ │ ├── timelines.ts │ │ │ └── trends.ts │ │ ├── request.ts │ │ ├── responses.ts │ │ └── utils │ │ │ └── url.ts │ ├── package.json │ ├── tsconfig-build.json │ ├── tsconfig.json │ ├── typedoc.config.mjs │ ├── vite.config.ts │ └── yarn.lock ├── pl-fe │ ├── .dockerignore │ ├── .editorconfig │ ├── .env.example │ ├── .eslintignore │ ├── .eslintrc.json │ ├── .gitattributes │ ├── .gitignore │ ├── .lintstagedrc.json │ ├── .stylelintrc.json │ ├── .tool-versions │ ├── CHANGELOG.md │ ├── Dockerfile │ ├── Dockerfile.dev │ ├── LICENSE │ ├── LICENSE-GPL-3.0 │ ├── README.md │ ├── app.json │ ├── compose-dev.yaml │ ├── custom │ │ ├── .gitkeep │ │ ├── instance │ │ │ └── .gitkeep │ │ ├── locales │ │ │ └── .gitkeep │ │ └── modules │ │ │ └── .gitkeep │ ├── favicon.ico │ ├── index.html │ ├── installation │ │ ├── docker.conf.template │ │ └── mastodon.conf │ ├── package.json │ ├── postcss.config.cjs │ ├── src │ │ ├── __fixtures__ │ │ │ ├── account-moved.json │ │ │ ├── account-with-emojis.json │ │ │ ├── accounts.json │ │ │ ├── accounts_counter_follow.json │ │ │ ├── accounts_counter_initial.json │ │ │ ├── accounts_counter_unfollow.json │ │ │ ├── admin_api_frontend_config.json │ │ │ ├── akkoma-instance.json │ │ │ ├── announcements.json │ │ │ ├── app.json │ │ │ ├── blocks.json │ │ │ ├── config_db.json │ │ │ ├── fedibird-account.json │ │ │ ├── fedibird-instance.json │ │ │ ├── fedibird-quote-of-quote-post.json │ │ │ ├── fedibird-quote-post.json │ │ │ ├── friendica-instance.json │ │ │ ├── friendica-status.json │ │ │ ├── gotosocial-account.json │ │ │ ├── gotosocial-instance.json │ │ │ ├── gotosocial-status.json │ │ │ ├── group-truthsocial.json │ │ │ ├── intlMessages.json │ │ │ ├── lain.json │ │ │ ├── markers.json │ │ │ ├── mastodon-3.0.0-instance.json │ │ │ ├── mastodon-account.json │ │ │ ├── mastodon-instance-rc.json │ │ │ ├── mastodon-instance.json │ │ │ ├── mastodon-reply-to-self.json │ │ │ ├── mastodon_initial_state.json │ │ │ ├── mitra-context.json │ │ │ ├── mitra-instance.json │ │ │ ├── mitra-status-with-attachments.json │ │ │ ├── mk.json │ │ │ ├── notification-favourite.json │ │ │ ├── notification-follow.json │ │ │ ├── notification-follow_request.json │ │ │ ├── notification-mention.json │ │ │ ├── notification-move.json │ │ │ ├── notification-pleroma-chat_mention.json │ │ │ ├── notification-pleroma-emoji_reaction.json │ │ │ ├── notification-poll.json │ │ │ ├── notification-reblog.json │ │ │ ├── notification.json │ │ │ ├── notifications.json │ │ │ ├── patron-instance.json │ │ │ ├── patron-user.json │ │ │ ├── pixelfed-instance.json │ │ │ ├── pl-fe.json │ │ │ ├── pleroma-2.2.2-account.json │ │ │ ├── pleroma-account.json │ │ │ ├── pleroma-admin-config.json │ │ │ ├── pleroma-instance.json │ │ │ ├── pleroma-notification-move.json │ │ │ ├── pleroma-quote-of-quote-post.json │ │ │ ├── pleroma-quote-post.json │ │ │ ├── pleroma-status-deleted.json │ │ │ ├── pleroma-status-reply-with-mentions.json │ │ │ ├── pleroma-status-vertical-video-without-metadata.json │ │ │ ├── pleroma-status-with-attachments.json │ │ │ ├── pleroma-status-with-poll-with-emojis.json │ │ │ ├── pleroma-status-with-poll.json │ │ │ ├── pleroma-status.json │ │ │ ├── pleroma_initial_results.json │ │ │ ├── relationship.json │ │ │ ├── rules.json │ │ │ ├── status-custom-emoji.json │ │ │ ├── status-cw.json │ │ │ ├── status-quotes.json │ │ │ ├── status-unordered-mentions.json │ │ │ ├── status-with-card.json │ │ │ ├── status-with-poll.json │ │ │ └── user.json │ │ ├── actions │ │ │ ├── account-notes.test.ts │ │ │ ├── account-notes.ts │ │ │ ├── accounts.test.ts │ │ │ ├── accounts.ts │ │ │ ├── admin.ts │ │ │ ├── aliases.ts │ │ │ ├── apps.ts │ │ │ ├── auth.ts │ │ │ ├── bookmarks.ts │ │ │ ├── chats.ts │ │ │ ├── circle.ts │ │ │ ├── compose.test.ts │ │ │ ├── compose.ts │ │ │ ├── consumer-auth.ts │ │ │ ├── conversations.ts │ │ │ ├── draft-statuses.ts │ │ │ ├── emoji-reacts.ts │ │ │ ├── events.ts │ │ │ ├── export-data.ts │ │ │ ├── external-auth.ts │ │ │ ├── favourites.ts │ │ │ ├── filters.ts │ │ │ ├── import-data.ts │ │ │ ├── importer.ts │ │ │ ├── instance.ts │ │ │ ├── interactions.ts │ │ │ ├── lists.ts │ │ │ ├── markers.ts │ │ │ ├── me.test.ts │ │ │ ├── me.ts │ │ │ ├── media.ts │ │ │ ├── mfa.ts │ │ │ ├── moderation.tsx │ │ │ ├── mrf.ts │ │ │ ├── notifications.test.ts │ │ │ ├── notifications.ts │ │ │ ├── oauth.ts │ │ │ ├── onboarding.test.ts │ │ │ ├── onboarding.ts │ │ │ ├── pin-statuses.ts │ │ │ ├── pl-fe.ts │ │ │ ├── polls.ts │ │ │ ├── preload.test.ts │ │ │ ├── preload.ts │ │ │ ├── push-notifications │ │ │ │ ├── registerer.ts │ │ │ │ └── setter.ts │ │ │ ├── push-subscriptions.ts │ │ │ ├── remote-timeline.ts │ │ │ ├── reports.ts │ │ │ ├── security.ts │ │ │ ├── settings.ts │ │ │ ├── shoutbox.ts │ │ │ ├── statuses.test.ts │ │ │ ├── statuses.ts │ │ │ └── timelines.ts │ │ ├── api │ │ │ ├── __mocks__ │ │ │ │ └── index.ts │ │ │ ├── hooks │ │ │ │ ├── accounts │ │ │ │ │ ├── use-account-lookup.ts │ │ │ │ │ ├── use-account.ts │ │ │ │ │ ├── use-follow.ts │ │ │ │ │ ├── use-relationship.ts │ │ │ │ │ └── use-relationships.ts │ │ │ │ ├── admin │ │ │ │ │ ├── use-suggest.ts │ │ │ │ │ └── use-verify.ts │ │ │ │ ├── groups │ │ │ │ │ ├── use-create-group.ts │ │ │ │ │ ├── use-delete-group.ts │ │ │ │ │ ├── use-demote-group-member.ts │ │ │ │ │ ├── use-group-membership-requests.ts │ │ │ │ │ ├── use-group-relationship.ts │ │ │ │ │ ├── use-group-relationships.ts │ │ │ │ │ ├── use-group.test.ts │ │ │ │ │ ├── use-group.ts │ │ │ │ │ ├── use-groups.test.ts │ │ │ │ │ ├── use-groups.ts │ │ │ │ │ ├── use-join-group.ts │ │ │ │ │ ├── use-leave-group.ts │ │ │ │ │ ├── use-promote-group-member.ts │ │ │ │ │ └── use-update-group.ts │ │ │ │ └── streaming │ │ │ │ │ ├── use-bubble-stream.ts │ │ │ │ │ ├── use-community-stream.ts │ │ │ │ │ ├── use-direct-stream.ts │ │ │ │ │ ├── use-group-stream.ts │ │ │ │ │ ├── use-hashtag-stream.ts │ │ │ │ │ ├── use-list-stream.ts │ │ │ │ │ ├── use-public-stream.ts │ │ │ │ │ ├── use-remote-stream.ts │ │ │ │ │ ├── use-timeline-stream.ts │ │ │ │ │ └── use-user-stream.ts │ │ │ └── index.ts │ │ ├── assets │ │ │ ├── images │ │ │ │ ├── audio-placeholder.png │ │ │ │ ├── avatar-missing.png │ │ │ │ ├── header-missing.png │ │ │ │ ├── video-placeholder.png │ │ │ │ └── web-push │ │ │ │ │ ├── web-push-icon_expand.png │ │ │ │ │ ├── web-push-icon_favourite.png │ │ │ │ │ └── web-push-icon_reblog.png │ │ │ └── sounds │ │ │ │ ├── LICENSE.md │ │ │ │ ├── boop.mp3 │ │ │ │ ├── boop.ogg │ │ │ │ ├── chat.mp3 │ │ │ │ └── chat.ogg │ │ ├── build-config-compiletime.ts │ │ ├── build-config.ts │ │ ├── components │ │ │ ├── account-hover-card.tsx │ │ │ ├── account-search.tsx │ │ │ ├── account.test.tsx │ │ │ ├── account.tsx │ │ │ ├── alt-indicator.tsx │ │ │ ├── animated-number.tsx │ │ │ ├── announcements │ │ │ │ ├── announcement-content.tsx │ │ │ │ ├── announcement.tsx │ │ │ │ ├── announcements-panel.tsx │ │ │ │ ├── emoji.tsx │ │ │ │ ├── reaction.tsx │ │ │ │ └── reactions-bar.tsx │ │ │ ├── attachment-thumbs.tsx │ │ │ ├── authorize-reject-buttons.tsx │ │ │ ├── autosuggest-account-input.tsx │ │ │ ├── autosuggest-emoji.test.tsx │ │ │ ├── autosuggest-emoji.tsx │ │ │ ├── autosuggest-input.tsx │ │ │ ├── autosuggest-location.tsx │ │ │ ├── avatar-stack.tsx │ │ │ ├── badge.test.tsx │ │ │ ├── badge.tsx │ │ │ ├── big-card.tsx │ │ │ ├── birthday-input.tsx │ │ │ ├── birthday-panel.tsx │ │ │ ├── blurhash.tsx │ │ │ ├── copyable-input.tsx │ │ │ ├── domain.tsx │ │ │ ├── dropdown-menu │ │ │ │ ├── dropdown-menu-item.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ └── index.ts │ │ │ ├── event-preview.tsx │ │ │ ├── extended-video-player.tsx │ │ │ ├── fork-awesome-icon.tsx │ │ │ ├── gdpr-banner.tsx │ │ │ ├── group-card.tsx │ │ │ ├── groups │ │ │ │ ├── group-avatar.tsx │ │ │ │ └── popover │ │ │ │ │ └── group-popover.tsx │ │ │ ├── hashtag-link.tsx │ │ │ ├── hashtag.tsx │ │ │ ├── hashtags-bar.tsx │ │ │ ├── helmet.tsx │ │ │ ├── hover-account-wrapper.tsx │ │ │ ├── hover-status-wrapper.tsx │ │ │ ├── icon-button.tsx │ │ │ ├── icon-with-counter.tsx │ │ │ ├── icon.tsx │ │ │ ├── landing-gradient.tsx │ │ │ ├── link.tsx │ │ │ ├── list.tsx │ │ │ ├── load-gap.tsx │ │ │ ├── load-more.tsx │ │ │ ├── loading-screen.tsx │ │ │ ├── location-search.tsx │ │ │ ├── markup.tsx │ │ │ ├── media-gallery.tsx │ │ │ ├── mention.tsx │ │ │ ├── missing-indicator.tsx │ │ │ ├── modal-root.tsx │ │ │ ├── navlinks.tsx │ │ │ ├── outline-box.tsx │ │ │ ├── parsed-content.tsx │ │ │ ├── parsed-mfm.tsx │ │ │ ├── pending-items-row.tsx │ │ │ ├── polls │ │ │ │ ├── poll-footer.test.tsx │ │ │ │ ├── poll-footer.tsx │ │ │ │ ├── poll-option.tsx │ │ │ │ └── poll.tsx │ │ │ ├── preview-card.tsx │ │ │ ├── progress-circle.tsx │ │ │ ├── pull-to-refresh.tsx │ │ │ ├── quoted-status-indicator.tsx │ │ │ ├── quoted-status.test.tsx │ │ │ ├── quoted-status.tsx │ │ │ ├── radio.tsx │ │ │ ├── relative-timestamp.tsx │ │ │ ├── safe-embed.tsx │ │ │ ├── scrobble.tsx │ │ │ ├── scroll-top-button.test.tsx │ │ │ ├── scroll-top-button.tsx │ │ │ ├── scrollable-list.tsx │ │ │ ├── search-input.tsx │ │ │ ├── sentry-feedback-form.tsx │ │ │ ├── sidebar-menu.tsx │ │ │ ├── sidebar-navigation-link.tsx │ │ │ ├── sidebar-navigation.tsx │ │ │ ├── site-error-boundary.tsx │ │ │ ├── site-logo.tsx │ │ │ ├── status-action-bar.tsx │ │ │ ├── status-action-button.tsx │ │ │ ├── status-content.tsx │ │ │ ├── status-hover-card.tsx │ │ │ ├── status-language-picker.tsx │ │ │ ├── status-list.tsx │ │ │ ├── status-media.tsx │ │ │ ├── status-mention.tsx │ │ │ ├── status-reactions-bar.tsx │ │ │ ├── status-reply-mentions.tsx │ │ │ ├── status.test.tsx │ │ │ ├── status.tsx │ │ │ ├── statuses │ │ │ │ ├── sensitive-content-overlay.test.tsx │ │ │ │ ├── sensitive-content-overlay.tsx │ │ │ │ └── status-info.tsx │ │ │ ├── still-image.tsx │ │ │ ├── thumb-navigation-link.tsx │ │ │ ├── thumb-navigation.tsx │ │ │ ├── tombstone.tsx │ │ │ ├── translate-button.tsx │ │ │ ├── trending-link.tsx │ │ │ ├── ui │ │ │ │ ├── accordion.tsx │ │ │ │ ├── avatar.test.tsx │ │ │ │ ├── avatar.tsx │ │ │ │ ├── banner.tsx │ │ │ │ ├── button │ │ │ │ │ ├── index.test.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── useButtonStyles.ts │ │ │ │ ├── card.test.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── column.test.tsx │ │ │ │ ├── column.tsx │ │ │ │ ├── combobox.css │ │ │ │ ├── combobox.tsx │ │ │ │ ├── counter.tsx │ │ │ │ ├── divider.test.tsx │ │ │ │ ├── divider.tsx │ │ │ │ ├── emoji.test.tsx │ │ │ │ ├── emoji.tsx │ │ │ │ ├── file-input.tsx │ │ │ │ ├── form-actions.test.tsx │ │ │ │ ├── form-actions.tsx │ │ │ │ ├── form-group.test.tsx │ │ │ │ ├── form-group.tsx │ │ │ │ ├── form.test.tsx │ │ │ │ ├── form.tsx │ │ │ │ ├── hstack.tsx │ │ │ │ ├── icon-button.tsx │ │ │ │ ├── icon.tsx │ │ │ │ ├── inline-multiselect.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── modal.test.tsx │ │ │ │ ├── modal.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── portal.tsx │ │ │ │ ├── progress-bar.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── slider.tsx │ │ │ │ ├── spinner.css │ │ │ │ ├── spinner.tsx │ │ │ │ ├── stack.tsx │ │ │ │ ├── step-slider.tsx │ │ │ │ ├── streamfield.tsx │ │ │ │ ├── svg-icon.test.tsx │ │ │ │ ├── svg-icon.tsx │ │ │ │ ├── tabs.css │ │ │ │ ├── tabs.tsx │ │ │ │ ├── tag-input.tsx │ │ │ │ ├── tag.tsx │ │ │ │ ├── text.tsx │ │ │ │ ├── textarea.tsx │ │ │ │ ├── toast.tsx │ │ │ │ ├── toggle.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ └── widget.tsx │ │ │ ├── upload-progress.tsx │ │ │ ├── upload.tsx │ │ │ └── verification-badge.tsx │ │ ├── containers │ │ │ ├── account-container.tsx │ │ │ └── status-container.tsx │ │ ├── contexts │ │ │ ├── chat-context.tsx │ │ │ └── stat-context.tsx │ │ ├── custom.ts │ │ ├── entity-store │ │ │ ├── actions.ts │ │ │ ├── entities.ts │ │ │ ├── hooks │ │ │ │ ├── types.ts │ │ │ │ ├── use-batched-entities.ts │ │ │ │ ├── use-create-entity.ts │ │ │ │ ├── use-delete-entity.ts │ │ │ │ ├── use-dismiss-entity.ts │ │ │ │ ├── use-entities.ts │ │ │ │ ├── use-entity-lookup.ts │ │ │ │ ├── use-entity.ts │ │ │ │ ├── use-transaction.ts │ │ │ │ └── utils.ts │ │ │ ├── reducer.test.ts │ │ │ ├── reducer.ts │ │ │ ├── selectors.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── features │ │ │ ├── account │ │ │ │ └── components │ │ │ │ │ └── header.tsx │ │ │ ├── admin │ │ │ │ ├── components │ │ │ │ │ ├── admin-tabs.tsx │ │ │ │ │ ├── counter.tsx │ │ │ │ │ ├── dashcounter.tsx │ │ │ │ │ ├── dimension.tsx │ │ │ │ │ ├── latest-accounts-panel.tsx │ │ │ │ │ ├── registration-mode-picker.tsx │ │ │ │ │ ├── report-status.tsx │ │ │ │ │ ├── report.tsx │ │ │ │ │ ├── retention.tsx │ │ │ │ │ └── unapproved-account.tsx │ │ │ │ └── tabs │ │ │ │ │ ├── awaiting-approval.tsx │ │ │ │ │ ├── dashboard.tsx │ │ │ │ │ └── reports.tsx │ │ │ ├── audio │ │ │ │ ├── index.tsx │ │ │ │ └── visualizer.ts │ │ │ ├── auth-login │ │ │ │ └── components │ │ │ │ │ ├── captcha.test.tsx │ │ │ │ │ ├── captcha.tsx │ │ │ │ │ ├── consumer-button.tsx │ │ │ │ │ ├── consumers-list.tsx │ │ │ │ │ ├── login-form.test.tsx │ │ │ │ │ ├── login-form.tsx │ │ │ │ │ ├── otp-auth-form.test.tsx │ │ │ │ │ ├── otp-auth-form.tsx │ │ │ │ │ └── registration-form.tsx │ │ │ ├── birthdays │ │ │ │ ├── account.tsx │ │ │ │ └── date-picker.ts │ │ │ ├── chats │ │ │ │ └── components │ │ │ │ │ ├── chat-composer.tsx │ │ │ │ │ ├── chat-list-item.test.tsx │ │ │ │ │ ├── chat-list-item.tsx │ │ │ │ │ ├── chat-list-shoutbox.tsx │ │ │ │ │ ├── chat-list.tsx │ │ │ │ │ ├── chat-message-list.test.tsx │ │ │ │ │ ├── chat-message-list.tsx │ │ │ │ │ ├── chat-message.tsx │ │ │ │ │ ├── chat-page │ │ │ │ │ ├── chat-page.tsx │ │ │ │ │ └── components │ │ │ │ │ │ ├── blankslate-empty.tsx │ │ │ │ │ │ ├── blankslate-with-chats.tsx │ │ │ │ │ │ ├── chat-page-main.tsx │ │ │ │ │ │ ├── chat-page-new.tsx │ │ │ │ │ │ ├── chat-page-settings.tsx │ │ │ │ │ │ ├── chat-page-shoutbox.tsx │ │ │ │ │ │ └── chat-page-sidebar.tsx │ │ │ │ │ ├── chat-pane-header.test.tsx │ │ │ │ │ ├── chat-pane │ │ │ │ │ ├── blankslate.tsx │ │ │ │ │ ├── chat-pane.test.tsx │ │ │ │ │ └── chat-pane.tsx │ │ │ │ │ ├── chat-pending-upload.tsx │ │ │ │ │ ├── chat-search │ │ │ │ │ ├── blankslate.tsx │ │ │ │ │ ├── chat-search.test.tsx │ │ │ │ │ ├── chat-search.tsx │ │ │ │ │ ├── empty-results-blankslate.tsx │ │ │ │ │ └── results.tsx │ │ │ │ │ ├── chat-textarea.tsx │ │ │ │ │ ├── chat-upload-preview.tsx │ │ │ │ │ ├── chat-upload.tsx │ │ │ │ │ ├── chat-widget.test.tsx │ │ │ │ │ ├── chat-widget │ │ │ │ │ ├── chat-pane-header.tsx │ │ │ │ │ ├── chat-settings.tsx │ │ │ │ │ ├── chat-widget.tsx │ │ │ │ │ ├── chat-window.tsx │ │ │ │ │ ├── headers │ │ │ │ │ │ └── chat-search-header.tsx │ │ │ │ │ └── shoutbox-window.tsx │ │ │ │ │ ├── chat.tsx │ │ │ │ │ ├── shoutbox-composer.tsx │ │ │ │ │ ├── shoutbox-message-list.tsx │ │ │ │ │ ├── shoutbox.tsx │ │ │ │ │ └── ui │ │ │ │ │ └── pane.tsx │ │ │ ├── compose-event │ │ │ │ ├── components │ │ │ │ │ └── upload-button.tsx │ │ │ │ └── tabs │ │ │ │ │ ├── edit-event.tsx │ │ │ │ │ └── manage-pending-participants.tsx │ │ │ ├── compose │ │ │ │ ├── components │ │ │ │ │ ├── autosuggest-account.tsx │ │ │ │ │ ├── clear-link-suggestion.tsx │ │ │ │ │ ├── compose-form-button.tsx │ │ │ │ │ ├── compose-form.tsx │ │ │ │ │ ├── content-type-button.tsx │ │ │ │ │ ├── interaction-policy-button.tsx │ │ │ │ │ ├── language-dropdown.tsx │ │ │ │ │ ├── poll-button.tsx │ │ │ │ │ ├── polls │ │ │ │ │ │ ├── duration-selector.test.tsx │ │ │ │ │ │ ├── duration-selector.tsx │ │ │ │ │ │ └── poll-form.tsx │ │ │ │ │ ├── privacy-dropdown.tsx │ │ │ │ │ ├── reply-group-indicator.tsx │ │ │ │ │ ├── reply-indicator.tsx │ │ │ │ │ ├── reply-mentions.tsx │ │ │ │ │ ├── schedule-button.tsx │ │ │ │ │ ├── schedule-form.tsx │ │ │ │ │ ├── sensitive-media-button.tsx │ │ │ │ │ ├── spoiler-input.tsx │ │ │ │ │ ├── text-character-counter.tsx │ │ │ │ │ ├── upload-button.tsx │ │ │ │ │ ├── upload-form.tsx │ │ │ │ │ ├── upload-progress.tsx │ │ │ │ │ ├── upload.tsx │ │ │ │ │ ├── visual-character-counter.tsx │ │ │ │ │ └── warning.tsx │ │ │ │ ├── containers │ │ │ │ │ ├── preview-compose-container.tsx │ │ │ │ │ ├── quoted-status-container.tsx │ │ │ │ │ ├── reply-indicator-container.tsx │ │ │ │ │ ├── upload-button-container.tsx │ │ │ │ │ └── warning-container.tsx │ │ │ │ ├── editor │ │ │ │ │ ├── LICENSE │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── nodes │ │ │ │ │ │ ├── emoji-node.tsx │ │ │ │ │ │ ├── image-component.tsx │ │ │ │ │ │ ├── image-node.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── mention-node.tsx │ │ │ │ │ ├── plugins │ │ │ │ │ │ ├── autosuggest-plugin.tsx │ │ │ │ │ │ ├── floating-block-type-toolbar-plugin.tsx │ │ │ │ │ │ ├── floating-link-editor-plugin.tsx │ │ │ │ │ │ ├── floating-text-format-toolbar-plugin.tsx │ │ │ │ │ │ ├── focus-plugin.tsx │ │ │ │ │ │ ├── link-plugin.tsx │ │ │ │ │ │ ├── ref-plugin.tsx │ │ │ │ │ │ ├── state-plugin.tsx │ │ │ │ │ │ └── submit-plugin.tsx │ │ │ │ │ ├── transformers │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── utils │ │ │ │ │ │ ├── get-dom-range-rect.ts │ │ │ │ │ │ ├── get-selected-node.ts │ │ │ │ │ │ ├── set-floating-elem-position.ts │ │ │ │ │ │ └── url.ts │ │ │ │ └── util │ │ │ │ │ ├── counter.ts │ │ │ │ │ └── url-regex.ts │ │ │ ├── conversations │ │ │ │ └── components │ │ │ │ │ ├── conversation.tsx │ │ │ │ │ └── conversations-list.tsx │ │ │ ├── crypto-donate │ │ │ │ ├── components │ │ │ │ │ ├── crypto-address.tsx │ │ │ │ │ ├── crypto-donate-panel.tsx │ │ │ │ │ ├── crypto-icon.tsx │ │ │ │ │ ├── detailed-crypto-address.tsx │ │ │ │ │ ├── lightning-address.tsx │ │ │ │ │ └── site-wallet.tsx │ │ │ │ └── utils │ │ │ │ │ ├── block-explorer.ts │ │ │ │ │ ├── block-explorers.json │ │ │ │ │ ├── coin-db.ts │ │ │ │ │ └── manifest-map.ts │ │ │ ├── developers │ │ │ │ └── components │ │ │ │ │ └── indicator.tsx │ │ │ ├── draft-statuses │ │ │ │ ├── builder.tsx │ │ │ │ └── components │ │ │ │ │ ├── draft-status-action-bar.tsx │ │ │ │ │ └── draft-status.tsx │ │ │ ├── edit-profile │ │ │ │ └── components │ │ │ │ │ ├── avatar-picker.tsx │ │ │ │ │ └── header-picker.tsx │ │ │ ├── embedded-status │ │ │ │ └── index.tsx │ │ │ ├── emoji │ │ │ │ ├── components │ │ │ │ │ ├── emoji-picker-dropdown.tsx │ │ │ │ │ └── emoji-picker.tsx │ │ │ │ ├── containers │ │ │ │ │ └── emoji-picker-dropdown-container.tsx │ │ │ │ ├── data.ts │ │ │ │ ├── emojify.tsx │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mapping-compiletime.ts │ │ │ │ ├── mapping.ts │ │ │ │ ├── search.test.ts │ │ │ │ └── search.ts │ │ │ ├── event │ │ │ │ └── components │ │ │ │ │ ├── event-action-button.tsx │ │ │ │ │ ├── event-date.tsx │ │ │ │ │ └── event-header.tsx │ │ │ ├── external-login │ │ │ │ └── components │ │ │ │ │ └── external-login-form.tsx │ │ │ ├── federation-restrictions │ │ │ │ └── components │ │ │ │ │ ├── instance-restrictions.tsx │ │ │ │ │ └── restricted-instance.tsx │ │ │ ├── forms │ │ │ │ └── index.tsx │ │ │ ├── group │ │ │ │ └── components │ │ │ │ │ ├── group-action-button.test.tsx │ │ │ │ │ ├── group-action-button.tsx │ │ │ │ │ ├── group-header-image.tsx │ │ │ │ │ ├── group-header.test.tsx │ │ │ │ │ ├── group-header.tsx │ │ │ │ │ ├── group-member-count.test.tsx │ │ │ │ │ ├── group-member-count.tsx │ │ │ │ │ ├── group-member-list-item.test.tsx │ │ │ │ │ ├── group-member-list-item.tsx │ │ │ │ │ ├── group-options-button.test.tsx │ │ │ │ │ ├── group-options-button.tsx │ │ │ │ │ ├── group-privacy.test.tsx │ │ │ │ │ ├── group-privacy.tsx │ │ │ │ │ ├── group-relationship.test.tsx │ │ │ │ │ └── group-relationship.tsx │ │ │ ├── groups │ │ │ │ └── components │ │ │ │ │ └── discover │ │ │ │ │ ├── group-list-item.test.tsx │ │ │ │ │ └── group-list-item.tsx │ │ │ ├── notifications │ │ │ │ └── components │ │ │ │ │ ├── notification.tsx │ │ │ │ │ └── notifications.test.tsx │ │ │ ├── onboarding │ │ │ │ ├── onboarding-wizard.tsx │ │ │ │ └── steps │ │ │ │ │ ├── avatar-selection-step.tsx │ │ │ │ │ ├── bio-step.tsx │ │ │ │ │ ├── completed-step.tsx │ │ │ │ │ ├── cover-photo-selection-step.tsx │ │ │ │ │ ├── display-name-step.tsx │ │ │ │ │ ├── fediverse-step.tsx │ │ │ │ │ └── suggested-accounts-step.tsx │ │ │ ├── pl-fe-config │ │ │ │ ├── components │ │ │ │ │ ├── color-picker.tsx │ │ │ │ │ ├── crypto-address-input.tsx │ │ │ │ │ ├── footer-link-input.tsx │ │ │ │ │ ├── icon-picker-dropdown.tsx │ │ │ │ │ ├── icon-picker-menu.tsx │ │ │ │ │ ├── icon-picker.tsx │ │ │ │ │ ├── promo-panel-input.tsx │ │ │ │ │ └── site-preview.tsx │ │ │ │ └── forkawesome.json │ │ │ ├── placeholder │ │ │ │ ├── components │ │ │ │ │ ├── placeholder-account.tsx │ │ │ │ │ ├── placeholder-avatar.tsx │ │ │ │ │ ├── placeholder-card.tsx │ │ │ │ │ ├── placeholder-chat-message.tsx │ │ │ │ │ ├── placeholder-chat.tsx │ │ │ │ │ ├── placeholder-display-name.tsx │ │ │ │ │ ├── placeholder-event-header.tsx │ │ │ │ │ ├── placeholder-event-preview.tsx │ │ │ │ │ ├── placeholder-group-card.tsx │ │ │ │ │ ├── placeholder-group-search.tsx │ │ │ │ │ ├── placeholder-hashtag.tsx │ │ │ │ │ ├── placeholder-media-gallery.tsx │ │ │ │ │ ├── placeholder-notification.tsx │ │ │ │ │ ├── placeholder-sidebar-suggestions.tsx │ │ │ │ │ ├── placeholder-sidebar-trends.tsx │ │ │ │ │ ├── placeholder-status-content.tsx │ │ │ │ │ └── placeholder-status.tsx │ │ │ │ └── utils.ts │ │ │ ├── preferences │ │ │ │ └── index.tsx │ │ │ ├── remote-timeline │ │ │ │ └── components │ │ │ │ │ └── pinned-hosts-picker.tsx │ │ │ ├── reply-mentions │ │ │ │ └── account.tsx │ │ │ ├── scheduled-statuses │ │ │ │ ├── builder.tsx │ │ │ │ └── components │ │ │ │ │ ├── scheduled-status-action-bar.tsx │ │ │ │ │ └── scheduled-status.tsx │ │ │ ├── security │ │ │ │ ├── mfa-form.tsx │ │ │ │ └── mfa │ │ │ │ │ ├── disable-otp-form.tsx │ │ │ │ │ ├── enable-otp-form.tsx │ │ │ │ │ └── otp-confirm-form.tsx │ │ │ ├── settings │ │ │ │ └── components │ │ │ │ │ ├── messages-settings.tsx │ │ │ │ │ └── setting-toggle.tsx │ │ │ ├── status │ │ │ │ ├── components │ │ │ │ │ ├── detailed-status.tsx │ │ │ │ │ ├── status-interaction-bar.tsx │ │ │ │ │ ├── status-type-icon.tsx │ │ │ │ │ ├── thread-login-cta.tsx │ │ │ │ │ ├── thread-status.tsx │ │ │ │ │ └── thread.tsx │ │ │ │ └── containers │ │ │ │ │ └── quoted-status-container.tsx │ │ │ ├── theme-editor │ │ │ │ └── components │ │ │ │ │ ├── color.tsx │ │ │ │ │ └── palette.tsx │ │ │ ├── ui │ │ │ │ ├── components │ │ │ │ │ ├── action-button.tsx │ │ │ │ │ ├── background-shapes.tsx │ │ │ │ │ ├── column-forbidden.tsx │ │ │ │ │ ├── column-loading.tsx │ │ │ │ │ ├── compose-button.test.tsx │ │ │ │ │ ├── compose-button.tsx │ │ │ │ │ ├── error-column.tsx │ │ │ │ │ ├── hotkeys.tsx │ │ │ │ │ ├── image-loader.tsx │ │ │ │ │ ├── link-footer.tsx │ │ │ │ │ ├── modal-loading.tsx │ │ │ │ │ ├── modal-root.tsx │ │ │ │ │ ├── panels │ │ │ │ │ │ ├── account-note-panel.tsx │ │ │ │ │ │ ├── group-media-panel.tsx │ │ │ │ │ │ ├── instance-info-panel.tsx │ │ │ │ │ │ ├── instance-moderation-panel.tsx │ │ │ │ │ │ ├── my-groups-panel.tsx │ │ │ │ │ │ ├── new-event-panel.tsx │ │ │ │ │ │ ├── new-group-panel.tsx │ │ │ │ │ │ ├── pinned-accounts-panel.tsx │ │ │ │ │ │ ├── profile-fields-panel.tsx │ │ │ │ │ │ ├── profile-info-panel.tsx │ │ │ │ │ │ ├── profile-media-panel.tsx │ │ │ │ │ │ ├── promo-panel.tsx │ │ │ │ │ │ ├── sign-up-panel.test.tsx │ │ │ │ │ │ ├── sign-up-panel.tsx │ │ │ │ │ │ ├── trends-panel.test.tsx │ │ │ │ │ │ ├── trends-panel.tsx │ │ │ │ │ │ ├── user-panel.tsx │ │ │ │ │ │ └── who-to-follow-panel.tsx │ │ │ │ │ ├── pending-status.tsx │ │ │ │ │ ├── poll-preview.tsx │ │ │ │ │ ├── profile-dropdown.tsx │ │ │ │ │ ├── profile-familiar-followers.tsx │ │ │ │ │ ├── profile-field.tsx │ │ │ │ │ ├── profile-stats.tsx │ │ │ │ │ ├── subscribe-button.test.tsx │ │ │ │ │ ├── subscription-button.tsx │ │ │ │ │ ├── theme-selector.tsx │ │ │ │ │ ├── theme-toggle.tsx │ │ │ │ │ ├── timeline.tsx │ │ │ │ │ └── zoomable-image.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── util │ │ │ │ │ ├── async-components.ts │ │ │ │ │ ├── fullscreen.ts │ │ │ │ │ ├── global-hotkeys.tsx │ │ │ │ │ ├── optional-motion.tsx │ │ │ │ │ ├── pending-status-builder.ts │ │ │ │ │ ├── react-router-helpers.tsx │ │ │ │ │ └── reduced-motion.tsx │ │ │ └── video │ │ │ │ └── index.tsx │ │ ├── hooks │ │ │ ├── __mocks__ │ │ │ │ └── resize-observer.ts │ │ │ ├── forms │ │ │ │ ├── use-image-field.ts │ │ │ │ ├── use-preview.ts │ │ │ │ └── use-text-field.ts │ │ │ ├── use-account-gallery.ts │ │ │ ├── use-app-dispatch.ts │ │ │ ├── use-app-selector.ts │ │ │ ├── use-can-interact.ts │ │ │ ├── use-click-outside.ts │ │ │ ├── use-client.ts │ │ │ ├── use-compose.ts │ │ │ ├── use-debounce.ts │ │ │ ├── use-dimensions.test.ts │ │ │ ├── use-dimensions.ts │ │ │ ├── use-dragged-files.ts │ │ │ ├── use-features.ts │ │ │ ├── use-get-state.ts │ │ │ ├── use-instance.ts │ │ │ ├── use-is-mobile.ts │ │ │ ├── use-loading.ts │ │ │ ├── use-locale.ts │ │ │ ├── use-logged-in.ts │ │ │ ├── use-logo.ts │ │ │ ├── use-long-press.ts │ │ │ ├── use-own-account.ts │ │ │ ├── use-pl-fe-config.ts │ │ │ ├── use-previous.ts │ │ │ ├── use-registration-status.ts │ │ │ ├── use-screen-width.ts │ │ │ ├── use-settings.ts │ │ │ ├── use-system-theme.ts │ │ │ ├── use-theme-css.ts │ │ │ └── use-theme.ts │ │ ├── iframe.ts │ │ ├── init │ │ │ ├── pl-fe-head.tsx │ │ │ ├── pl-fe-load.tsx │ │ │ ├── pl-fe-mount.tsx │ │ │ └── pl-fe.tsx │ │ ├── instance │ │ │ ├── about.example │ │ │ │ ├── dmca.html │ │ │ │ ├── index.html │ │ │ │ ├── privacy.html │ │ │ │ └── tos.html │ │ │ ├── images │ │ │ │ ├── logo.png │ │ │ │ └── shortcuts │ │ │ │ │ ├── chats.png │ │ │ │ │ ├── notifications.png │ │ │ │ │ └── search.png │ │ │ └── pl-fe.example.json │ │ ├── is-mobile.ts │ │ ├── jest │ │ │ ├── factory.ts │ │ │ ├── fixtures │ │ │ │ └── chats.json │ │ │ ├── mock-stores.tsx │ │ │ ├── test-helpers.tsx │ │ │ └── test-setup.ts │ │ ├── layouts │ │ │ ├── admin-layout.tsx │ │ │ ├── chats-layout.tsx │ │ │ ├── default-layout.tsx │ │ │ ├── empty-layout.tsx │ │ │ ├── event-layout.tsx │ │ │ ├── events-layout.tsx │ │ │ ├── external-login-layout.tsx │ │ │ ├── group-layout.tsx │ │ │ ├── groups-layout.tsx │ │ │ ├── home-layout.tsx │ │ │ ├── landing-layout.tsx │ │ │ ├── manage-groups-layout.tsx │ │ │ ├── profile-layout.tsx │ │ │ ├── remote-instance-layout.tsx │ │ │ ├── search-layout.tsx │ │ │ └── status-layout.tsx │ │ ├── locales │ │ │ ├── ar.json │ │ │ ├── ast.json │ │ │ ├── bg.json │ │ │ ├── bn.json │ │ │ ├── br.json │ │ │ ├── bs.json │ │ │ ├── ca.json │ │ │ ├── co.json │ │ │ ├── cs.json │ │ │ ├── cy.json │ │ │ ├── da.json │ │ │ ├── de.json │ │ │ ├── el.json │ │ │ ├── en-Shaw.json │ │ │ ├── en.json │ │ │ ├── eo.json │ │ │ ├── es-AR.json │ │ │ ├── es.json │ │ │ ├── et.json │ │ │ ├── eu.json │ │ │ ├── fa.json │ │ │ ├── fi.json │ │ │ ├── fr.json │ │ │ ├── ga.json │ │ │ ├── gl.json │ │ │ ├── he.json │ │ │ ├── hi.json │ │ │ ├── hr.json │ │ │ ├── hu.json │ │ │ ├── hy.json │ │ │ ├── id.json │ │ │ ├── io.json │ │ │ ├── is.json │ │ │ ├── it.json │ │ │ ├── ja.json │ │ │ ├── jv.json │ │ │ ├── ka.json │ │ │ ├── kk.json │ │ │ ├── ko.json │ │ │ ├── lt.json │ │ │ ├── lv.json │ │ │ ├── mk.json │ │ │ ├── ms.json │ │ │ ├── nl.json │ │ │ ├── nn.json │ │ │ ├── no.json │ │ │ ├── oc.json │ │ │ ├── pl.json │ │ │ ├── pt-BR.json │ │ │ ├── pt.json │ │ │ ├── ro.json │ │ │ ├── ru.json │ │ │ ├── sk.json │ │ │ ├── sl.json │ │ │ ├── sq.json │ │ │ ├── sr-Latn.json │ │ │ ├── sr.json │ │ │ ├── sv.json │ │ │ ├── ta.json │ │ │ ├── te.json │ │ │ ├── th.json │ │ │ ├── tr.json │ │ │ ├── uk.json │ │ │ ├── zh-CN.json │ │ │ ├── zh-HK.json │ │ │ └── zh-TW.json │ │ ├── main.tsx │ │ ├── messages.ts │ │ ├── middleware │ │ │ ├── errors.ts │ │ │ └── sounds.ts │ │ ├── modals │ │ │ ├── account-moderation-modal │ │ │ │ ├── badge-input.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── staff-role-picker.tsx │ │ │ ├── alt-text-modal.tsx │ │ │ ├── birthdays-modal.tsx │ │ │ ├── boost-modal.tsx │ │ │ ├── compare-history-modal.tsx │ │ │ ├── component-modal.tsx │ │ │ ├── compose-interaction-policy-modal.tsx │ │ │ ├── compose-modal.tsx │ │ │ ├── confirmation-modal.tsx │ │ │ ├── crypto-donate-modal.tsx │ │ │ ├── dislikes-modal.tsx │ │ │ ├── dropdown-menu-modal.tsx │ │ │ ├── edit-announcement-modal.tsx │ │ │ ├── edit-bookmark-folder-modal.tsx │ │ │ ├── edit-domain-modal.tsx │ │ │ ├── edit-federation-modal.tsx │ │ │ ├── edit-rule-modal.tsx │ │ │ ├── embed-modal.tsx │ │ │ ├── event-map-modal.tsx │ │ │ ├── event-participants-modal.tsx │ │ │ ├── familiar-followers-modal.tsx │ │ │ ├── favourites-modal.tsx │ │ │ ├── hotkeys-modal.tsx │ │ │ ├── join-event-modal.tsx │ │ │ ├── list-adder-modal │ │ │ │ ├── components │ │ │ │ │ └── list.tsx │ │ │ │ └── index.tsx │ │ │ ├── list-editor-modal │ │ │ │ ├── components │ │ │ │ │ ├── account.tsx │ │ │ │ │ ├── edit-list-form.tsx │ │ │ │ │ └── search.tsx │ │ │ │ └── index.tsx │ │ │ ├── manage-group-modal │ │ │ │ ├── index.tsx │ │ │ │ └── steps │ │ │ │ │ ├── confirmation-step.tsx │ │ │ │ │ └── details-step.tsx │ │ │ ├── media-modal.tsx │ │ │ ├── mentions-modal.tsx │ │ │ ├── missing-description-modal.tsx │ │ │ ├── mute-modal.tsx │ │ │ ├── reactions-modal.tsx │ │ │ ├── reblogs-modal.tsx │ │ │ ├── reply-mentions-modal.tsx │ │ │ ├── report-modal │ │ │ │ ├── components │ │ │ │ │ └── status-check-box.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── steps │ │ │ │ │ ├── confirmation-step.tsx │ │ │ │ │ ├── other-actions-step.tsx │ │ │ │ │ └── reason-step.tsx │ │ │ ├── select-bookmark-folder-modal.tsx │ │ │ ├── text-field-modal.tsx │ │ │ ├── unauthorized-modal.test.tsx │ │ │ ├── unauthorized-modal.tsx │ │ │ └── video-modal.tsx │ │ ├── normalizers │ │ │ ├── account.ts │ │ │ ├── admin-report.ts │ │ │ ├── chat-message.ts │ │ │ ├── group-member.ts │ │ │ ├── group.ts │ │ │ ├── notification.ts │ │ │ ├── pl-fe │ │ │ │ ├── pl-fe-config.test.ts │ │ │ │ └── pl-fe-config.ts │ │ │ └── status.ts │ │ ├── pages │ │ │ ├── account-lists │ │ │ │ ├── circles.tsx │ │ │ │ ├── directory.tsx │ │ │ │ ├── follow-recommendations.tsx │ │ │ │ ├── follow-requests.tsx │ │ │ │ ├── followers.tsx │ │ │ │ ├── following.tsx │ │ │ │ ├── lists.tsx │ │ │ │ └── outgoing-follow-requests.tsx │ │ │ ├── accounts │ │ │ │ ├── account-gallery.tsx │ │ │ │ └── account-timeline.tsx │ │ │ ├── auth │ │ │ │ ├── external-login.tsx │ │ │ │ ├── login.test.tsx │ │ │ │ ├── login.tsx │ │ │ │ ├── logout.tsx │ │ │ │ ├── password-reset.tsx │ │ │ │ ├── register-with-invite.tsx │ │ │ │ └── registration.tsx │ │ │ ├── chats │ │ │ │ └── chats.tsx │ │ │ ├── dashboard │ │ │ │ ├── announcements.tsx │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── domains.tsx │ │ │ │ ├── moderation-log.tsx │ │ │ │ ├── pl-fe-config.tsx │ │ │ │ ├── relays.tsx │ │ │ │ ├── rules.tsx │ │ │ │ ├── theme-editor.tsx │ │ │ │ └── user-index.tsx │ │ │ ├── developers │ │ │ │ ├── create-app.tsx │ │ │ │ ├── developers.tsx │ │ │ │ ├── service-worker-info.tsx │ │ │ │ └── settings-store.tsx │ │ │ ├── fun │ │ │ │ └── circle.tsx │ │ │ ├── groups │ │ │ │ ├── edit-group.tsx │ │ │ │ ├── group-blocked-members.tsx │ │ │ │ ├── group-gallery.tsx │ │ │ │ ├── group-members.tsx │ │ │ │ ├── group-membership-requests.tsx │ │ │ │ ├── groups.tsx │ │ │ │ └── manage-group.tsx │ │ │ ├── notifications │ │ │ │ └── notifications.tsx │ │ │ ├── search │ │ │ │ └── search.tsx │ │ │ ├── settings │ │ │ │ ├── aliases.tsx │ │ │ │ ├── auth-token-list.tsx │ │ │ │ ├── backups.tsx │ │ │ │ ├── blocks.tsx │ │ │ │ ├── delete-account.tsx │ │ │ │ ├── domain-blocks.tsx │ │ │ │ ├── edit-email.tsx │ │ │ │ ├── edit-filter.tsx │ │ │ │ ├── edit-password.tsx │ │ │ │ ├── edit-profile.tsx │ │ │ │ ├── export-data.tsx │ │ │ │ ├── filters.tsx │ │ │ │ ├── import-data.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── interaction-policies.tsx │ │ │ │ ├── migration.tsx │ │ │ │ ├── mutes.tsx │ │ │ │ ├── settings.tsx │ │ │ │ └── url-privacy.tsx │ │ │ ├── status-lists │ │ │ │ ├── bookmark-folders.tsx │ │ │ │ ├── bookmarks.tsx │ │ │ │ ├── conversations.tsx │ │ │ │ ├── draft-statuses.tsx │ │ │ │ ├── events.tsx │ │ │ │ ├── favourited-statuses.tsx │ │ │ │ ├── interaction-requests.tsx │ │ │ │ ├── pinned-statuses.tsx │ │ │ │ ├── quotes.tsx │ │ │ │ └── scheduled-statuses.tsx │ │ │ ├── statuses │ │ │ │ ├── compose-event.tsx │ │ │ │ ├── event-discussion.tsx │ │ │ │ ├── event-information.tsx │ │ │ │ └── status.tsx │ │ │ ├── timelines │ │ │ │ ├── bubble-timeline.tsx │ │ │ │ ├── community-timeline.tsx │ │ │ │ ├── group-timeline.tsx │ │ │ │ ├── hashtag-timeline.tsx │ │ │ │ ├── home-timeline.tsx │ │ │ │ ├── landing-timeline.tsx │ │ │ │ ├── link-timeline.tsx │ │ │ │ ├── list-timeline.tsx │ │ │ │ ├── public-timeline.tsx │ │ │ │ ├── remote-timeline.tsx │ │ │ │ └── test-timeline.tsx │ │ │ └── utils │ │ │ │ ├── about.tsx │ │ │ │ ├── crypto-donate.tsx │ │ │ │ ├── federation-restrictions.tsx │ │ │ │ ├── generic-not-found.tsx │ │ │ │ ├── intentional-error.tsx │ │ │ │ ├── landing.tsx │ │ │ │ ├── new-status.tsx │ │ │ │ ├── server-info.tsx │ │ │ │ └── share.tsx │ │ ├── polyfills.ts │ │ ├── precheck.ts │ │ ├── queries │ │ │ ├── __mocks__ │ │ │ │ └── client.ts │ │ │ ├── account-lists │ │ │ │ ├── use-blocks.ts │ │ │ │ └── use-follows.ts │ │ │ ├── accounts.ts │ │ │ ├── accounts │ │ │ │ ├── account-scrobble.ts │ │ │ │ ├── use-birthday-reminders.ts │ │ │ │ ├── use-circles.ts │ │ │ │ ├── use-directory.ts │ │ │ │ ├── use-endorsed-accounts.ts │ │ │ │ ├── use-familiar-followers.ts │ │ │ │ ├── use-follow-requests.ts │ │ │ │ └── use-lists.ts │ │ │ ├── admin │ │ │ │ ├── use-announcements.ts │ │ │ │ ├── use-domains.ts │ │ │ │ ├── use-metrics.ts │ │ │ │ ├── use-moderation-log.ts │ │ │ │ ├── use-relays.ts │ │ │ │ └── use-rules.ts │ │ │ ├── announcements │ │ │ │ └── use-announcements.ts │ │ │ ├── chats.test.ts │ │ │ ├── chats.ts │ │ │ ├── client.ts │ │ │ ├── embed.ts │ │ │ ├── events │ │ │ │ ├── use-event-participation-requests.ts │ │ │ │ └── use-event-participations.ts │ │ │ ├── groups │ │ │ │ ├── use-group-blocks.ts │ │ │ │ └── use-group-members.ts │ │ │ ├── hashtags │ │ │ │ ├── use-followed-tags.ts │ │ │ │ └── use-hashtag.ts │ │ │ ├── instance │ │ │ │ ├── use-custom-emojis.ts │ │ │ │ └── use-translation-languages.ts │ │ │ ├── pl-fe │ │ │ │ └── use-about-page.ts │ │ │ ├── relationships.test.ts │ │ │ ├── relationships.ts │ │ │ ├── search │ │ │ │ ├── use-search-accounts.ts │ │ │ │ ├── use-search-location.ts │ │ │ │ └── use-search.ts │ │ │ ├── security │ │ │ │ └── oauth-tokens.ts │ │ │ ├── settings │ │ │ │ ├── domain-blocks.ts │ │ │ │ ├── use-backups.ts │ │ │ │ └── use-interaction-policies.ts │ │ │ ├── statuses │ │ │ │ ├── scheduled-statuses.ts │ │ │ │ ├── use-bookmark-folders.ts │ │ │ │ ├── use-interaction-requests.ts │ │ │ │ ├── use-status-history.ts │ │ │ │ ├── use-status-interactions.ts │ │ │ │ ├── use-status-quotes.ts │ │ │ │ └── use-status-translation.ts │ │ │ ├── suggestions.test.ts │ │ │ ├── suggestions.ts │ │ │ ├── timelines │ │ │ │ ├── use-account-media-timeline.ts │ │ │ │ └── use-events-lists.ts │ │ │ ├── trends.test.ts │ │ │ ├── trends.ts │ │ │ ├── trends │ │ │ │ ├── use-suggested-accounts.ts │ │ │ │ ├── use-trending-links.ts │ │ │ │ └── use-trending-statuses.ts │ │ │ └── utils │ │ │ │ ├── make-paginated-response-query-options.ts │ │ │ │ ├── make-paginated-response-query.ts │ │ │ │ ├── minify-list.ts │ │ │ │ └── mutation-options.ts │ │ ├── ready.ts │ │ ├── reducers │ │ │ ├── accounts-meta.ts │ │ │ ├── admin-user-index.ts │ │ │ ├── admin.test.ts │ │ │ ├── admin.ts │ │ │ ├── aliases.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── compose.test.ts │ │ │ ├── compose.ts │ │ │ ├── contexts.test.ts │ │ │ ├── contexts.ts │ │ │ ├── conversations.test.ts │ │ │ ├── conversations.ts │ │ │ ├── draft-statuses.ts │ │ │ ├── filters.test.ts │ │ │ ├── filters.ts │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ ├── instance.test.ts │ │ │ ├── instance.ts │ │ │ ├── list-adder.test.ts │ │ │ ├── list-adder.ts │ │ │ ├── list-editor.ts │ │ │ ├── lists.test.ts │ │ │ ├── lists.ts │ │ │ ├── me.test.ts │ │ │ ├── me.ts │ │ │ ├── meta.test.ts │ │ │ ├── meta.ts │ │ │ ├── notifications.ts │ │ │ ├── onboarding.test.ts │ │ │ ├── onboarding.ts │ │ │ ├── pending-statuses.ts │ │ │ ├── pl-fe.test.ts │ │ │ ├── pl-fe.ts │ │ │ ├── polls.test.ts │ │ │ ├── polls.ts │ │ │ ├── push-notifications.test.ts │ │ │ ├── push-notifications.ts │ │ │ ├── security.ts │ │ │ ├── shoutbox.ts │ │ │ ├── status-lists.test.ts │ │ │ ├── status-lists.ts │ │ │ ├── statuses.test.ts │ │ │ ├── statuses.ts │ │ │ ├── timelines.test.ts │ │ │ └── timelines.ts │ │ ├── schemas │ │ │ ├── pl-fe │ │ │ │ └── settings.ts │ │ │ ├── pleroma.ts │ │ │ └── utils.ts │ │ ├── selectors │ │ │ └── index.ts │ │ ├── sentry.ts │ │ ├── service-worker │ │ │ ├── sw.ts │ │ │ └── web-push-locales.ts │ │ ├── settings.ts │ │ ├── storage │ │ │ └── kv-store.ts │ │ ├── store.ts │ │ ├── stores │ │ │ ├── account-hover-card.ts │ │ │ ├── modals.ts │ │ │ ├── settings.ts │ │ │ ├── status-hover-card.ts │ │ │ ├── status-meta.ts │ │ │ └── ui.ts │ │ ├── styles │ │ │ ├── accessibility.scss │ │ │ ├── application.scss │ │ │ ├── basics.scss │ │ │ ├── components │ │ │ │ ├── columns.scss │ │ │ │ ├── compose-form.scss │ │ │ │ ├── datepicker.scss │ │ │ │ ├── detailed-status.scss │ │ │ │ ├── icon.scss │ │ │ │ ├── media-gallery.scss │ │ │ │ ├── modal.scss │ │ │ │ ├── notification.scss │ │ │ │ ├── status.scss │ │ │ │ └── video-player.scss │ │ │ ├── emoji-picker.scss │ │ │ ├── forms.scss │ │ │ ├── i18n │ │ │ │ ├── arabic.css │ │ │ │ └── javanese.css │ │ │ ├── loading.scss │ │ │ ├── markup.scss │ │ │ ├── mfm.scss │ │ │ ├── tailwind.css │ │ │ ├── ui.scss │ │ │ └── utilities.scss │ │ ├── toast.test.tsx │ │ ├── toast.tsx │ │ ├── types │ │ │ ├── colors.ts │ │ │ ├── entities.ts │ │ │ ├── history.ts │ │ │ └── pl-fe.ts │ │ └── utils │ │ │ ├── accounts.test.ts │ │ │ ├── accounts.ts │ │ │ ├── auth.ts │ │ │ ├── badges.test.ts │ │ │ ├── badges.ts │ │ │ ├── base64.test.ts │ │ │ ├── base64.ts │ │ │ ├── chats.test.ts │ │ │ ├── chats.ts │ │ │ ├── check-instance-capability.ts │ │ │ ├── code-compiletime.ts │ │ │ ├── code.ts │ │ │ ├── colors.test.ts │ │ │ ├── colors.ts │ │ │ ├── comparators.test.ts │ │ │ ├── comparators.ts │ │ │ ├── config-db.test.ts │ │ │ ├── config-db.ts │ │ │ ├── console.ts │ │ │ ├── copy.ts │ │ │ ├── download.ts │ │ │ ├── emoji-reacts.test.ts │ │ │ ├── emoji-reacts.ts │ │ │ ├── emoji.test.ts │ │ │ ├── emoji.ts │ │ │ ├── errors.ts │ │ │ ├── favicon-service.ts │ │ │ ├── html.test.ts │ │ │ ├── html.ts │ │ │ ├── input.test.ts │ │ │ ├── input.ts │ │ │ ├── media-aspect-ratio.ts │ │ │ ├── media.test.ts │ │ │ ├── media.ts │ │ │ ├── normalizers.ts │ │ │ ├── notification.ts │ │ │ ├── numbers.test.tsx │ │ │ ├── numbers.tsx │ │ │ ├── nyaize.ts │ │ │ ├── queries.test.ts │ │ │ ├── queries.ts │ │ │ ├── redirect.ts │ │ │ ├── resize-image.ts │ │ │ ├── rich-content.ts │ │ │ ├── rtl.ts │ │ │ ├── scopes.ts │ │ │ ├── sounds.ts │ │ │ ├── state.ts │ │ │ ├── static.ts │ │ │ ├── status.test.ts │ │ │ ├── status.ts │ │ │ ├── strings.ts │ │ │ ├── suggestions.ts │ │ │ ├── sw.ts │ │ │ ├── tailwind.test.ts │ │ │ ├── tailwind.ts │ │ │ ├── theme.ts │ │ │ ├── timelines.test.ts │ │ │ ├── timelines.ts │ │ │ ├── url-purify.ts │ │ │ └── url.ts │ ├── tailwind.config.ts │ ├── tailwind │ │ ├── colors.test.ts │ │ └── colors.ts │ ├── tsconfig.json │ ├── vite.config.ts │ └── yarn.lock └── pl-hooks │ ├── .eslintignore │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── lib │ ├── contexts │ │ ├── api-client.ts │ │ └── query-client.ts │ ├── hooks │ │ ├── account-lists │ │ │ └── use-directory.ts │ │ ├── accounts │ │ │ ├── use-account-lookup.ts │ │ │ ├── use-account-relationship.ts │ │ │ └── use-account.ts │ │ ├── instance │ │ │ ├── use-instance.ts │ │ │ └── use-translation-languages.ts │ │ ├── markers │ │ │ ├── use-markers.ts │ │ │ └── use-update-marker-mutation.ts │ │ ├── notifications │ │ │ ├── use-notification-list.ts │ │ │ └── use-notification.ts │ │ ├── polls │ │ │ └── use-poll.ts │ │ ├── search │ │ │ └── use-search.ts │ │ └── statuses │ │ │ ├── use-status-history.ts │ │ │ ├── use-status-quotes.ts │ │ │ ├── use-status-translation.ts │ │ │ └── use-status.ts │ ├── importer.ts │ ├── main.ts │ ├── normalizers │ │ ├── account.ts │ │ ├── notification.ts │ │ ├── status-edit.ts │ │ ├── status-list.ts │ │ └── status.ts │ └── utils │ │ └── queries.ts │ ├── package.json │ ├── tsconfig-build.json │ ├── tsconfig.json │ ├── vite.config.ts │ └── yarn.lock └── yarn.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directories: 5 | - "/packages/pl-api" 6 | - "/packages/pl-fe" 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | yarn-error.log* 3 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn precommit 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @transfem-org:registry=https://activitypub.software/api/v4/packages/npm/ 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "bradlc.vscode-tailwindcss", 5 | "stylelint.vscode-stylelint", 6 | "wix.vscode-import-cost", 7 | "bradlc.vscode-tailwindcss" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.validate": false, 3 | "editor.insertSpaces": true, 4 | "editor.tabSize": 2, 5 | "files.associations": { 6 | "*.conf.template": "properties" 7 | }, 8 | "files.eol": "\n", 9 | "files.insertFinalNewline": false, 10 | "json.schemas": [ 11 | { 12 | "fileMatch": [".lintstagedrc.json"], 13 | "url": "https://json.schemastore.org/lintstagedrc.schema.json" 14 | }, 15 | { 16 | "fileMatch": ["renovate.json"], 17 | "url": "https://docs.renovatebot.com/renovate-schema.json" 18 | } 19 | ], 20 | "scss.validate": false 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "precommit": "lint-staged" 5 | }, 6 | "devDependencies": { 7 | "husky": "^9.0.0", 8 | "lint-staged": ">=10" 9 | }, 10 | "workspaces": ["pl-api", "pl-fe", "pl-hooks"], 11 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 12 | } 13 | -------------------------------------------------------------------------------- /packages/pl-api/.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | /dist/** 3 | /static/** 4 | /public/** 5 | /tmp/** 6 | /coverage/** 7 | /custom/** 8 | -------------------------------------------------------------------------------- /packages/pl-api/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | /.eslintcache 11 | 12 | node_modules 13 | dist 14 | dist-ssr 15 | docs 16 | *.local 17 | 18 | .idea 19 | .DS_Store 20 | 21 | # Editor directories and files 22 | .vscode/* 23 | !.vscode/extensions.json 24 | .idea 25 | .DS_Store 26 | *.suo 27 | *.ntvs* 28 | *.njsproj 29 | *.sln 30 | *.sw? 31 | -------------------------------------------------------------------------------- /packages/pl-api/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | pl-api 7 | 8 | 9 | 10 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/announcement.ts: -------------------------------------------------------------------------------- 1 | import pick from 'lodash.pick'; 2 | import * as v from 'valibot'; 3 | 4 | import { announcementSchema } from '../announcement'; 5 | 6 | /** 7 | * @category Admin schemas 8 | * @see {@link https://docs.pleroma.social/backend/development/API/admin_api/#get-apiv1pleromaadminannouncements} 9 | */ 10 | const adminAnnouncementSchema = v.pipe( 11 | v.any(), 12 | v.transform((announcement: any) => ({ 13 | ...announcement, 14 | ...pick(announcement.pleroma, 'raw_content'), 15 | })), 16 | v.object({ 17 | ...announcementSchema.entries, 18 | raw_content: v.fallback(v.string(), ''), 19 | }), 20 | ); 21 | 22 | /** 23 | * @category Admin entity types 24 | */ 25 | type AdminAnnouncement = v.InferOutput; 26 | 27 | export { adminAnnouncementSchema, type AdminAnnouncement }; 28 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/canonical-email-block.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Admin schemas 5 | * @see {@link https://docs.joinmastodon.org/entities/Admin_CanonicalEmailBlock/} 6 | */ 7 | const adminCanonicalEmailBlockSchema = v.object({ 8 | id: v.string(), 9 | canonical_email_hash: v.string(), 10 | }); 11 | 12 | /** 13 | * @category Admin entity types 14 | */ 15 | type AdminCanonicalEmailBlock = v.InferOutput; 16 | 17 | export { 18 | adminCanonicalEmailBlockSchema, 19 | type AdminCanonicalEmailBlock, 20 | }; 21 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/cohort.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from '../utils'; 4 | 5 | /** 6 | * @category Admin schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/Admin_Cohort/} 8 | */ 9 | const adminCohortSchema = v.object({ 10 | period: datetimeSchema, 11 | frequency: v.picklist(['day', 'month']), 12 | data: v.array(v.object({ 13 | date: datetimeSchema, 14 | rate: v.number(), 15 | value: v.pipe(v.unknown(), v.transform(Number)), 16 | })), 17 | }); 18 | 19 | /** 20 | * @category Admin entity types 21 | */ 22 | type AdminCohort = v.InferOutput; 23 | 24 | export { 25 | adminCohortSchema, 26 | type AdminCohort, 27 | }; 28 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/dimension.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Admin schemas 5 | * @see {@link https://docs.joinmastodon.org/entities/Admin_Dimension/} 6 | */ 7 | const adminDimensionSchema = v.object({ 8 | key: v.string(), 9 | data: v.array(v.object({ 10 | key: v.string(), 11 | human_key: v.string(), 12 | value: v.string(), 13 | unit: v.fallback(v.optional(v.string()), undefined), 14 | human_value: v.fallback(v.optional(v.string()), undefined), 15 | })), 16 | }); 17 | 18 | /** 19 | * @category Admin entity types 20 | */ 21 | type AdminDimension = v.InferOutput; 22 | 23 | export { 24 | adminDimensionSchema, 25 | type AdminDimension, 26 | }; 27 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/domain-allow.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from '../utils'; 4 | 5 | /** 6 | * @category Admin schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/Admin_DomainAllow/} 8 | */ 9 | const adminDomainAllowSchema = v.object({ 10 | id: v.string(), 11 | domain: v.string(), 12 | created_at: datetimeSchema, 13 | }); 14 | 15 | /** 16 | * @category Admin entity types 17 | */ 18 | type AdminDomainAllow = v.InferOutput; 19 | 20 | export { 21 | adminDomainAllowSchema, 22 | type AdminDomainAllow, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/domain.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from '../utils'; 4 | 5 | /** 6 | * @category Admin schemas 7 | */ 8 | const adminDomainSchema = v.object({ 9 | domain: v.fallback(v.string(), ''), 10 | id: v.pipe(v.unknown(), v.transform(String)), 11 | public: v.fallback(v.boolean(), false), 12 | resolves: v.fallback(v.boolean(), false), 13 | last_checked_at: v.fallback(v.nullable(datetimeSchema), null), 14 | }); 15 | 16 | /** 17 | * @category Admin entity types 18 | */ 19 | type AdminDomain = v.InferOutput 20 | 21 | export { adminDomainSchema, type AdminDomain }; 22 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/email-domain-block.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from '../utils'; 4 | 5 | /** 6 | * @category Admin schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/Admin_EmailDomainBlock/} 8 | */ 9 | const adminEmailDomainBlockSchema = v.object({ 10 | id: v.string(), 11 | domain: v.string(), 12 | created_at: datetimeSchema, 13 | history: v.array(v.object({ 14 | day: v.pipe(v.unknown(), v.transform(String)), 15 | accounts: v.pipe(v.unknown(), v.transform(String)), 16 | uses: v.pipe(v.unknown(), v.transform(String)), 17 | })), 18 | }); 19 | 20 | /** 21 | * @category Admin entity types 22 | */ 23 | type AdminEmailDomainBlock = v.InferOutput; 24 | 25 | export { 26 | adminEmailDomainBlockSchema, 27 | type AdminEmailDomainBlock, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/ip-block.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from '../utils'; 4 | 5 | /** 6 | * @category Admin schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/Admin_IpBlock/} 8 | */ 9 | const adminIpBlockSchema = v.object({ 10 | id: v.string(), 11 | ip: v.pipe(v.string(), v.ip()), 12 | severity: v.picklist(['sign_up_requires_approval', 'sign_up_block', 'no_access']), 13 | comment: v.fallback(v.string(), ''), 14 | created_at: datetimeSchema, 15 | expires_at: v.fallback(v.nullable(datetimeSchema), null), 16 | }); 17 | 18 | /** 19 | * @category Admin entity types 20 | */ 21 | type AdminIpBlock = v.InferOutput; 22 | 23 | export { 24 | adminIpBlockSchema, 25 | type AdminIpBlock, 26 | }; 27 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/ip.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from '../utils'; 4 | 5 | /** 6 | * @category Admin schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/Admin_Ip/} 8 | */ 9 | const adminIpSchema = v.object({ 10 | ip: v.pipe(v.string(), v.ip()), 11 | used_at: datetimeSchema, 12 | }); 13 | 14 | /** 15 | * @category Admin entity types 16 | */ 17 | type AdminIp = v.InferOutput; 18 | 19 | export { 20 | adminIpSchema, 21 | type AdminIp, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/moderation-log-entry.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Admin schemas 5 | * @see {@link https://docs.pleroma.social/backend/development/API/admin_api/#get-apiv1pleromaadminmoderation_log} 6 | */ 7 | const adminModerationLogEntrySchema = v.object({ 8 | id: v.pipe(v.unknown(), v.transform(String)), 9 | data: v.fallback(v.record(v.string(), v.any()), {}), 10 | time: v.fallback(v.number(), 0), 11 | message: v.fallback(v.string(), ''), 12 | }); 13 | 14 | /** 15 | * @category Admin entity types 16 | */ 17 | type AdminModerationLogEntry = v.InferOutput 18 | 19 | export { adminModerationLogEntrySchema, type AdminModerationLogEntry }; 20 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/pleroma-config.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Admin schemas 5 | */ 6 | const pleromaConfigSchema = v.object({ 7 | configs: v.array(v.object({ 8 | value: v.any(), 9 | group: v.string(), 10 | key: v.string(), 11 | })), 12 | need_reboot: v.boolean(), 13 | }); 14 | 15 | /** 16 | * @category Admin entity types 17 | */ 18 | type PleromaConfig = v.InferOutput 19 | 20 | export { pleromaConfigSchema, type PleromaConfig }; 21 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/relay.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Admin schemas 5 | */ 6 | const adminRelaySchema = v.pipe( 7 | v.any(), 8 | v.transform((data: any) => ({ id: data.actor, ...data })), 9 | v.object({ 10 | actor: v.fallback(v.string(), ''), 11 | id: v.string(), 12 | followed_back: v.fallback(v.boolean(), false), 13 | }), 14 | ); 15 | 16 | /** 17 | * @category Admin entity types 18 | */ 19 | type AdminRelay = v.InferOutput 20 | 21 | export { adminRelaySchema, type AdminRelay }; 22 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/rule.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from '../utils'; 4 | 5 | /** 6 | * @category Admin schemas 7 | * @see {@link https://docs.pleroma.social/backend/development/API/admin_api/#get-apiv1pleromaadminrules} 8 | */ 9 | const adminRuleSchema = v.object({ 10 | id: v.string(), 11 | text: v.fallback(v.string(), ''), 12 | hint: v.fallback(v.string(), ''), 13 | priority: v.fallback(v.nullable(v.number()), null), 14 | 15 | created_at: v.fallback(v.optional(datetimeSchema), undefined), 16 | updated_at: v.fallback(v.optional(datetimeSchema), undefined), 17 | }); 18 | 19 | /** 20 | * @category Admin entity types 21 | */ 22 | type AdminRule = v.InferOutput; 23 | 24 | export { adminRuleSchema, type AdminRule }; 25 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/admin/tag.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { tagSchema } from '../tag'; 4 | 5 | /** 6 | * @category Admin schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/Tag/#admin} 8 | */ 9 | const adminTagSchema = v.object({ 10 | ...tagSchema.entries, 11 | id: v.string(), 12 | trendable: v.boolean(), 13 | usable: v.boolean(), 14 | requires_review: v.boolean(), 15 | }); 16 | 17 | /** 18 | * @category Admin entity types 19 | */ 20 | type AdminTag = v.InferOutput; 21 | 22 | export { 23 | adminTagSchema, 24 | type AdminTag, 25 | }; 26 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/announcement-reaction.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | * @see {@link https://docs.joinmastodon.org/entities/announcement/} 6 | */ 7 | const announcementReactionSchema = v.object({ 8 | name: v.fallback(v.string(), ''), 9 | count: v.fallback(v.pipe(v.number(), v.integer(), v.minValue(0)), 0), 10 | me: v.fallback(v.boolean(), false), 11 | url: v.fallback(v.nullable(v.string()), null), 12 | static_url: v.fallback(v.nullable(v.string()), null), 13 | announcement_id: v.fallback(v.string(), ''), 14 | }); 15 | 16 | /** 17 | * @category Entity types 18 | */ 19 | type AnnouncementReaction = v.InferOutput; 20 | 21 | export { announcementReactionSchema, type AnnouncementReaction }; 22 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/backup.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema, mimeSchema } from './utils'; 4 | 5 | /** 6 | * @category Schemas 7 | * @see {@link https://docs.pleroma.social/backend/development/API/pleroma_api/#post-apiv1pleromabackups} 8 | */ 9 | const backupSchema = v.object({ 10 | id: v.pipe(v.unknown(), v.transform(String)), 11 | content_type: mimeSchema, 12 | file_size: v.fallback(v.number(), 0), 13 | inserted_at: datetimeSchema, 14 | processed: v.fallback(v.boolean(), false), 15 | url: v.fallback(v.string(), ''), 16 | }); 17 | 18 | /** 19 | * @category Entity types 20 | */ 21 | type Backup = v.InferOutput; 22 | 23 | export { backupSchema, type Backup }; 24 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/bookmark-folder.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | */ 6 | const bookmarkFolderSchema = v.pipe(v.any(), v.transform((data) => ({ 7 | name: data.title, 8 | ...data, 9 | })), v.object({ 10 | id: v.pipe(v.unknown(), v.transform(String)), 11 | name: v.fallback(v.string(), ''), 12 | emoji: v.fallback(v.nullable(v.string()), null), 13 | emoji_url: v.fallback(v.nullable(v.string()), null), 14 | })); 15 | 16 | /** 17 | * @category Entity types 18 | */ 19 | type BookmarkFolder = v.InferOutput; 20 | 21 | export { bookmarkFolderSchema, type BookmarkFolder }; 22 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/chat.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { accountSchema } from './account'; 4 | import { chatMessageSchema } from './chat-message'; 5 | import { datetimeSchema } from './utils'; 6 | 7 | /** 8 | * @category Schemas 9 | * @see {@link https://docs.pleroma.social/backend/development/API/chats/#getting-a-list-of-chats} 10 | */ 11 | const chatSchema = v.object({ 12 | id: v.string(), 13 | account: accountSchema, 14 | unread: v.pipe(v.number(), v.integer()), 15 | last_message: v.fallback(v.nullable(chatMessageSchema), null), 16 | updated_at: datetimeSchema, 17 | }); 18 | 19 | /** 20 | * @category Entity types 21 | */ 22 | type Chat = v.InferOutput; 23 | 24 | export { chatSchema, type Chat }; 25 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/circle.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | */ 6 | const circleSchema = v.object({ 7 | id: v.string(), 8 | title: v.string(), 9 | }); 10 | 11 | /** 12 | * @category Entity types 13 | */ 14 | type Circle = v.InferOutput; 15 | 16 | export { circleSchema, type Circle }; 17 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/context.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { statusSchema } from './status'; 4 | import { filteredArray } from './utils'; 5 | 6 | /** 7 | * @category Schemas 8 | * @see {@link https://docs.joinmastodon.org/entities/Context/} 9 | */ 10 | const contextSchema = v.object({ 11 | ancestors: filteredArray(statusSchema), 12 | descendants: filteredArray(statusSchema), 13 | references: v.fallback(filteredArray(statusSchema), []), 14 | }); 15 | 16 | /** 17 | * @category Entity types 18 | */ 19 | type Context = v.InferOutput; 20 | 21 | export { contextSchema, type Context }; 22 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/conversation.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { accountSchema } from './account'; 4 | import { statusSchema } from './status'; 5 | import { filteredArray } from './utils'; 6 | 7 | /** 8 | * @category Schemas 9 | * @see {@link https://docs.joinmastodon.org/entities/Conversation} 10 | */ 11 | const conversationSchema = v.object({ 12 | id: v.string(), 13 | unread: v.fallback(v.boolean(), false), 14 | accounts: filteredArray(accountSchema), 15 | last_status: v.fallback(v.nullable(statusSchema), null), 16 | }); 17 | 18 | /** 19 | * @category Entity types 20 | */ 21 | type Conversation = v.InferOutput; 22 | 23 | export { conversationSchema, type Conversation }; 24 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/custom-emoji.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * Represents a custom emoji. 5 | * 6 | * @category Schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/CustomEmoji/} 8 | */ 9 | const customEmojiSchema = v.object({ 10 | shortcode: v.string(), 11 | url: v.string(), 12 | static_url: v.fallback(v.string(), ''), 13 | visible_in_picker: v.fallback(v.boolean(), true), 14 | category: v.fallback(v.nullable(v.string()), null), 15 | }); 16 | 17 | /** 18 | * @category Entity types 19 | */ 20 | type CustomEmoji = v.InferOutput; 21 | 22 | export { customEmojiSchema, type CustomEmoji }; 23 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/directory/category.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Directory schemas 5 | */ 6 | const directoryCategorySchema = v.object({ 7 | category: v.string(), 8 | servers_count: v.fallback(v.nullable(v.pipe(v.unknown(), v.transform(Number))), null), 9 | }); 10 | 11 | /** 12 | * @category Directory entity types 13 | */ 14 | type DirectoryCategory = v.InferOutput; 15 | 16 | export { directoryCategorySchema, type DirectoryCategory }; 17 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/directory/language.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Directory schemas 5 | */ 6 | const directoryLanguageSchema = v.object({ 7 | locale: v.string(), 8 | language: v.string(), 9 | servers_count: v.fallback(v.nullable(v.pipe(v.unknown(), v.transform(Number))), null), 10 | }); 11 | 12 | /** 13 | * @category Directory entity types 14 | */ 15 | type DirectoryLanguage = v.InferOutput; 16 | 17 | export { directoryLanguageSchema, type DirectoryLanguage }; 18 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/directory/statistics-period.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Directory schemas 5 | */ 6 | const directoryStatisticsPeriodSchema = v.object({ 7 | period: v.pipe(v.string(), v.isoDate()), 8 | server_count: v.fallback(v.nullable(v.pipe(v.unknown(), v.transform(Number))), null), 9 | user_count: v.fallback(v.nullable(v.pipe(v.unknown(), v.transform(Number))), null), 10 | active_user_count: v.fallback(v.nullable(v.pipe(v.unknown(), v.transform(Number))), null), 11 | }); 12 | 13 | /** 14 | * @category Directory entity types 15 | */ 16 | type DirectoryStatisticsPeriod = v.InferOutput; 17 | 18 | export { directoryStatisticsPeriodSchema, type DirectoryStatisticsPeriod }; 19 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/domain-block.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | * @see {@link https://docs.joinmastodon.org/entities/DomainBlock} 6 | */ 7 | const domainBlockSchema = v.object({ 8 | domain: v.string(), 9 | digest: v.string(), 10 | severity: v.picklist(['silence', 'suspend']), 11 | comment: v.fallback(v.optional(v.string()), undefined), 12 | }); 13 | 14 | /** 15 | * @category Entity types 16 | */ 17 | type DomainBlock = v.InferOutput; 18 | 19 | export { domainBlockSchema, type DomainBlock }; 20 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/drive-file.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | */ 6 | const driveFileSchema = v.pipe(v.any(), v.transform((file) => ({ 7 | ...file, 8 | thumbnail_url: file.thumbnailUrl, 9 | content_type: file.contentType, 10 | is_avatar: file.isAvatar, 11 | is_banner: file.isBanner, 12 | })), v.object({ 13 | id: v.string(), 14 | url: v.string(), 15 | thumbnail_url: v.string(), 16 | filename: v.string(), 17 | content_type: v.string(), 18 | sensitive: v.boolean(), 19 | description: v.fallback(v.nullable(v.string()), null), 20 | is_avatar: v.boolean(), 21 | is_banner: v.boolean(), 22 | })); 23 | 24 | /** 25 | * @category Entity types 26 | */ 27 | type DriveFile = v.InferOutput; 28 | 29 | export { driveFileSchema, type DriveFile }; 30 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/drive-folder.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { bookmarkFolderSchema } from './bookmark-folder'; 4 | import { driveFileSchema } from './drive-file'; 5 | import { filteredArray } from './utils'; 6 | 7 | /** 8 | * @category Schemas 9 | */ 10 | const driveFolderSchema = v.pipe(v.any(), v.transform((folder) => ({ 11 | ...folder, 12 | parent_id: folder.parentId, 13 | })), v.object({ 14 | id: v.fallback(v.nullable(v.string()), null), 15 | name: v.fallback(v.nullable(v.string()), null), 16 | parent_id: v.fallback(v.nullable(v.string()), null), 17 | files: filteredArray(driveFileSchema), 18 | folders: filteredArray(bookmarkFolderSchema), 19 | })); 20 | 21 | /** 22 | * @category Entity types 23 | */ 24 | type DriveFolder = v.InferOutput; 25 | 26 | export { driveFolderSchema, type DriveFolder }; 27 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/extended-description.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from './utils'; 4 | 5 | /** 6 | * @category Schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/ExtendedDescription} 8 | */ 9 | const extendedDescriptionSchema = v.object({ 10 | updated_at: datetimeSchema, 11 | content: v.string(), 12 | }); 13 | 14 | /** 15 | * @category Entity types 16 | */ 17 | type ExtendedDescription = v.InferOutput; 18 | 19 | export { extendedDescriptionSchema, type ExtendedDescription }; 20 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/familiar-followers.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { accountSchema } from './account'; 4 | import { filteredArray } from './utils'; 5 | 6 | /** 7 | * @category Schemas 8 | * @see {@link https://docs.joinmastodon.org/entities/FamiliarFollowers/} 9 | */ 10 | const familiarFollowersSchema = v.object({ 11 | id: v.string(), 12 | accounts: filteredArray(accountSchema), 13 | }); 14 | 15 | /** 16 | * @category Entity types 17 | */ 18 | type FamiliarFollowers = v.InferOutput 19 | 20 | export { familiarFollowersSchema, type FamiliarFollowers }; 21 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/featured-tag.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | * @see {@link https://docs.joinmastodon.org/entities/FeaturedTag/} 6 | */ 7 | const featuredTagSchema = v.object({ 8 | id: v.string(), 9 | name: v.string(), 10 | url: v.fallback(v.optional(v.string()), undefined), 11 | statuses_count: v.number(), 12 | last_status_at: v.number(), 13 | }); 14 | 15 | /** 16 | * @category Entity types 17 | */ 18 | type FeaturedTag = v.InferOutput; 19 | 20 | export { featuredTagSchema, type FeaturedTag }; 21 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/filter-result.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { baseFilterSchema } from './filter'; 4 | 5 | const filterSchema = v.omit(baseFilterSchema, ['keywords', 'statuses']); 6 | 7 | /** 8 | * @category Schemas 9 | * @see {@link https://docs.joinmastodon.org/entities/FilterResult/} 10 | */ 11 | const filterResultSchema = v.object({ 12 | filter: filterSchema, 13 | keyword_matches: v.fallback(v.nullable(v.string()), null), 14 | status_matches: v.fallback(v.nullable(v.string()), null), 15 | }); 16 | 17 | /** 18 | * @category Entity types 19 | */ 20 | type FilterResult = v.InferOutput; 21 | 22 | export { filterResultSchema, type FilterResult }; 23 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/group-relationship.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { GroupRoles } from './group-member'; 4 | 5 | /** 6 | * @category Schemas 7 | */ 8 | const groupRelationshipSchema = v.object({ 9 | id: v.string(), 10 | member: v.fallback(v.boolean(), false), 11 | role: v.fallback(v.enum(GroupRoles), GroupRoles.USER), 12 | requested: v.fallback(v.boolean(), false), 13 | }); 14 | 15 | /** 16 | * @category Entity types 17 | */ 18 | type GroupRelationship = v.InferOutput; 19 | 20 | export { groupRelationshipSchema, type GroupRelationship }; 21 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/list.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { antennaSchema } from './antenna'; 4 | import { filteredArray } from './utils'; 5 | 6 | /** 7 | * @category Schemas 8 | * @see {@link https://docs.joinmastodon.org/entities/List/} 9 | */ 10 | const listSchema = v.object({ 11 | id: v.pipe(v.unknown(), v.transform(String)), 12 | title: v.string(), 13 | replies_policy: v.fallback(v.optional(v.string()), undefined), 14 | exclusive: v.fallback(v.optional(v.boolean()), undefined), 15 | antennas: filteredArray(v.lazy(() => antennaSchema)), 16 | notify: v.fallback(v.optional(v.boolean()), undefined), 17 | favourite: v.fallback(v.optional(v.boolean()), undefined), 18 | }); 19 | 20 | /** 21 | * @category Entity types 22 | */ 23 | type List = v.InferOutput; 24 | 25 | export { listSchema, type List }; 26 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/mention.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | * @see {@link https://docs.joinmastodon.org/entities/Status/#Mention} 6 | */ 7 | const mentionSchema = v.pipe( 8 | v.object({ 9 | id: v.string(), 10 | username: v.fallback(v.string(), ''), 11 | url: v.fallback(v.pipe(v.string(), v.url()), ''), 12 | acct: v.string(), 13 | }), 14 | v.transform((mention) => { 15 | if (!mention.username) { 16 | mention.username = mention.acct.split('@')[0]; 17 | } 18 | 19 | return mention; 20 | }), 21 | ); 22 | 23 | /** 24 | * @category Entity types 25 | */ 26 | type Mention = v.InferOutput; 27 | 28 | export { mentionSchema, type Mention }; 29 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/notification-request.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { accountSchema } from './account'; 4 | import { statusSchema } from './status'; 5 | import { datetimeSchema } from './utils'; 6 | 7 | /** 8 | * @category Schemas 9 | * @see {@link https://docs.joinmastodon.org/entities/NotificationRequest} 10 | */ 11 | const notificationRequestSchema = v.object({ 12 | id: v.string(), 13 | created_at: datetimeSchema, 14 | updated_at: datetimeSchema, 15 | account: accountSchema, 16 | notifications_count: v.pipe(v.unknown(), v.transform(String)), 17 | last_status: v.fallback(v.optional(statusSchema), undefined), 18 | }); 19 | 20 | /** 21 | * @category Entity types 22 | */ 23 | type NotificationRequest = v.InferOutput; 24 | 25 | export { notificationRequestSchema, type NotificationRequest }; 26 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/preview-card-author.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { accountSchema } from './account'; 4 | 5 | /** 6 | * @category Schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/PreviewCardAuthor/} 8 | */ 9 | const previewCardAuthorSchema = v.object({ 10 | name: v.string(), 11 | url: v.pipe(v.string(), v.url()), 12 | account: v.fallback(v.nullable(accountSchema), null), 13 | }); 14 | 15 | /** 16 | * @category Entity types 17 | */ 18 | type PreviewCardAuthor = v.InferOutput; 19 | 20 | export { previewCardAuthorSchema, type PreviewCardAuthor }; 21 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/privacy-policy.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from './utils'; 4 | 5 | /** 6 | * @category Schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/PrivacyPolicy/} 8 | */ 9 | const privacyPolicySchema = v.object({ 10 | updated_at: datetimeSchema, 11 | content: v.string(), 12 | }); 13 | 14 | /** 15 | * @category Entity types 16 | */ 17 | type PrivacyPolicy = v.InferOutput; 18 | 19 | export { privacyPolicySchema, type PrivacyPolicy }; 20 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/relationship-severance-event.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from './utils'; 4 | 5 | /** 6 | * @category Schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/RelationshipSeveranceEvent/} 8 | */ 9 | const relationshipSeveranceEventSchema = v.object({ 10 | id: v.string(), 11 | type: v.picklist(['domain_block', 'user_domain_block', 'account_suspension']), 12 | purged: v.string(), 13 | relationships_count: v.fallback(v.optional(v.number()), undefined), 14 | created_at: datetimeSchema, 15 | }); 16 | 17 | /** 18 | * @category Entity types 19 | */ 20 | type RelationshipSeveranceEvent = v.InferOutput; 21 | 22 | export { relationshipSeveranceEventSchema, type RelationshipSeveranceEvent }; 23 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/role.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | const hexSchema = v.pipe(v.string(), v.regex(/^#[a-f0-9]{6}$/i)); 4 | 5 | /** 6 | * @category Schemas 7 | */ 8 | const roleSchema = v.object({ 9 | id: v.fallback(v.string(), ''), 10 | name: v.fallback(v.string(), ''), 11 | color: v.fallback(hexSchema, ''), 12 | permissions: v.fallback(v.string(), ''), 13 | highlighted: v.fallback(v.boolean(), true), 14 | }); 15 | 16 | /** 17 | * @category Entity types 18 | */ 19 | type Role = v.InferOutput; 20 | 21 | export { 22 | roleSchema, 23 | type Role, 24 | }; 25 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/rss-feed.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | */ 6 | const rssFeedSchema = v.object({ 7 | id: v.string(), 8 | url: v.string(), 9 | title: v.fallback(v.nullable(v.string()), null), 10 | description: v.fallback(v.nullable(v.string()), null), 11 | image_url: v.fallback(v.nullable(v.string()), null), 12 | }); 13 | 14 | /** 15 | * @category Entity types 16 | */ 17 | type RssFeed = v.InferOutput; 18 | 19 | export { rssFeedSchema, type RssFeed }; 20 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/rule.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | const baseRuleSchema = v.object({ 4 | id: v.string(), 5 | text: v.fallback(v.string(), ''), 6 | hint: v.fallback(v.string(), ''), 7 | translations: v.optional(v.record(v.string(), v.object({ 8 | text: v.fallback(v.string(), ''), 9 | hint: v.fallback(v.string(), ''), 10 | })), undefined), 11 | }); 12 | 13 | /** 14 | * @category Schemas 15 | * @see {@link https://docs.joinmastodon.org/entities/Rule/} 16 | */ 17 | const ruleSchema = v.pipe( 18 | v.any(), 19 | v.transform((data: any) => ({ 20 | ...data, 21 | hint: data.hint || data.subtext, 22 | })), 23 | baseRuleSchema, 24 | ); 25 | 26 | /** 27 | * @category Entity types 28 | */ 29 | type Rule = v.InferOutput; 30 | 31 | export { ruleSchema, type Rule }; 32 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/search.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { accountSchema } from './account'; 4 | import { groupSchema } from './group'; 5 | import { statusSchema } from './status'; 6 | import { tagSchema } from './tag'; 7 | import { filteredArray } from './utils'; 8 | 9 | /** 10 | * @category Schemas 11 | * @see {@link https://docs.joinmastodon.org/entities/Search} 12 | */ 13 | const searchSchema = v.object({ 14 | accounts: filteredArray(accountSchema), 15 | statuses: filteredArray(statusSchema), 16 | hashtags: filteredArray(tagSchema), 17 | groups: filteredArray(groupSchema), 18 | }); 19 | 20 | /** 21 | * @category Entity types 22 | */ 23 | type Search = v.InferOutput; 24 | 25 | export { searchSchema, type Search }; 26 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/shout-message.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { accountSchema } from './account'; 4 | 5 | /** 6 | * @category Schemas 7 | */ 8 | const shoutMessageSchema = v.object({ 9 | id: v.number(), 10 | text: v.string(), 11 | author: accountSchema, 12 | }); 13 | 14 | /** 15 | * @category Entity types 16 | */ 17 | type ShoutMessage = v.InferOutput; 18 | 19 | export { shoutMessageSchema, type ShoutMessage }; 20 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/status-source.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { locationSchema } from './location'; 4 | 5 | /** 6 | * @category Schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/StatusSource/} 8 | */ 9 | const statusSourceSchema = v.object({ 10 | id: v.string(), 11 | text: v.fallback(v.string(), ''), 12 | spoiler_text: v.fallback(v.string(), ''), 13 | 14 | content_type: v.fallback(v.string(), 'text/plain'), 15 | location: v.fallback(v.nullable(locationSchema), null), 16 | 17 | text_map: v.fallback(v.nullable(v.record(v.string(), v.string())), null), 18 | spoiler_text_map: v.fallback(v.nullable(v.record(v.string(), v.string())), null), 19 | }); 20 | 21 | /** 22 | * @category Entity types 23 | */ 24 | type StatusSource = v.InferOutput; 25 | 26 | export { statusSourceSchema, type StatusSource }; 27 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/story-carousel-item.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | */ 6 | const storyCarouselItemSchema = v.pipe(v.any(), v.transform((item) => ({ 7 | account_id: item.pid, 8 | story_id: item.sid, 9 | ...item, 10 | })), v.object({ 11 | account_id: v.string(), 12 | avatar: v.string(), 13 | local: v.boolean(), 14 | username: v.string(), 15 | latest: v.object({ 16 | id: v.pipe(v.unknown(), v.transform(String)), 17 | type: v.string(), 18 | preview_url: v.string(), 19 | }), 20 | url: v.string(), 21 | seen: v.boolean(), 22 | story_id: v.pipe(v.unknown(), v.transform(String)), 23 | })); 24 | 25 | /** 26 | * @category Entity types 27 | */ 28 | type StoryCarouselItem = v.InferOutput; 29 | 30 | export { storyCarouselItemSchema, type StoryCarouselItem }; 31 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/story-media.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | */ 6 | const storyMediaSchema = v.pipe(v.any(), v.transform((media) => ({ 7 | id: media.media_id, 8 | url: media.media_url, 9 | type: media.media_type, 10 | })), v.object({ 11 | id: v.string(), 12 | url: v.string(), 13 | type: v.picklist(['photo', 'video']), 14 | })); 15 | 16 | /** 17 | * @category Entity types 18 | */ 19 | type StoryMedia = v.InferOutput; 20 | 21 | export { storyMediaSchema, type StoryMedia }; 22 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/subscription-details.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { datetimeSchema } from './utils'; 4 | 5 | /** 6 | * @category Schemas 7 | */ 8 | const subscriptionDetailsSchema = v.object({ 9 | /** Subscription ID. */ 10 | id: v.number(), 11 | /** The date when subscription expires. */ 12 | expires_at: v.fallback(datetimeSchema, new Date().toISOString()), 13 | }); 14 | 15 | /** 16 | * @category Entity types 17 | */ 18 | type SubscriptionDetails = v.InferOutput; 19 | 20 | export { 21 | subscriptionDetailsSchema, 22 | type SubscriptionDetails, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/subscription-option.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | */ 6 | const subscriptionOptionSchema = v.object({ 7 | /** Subscription type */ 8 | type: v.picklist(['monero']), 9 | /** CAIP-2 chain ID. */ 10 | chain_id: v.fallback(v.string(), ''), 11 | /** Subscription price (only for Monero) */ 12 | price: v.fallback(v.nullable(v.number()), null), 13 | /** Payout address (only for Monero) */ 14 | payout_address: v.fallback(v.string(), ''), 15 | }); 16 | 17 | /** 18 | * @category Entity types 19 | */ 20 | type SubscriptionOption = v.InferOutput; 21 | 22 | export { 23 | subscriptionOptionSchema, 24 | type SubscriptionOption, 25 | }; 26 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/terms-of-service.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { dateSchema } from './utils'; 4 | 5 | /** 6 | * @category Schemas 7 | * @see {@link https://docs.joinmastodon.org/entities/TermsOfService/} 8 | */ 9 | const termsOfServiceSchema = v.object({ 10 | effective_date: dateSchema, 11 | effective: v.boolean(), 12 | content: v.string(), 13 | succeeded_by: v.fallback(v.nullable(dateSchema), null), 14 | }); 15 | 16 | /** 17 | * @category Entity types 18 | */ 19 | type TermsOfService = v.InferOutput; 20 | 21 | export { termsOfServiceSchema, type TermsOfService }; 22 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/token.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | * @see {@link https://docs.joinmastodon.org/entities/Token/} 6 | */ 7 | const tokenSchema = v.object({ 8 | access_token: v.string(), 9 | token_type: v.string(), 10 | scope: v.string(), 11 | created_at: v.fallback(v.optional(v.number()), undefined), 12 | 13 | id: v.fallback(v.optional(v.pipe(v.unknown(), v.transform(String))), undefined), 14 | refresh_token: v.fallback(v.optional(v.string()), undefined), 15 | expires_in: v.fallback(v.optional(v.number()), undefined), 16 | me: v.fallback(v.optional(v.string()), undefined), 17 | }); 18 | 19 | /** 20 | * @category Entity types 21 | */ 22 | type Token = v.InferOutput; 23 | 24 | export { tokenSchema, type Token }; 25 | -------------------------------------------------------------------------------- /packages/pl-api/lib/entities/web-push-subscription.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | /** 4 | * @category Schemas 5 | * @see {@link https://docs.joinmastodon.org/entities/WebPushSubscription/} 6 | */ 7 | const webPushSubscriptionSchema = v.object({ 8 | id: v.pipe(v.unknown(), v.transform(String)), 9 | endpoint: v.string(), 10 | standard: v.fallback(v.boolean(), false), 11 | alerts: v.record(v.string(), v.boolean()), 12 | server_key: v.string(), 13 | }); 14 | 15 | /** 16 | * @category Entity types 17 | */ 18 | type WebPushSubscription = v.InferOutput; 19 | 20 | export { webPushSubscriptionSchema, type WebPushSubscription }; 21 | -------------------------------------------------------------------------------- /packages/pl-api/lib/main.ts: -------------------------------------------------------------------------------- 1 | export { PlApiClient } from './client'; 2 | export { PlApiDirectoryClient } from './directory-client'; 3 | export { type Response as PlApiResponse } from './request'; 4 | export * from './entities'; 5 | export * from './features'; 6 | export * from './params'; 7 | export * from './responses'; 8 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/antennas.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @category Request params 3 | */ 4 | interface CreateAntennaParams { 5 | title: string; 6 | stl?: boolean; 7 | ltl?: boolean; 8 | insert_feeds?: boolean; 9 | with_media_only?: boolean; 10 | ignore_reblog?: boolean; 11 | favourite?: boolean; 12 | list_id?: string; 13 | } 14 | 15 | /** 16 | * @category Request params 17 | */ 18 | type UpdateAntennaParams = Partial; 19 | 20 | export { 21 | type CreateAntennaParams, 22 | type UpdateAntennaParams, 23 | }; 24 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/apps.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @category Request params 3 | */ 4 | interface CreateApplicationParams { 5 | /** String. A name for your application */ 6 | client_name: string; 7 | /** String. Where the user should be redirected after authorization. To display the authorization code to the user instead of redirecting to a web page, use `urn:ietf:wg:oauth:2.0:oob` in this parameter. */ 8 | redirect_uris: string; 9 | /** String. Space separated list of scopes. If none is provided, defaults to `read`. See [OAuth Scopes](https://docs.joinmastodon.org/api/oauth-scopes/) for a list of possible scopes. */ 10 | scopes?: string; 11 | /** String. A URL to the homepage of your app */ 12 | website?: string; 13 | } 14 | 15 | export type { 16 | CreateApplicationParams, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/chats.ts: -------------------------------------------------------------------------------- 1 | import { PaginationParams, WithMutedParam } from './common'; 2 | 3 | /** 4 | * @category Request params 5 | */ 6 | type GetChatsParams = PaginationParams & WithMutedParam; 7 | 8 | /** 9 | * @category Request params 10 | */ 11 | type GetChatMessagesParams = PaginationParams; 12 | 13 | /** 14 | * @category Request params 15 | */ 16 | type CreateChatMessageParams = { 17 | content?: string; 18 | media_id: string; 19 | } | { 20 | content: string; 21 | media_id?: string; 22 | }; 23 | 24 | export type { 25 | GetChatsParams, 26 | GetChatMessagesParams, 27 | CreateChatMessageParams, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/circles.ts: -------------------------------------------------------------------------------- 1 | import { PaginationParams } from './common'; 2 | 3 | /** 4 | * @category Request params 5 | */ 6 | type GetCircleStatusesParams = PaginationParams; 7 | 8 | export type { 9 | GetCircleStatusesParams, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/drive.ts: -------------------------------------------------------------------------------- 1 | interface UpdateFileParams { 2 | filename: string; 3 | sensitive: boolean; 4 | description: string; 5 | } 6 | 7 | export type { UpdateFileParams }; 8 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/index.ts: -------------------------------------------------------------------------------- 1 | export * from './accounts'; 2 | export * from './admin'; 3 | export * from './apps'; 4 | export * from './chats'; 5 | export type { PaginationParams } from './common'; 6 | export * from './events'; 7 | export * from './filtering'; 8 | export * from './grouped-notifications'; 9 | export * from './groups'; 10 | export * from './instance'; 11 | export * from './interaction-requests'; 12 | export * from './lists'; 13 | export * from './media'; 14 | export * from './my-account'; 15 | export * from './notifications'; 16 | export * from './oauth'; 17 | export * from './push-notifications'; 18 | export * from './scheduled-statuses'; 19 | export * from './search'; 20 | export * from './settings'; 21 | export * from './statuses'; 22 | export * from './timelines'; 23 | export * from './trends'; 24 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/instance.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @category Request params 3 | */ 4 | interface ProfileDirectoryParams { 5 | /** Number. Skip the first n results. */ 6 | offset?: number; 7 | /** Number. How many accounts to load. Defaults to 40 accounts. Max 80 accounts. */ 8 | limit?: number; 9 | /** String. Use active to sort by most recently posted statuses (default) or new to sort by most recently created profiles. */ 10 | order?: string; 11 | /** Boolean. If true, returns only local accounts. */ 12 | local?: boolean; 13 | } 14 | 15 | export type { 16 | ProfileDirectoryParams, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/lists.ts: -------------------------------------------------------------------------------- 1 | import type { PaginationParams } from './common'; 2 | 3 | /** 4 | * @category Request params 5 | */ 6 | interface CreateListParams { 7 | /** String. The title of the list to be created. */ 8 | title: string; 9 | /** String. One of followed, list, or none. Defaults to list. */ 10 | replies_policy?: 'followed' | 'list' | 'none'; 11 | /** Boolean. Whether members of this list need to get removed from the “Home” feed */ 12 | exclusive?: boolean; 13 | } 14 | 15 | /** 16 | * @category Request params 17 | */ 18 | type UpdateListParams = CreateListParams; 19 | 20 | /** 21 | * @category Request params 22 | */ 23 | type GetListAccountsParams = PaginationParams; 24 | 25 | export type { 26 | CreateListParams, 27 | UpdateListParams, 28 | GetListAccountsParams, 29 | }; 30 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/scheduled-statuses.ts: -------------------------------------------------------------------------------- 1 | import type { PaginationParams } from './common'; 2 | 3 | /** 4 | * @category Request params 5 | */ 6 | type GetScheduledStatusesParams = PaginationParams; 7 | 8 | export type { 9 | GetScheduledStatusesParams, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/stories.ts: -------------------------------------------------------------------------------- 1 | interface CreateStoryPollParams { 2 | /** From 6 to 140 characters. */ 3 | question: string; 4 | /** Between 2 and 4 answers. */ 5 | answers: string; 6 | can_reply: boolean; 7 | can_react: boolean; 8 | } 9 | 10 | type StoryReportType = 'spam' | 'sensitive' | 'abusive' | 'underage' | 'copyright' | 'impersonation' | 'scam' | 'terrorism'; 11 | 12 | interface CropStoryPhotoParams { 13 | width: number; 14 | height: number; 15 | x: number; 16 | y: number; 17 | } 18 | 19 | interface CreateStoryParams { 20 | /** Between 3 and 120 (in seconds). */ 21 | duration: number; 22 | can_reply: boolean; 23 | can_react: boolean; 24 | } 25 | 26 | export type { CreateStoryPollParams, StoryReportType, CropStoryPhotoParams, CreateStoryParams }; 27 | -------------------------------------------------------------------------------- /packages/pl-api/lib/params/trends.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @category Request params 3 | */ 4 | interface GetTrends { 5 | /** Integer. Maximum number of results to return. */ 6 | limit?: number; 7 | /** Integer. Skip the first n results. */ 8 | offset?: number; 9 | } 10 | 11 | /** 12 | * @category Request params 13 | */ 14 | type GetTrendingTags = GetTrends; 15 | 16 | /** 17 | * @category Request params 18 | */ 19 | interface GetTrendingStatuses extends GetTrends { 20 | /** 21 | * Display trends from a given time range. 22 | * 23 | * Requires features{@link Features['trendingStatusesRange']}. 24 | */ 25 | range?: 'daily' | 'monthly' | 'yearly'; 26 | } 27 | 28 | /** 29 | * @category Request params 30 | */ 31 | type GetTrendingLinks = GetTrends; 32 | 33 | export type { 34 | GetTrendingTags, 35 | GetTrendingStatuses, 36 | GetTrendingLinks, 37 | }; 38 | -------------------------------------------------------------------------------- /packages/pl-api/lib/responses.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @category Utils 3 | */ 4 | interface PaginatedResponse { 5 | previous: (() => Promise>) | null; 6 | next: (() => Promise>) | null; 7 | items: IsArray extends true ? Array : T; 8 | partial: boolean; 9 | total?: number; 10 | } 11 | 12 | export type { 13 | PaginatedResponse, 14 | }; 15 | -------------------------------------------------------------------------------- /packages/pl-api/tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["lib"] 4 | } -------------------------------------------------------------------------------- /packages/pl-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src", "lib"] 24 | } 25 | -------------------------------------------------------------------------------- /packages/pl-api/typedoc.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {Partial} */ 2 | 3 | const config = { 4 | entryPoints: ['./lib/main.ts'], 5 | plugin: ['typedoc-material-theme', 'typedoc-plugin-valibot'], 6 | themeColor: '#d80482', 7 | navigation: { 8 | includeCategories: true, 9 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /packages/pl-api/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | 3 | import { defineConfig } from 'vite'; 4 | import dts from 'vite-plugin-dts'; 5 | 6 | import pkg from './package.json'; 7 | 8 | export default defineConfig({ 9 | plugins: [dts({ include: ['lib'], insertTypesEntry: true })], 10 | build: { 11 | copyPublicDir: false, 12 | lib: { 13 | entry: resolve(__dirname, 'lib/main.ts'), 14 | fileName: (format) => `main.${format}.js`, 15 | formats: ['es'], 16 | name: 'pl-api', 17 | }, 18 | target: 'esnext', 19 | sourcemap: true, 20 | rollupOptions: { 21 | external: Object.keys(pkg.dependencies), 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /packages/pl-fe/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | 3 | /node_modules/ 4 | /tmp/ 5 | /build/ 6 | /coverage/ 7 | /.coverage/ 8 | /.eslintcache 9 | /.env 10 | /deploy.sh 11 | /.vs/ 12 | yarn-error.log* 13 | /junit.xml 14 | 15 | /dist/ 16 | /static/ 17 | /public/ 18 | /dist/ 19 | /pl-fe.zip 20 | 21 | .idea 22 | .DS_Store 23 | 24 | # Custom build files 25 | /custom/**/* 26 | !/custom/* 27 | /custom/*.* 28 | !/custom/.gitkeep 29 | !/custom/**/.gitkeep 30 | 31 | # surge.sh 32 | /CNAME 33 | /AUTH 34 | /CORS 35 | /ROUTER 36 | -------------------------------------------------------------------------------- /packages/pl-fe/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /packages/pl-fe/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | # BACKEND_URL="https://example.com" 3 | # PROXY_HTTPS_INSECURE=false 4 | -------------------------------------------------------------------------------- /packages/pl-fe/.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | /dist/** 3 | /static/** 4 | /public/** 5 | /tmp/** 6 | /coverage/** 7 | /custom/** 8 | -------------------------------------------------------------------------------- /packages/pl-fe/.gitattributes: -------------------------------------------------------------------------------- 1 | CHANGELOG.md merge=union -------------------------------------------------------------------------------- /packages/pl-fe/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /tmp/ 3 | /build/ 4 | /coverage/ 5 | /.coverage/ 6 | /.eslintcache 7 | /.env 8 | /deploy.sh 9 | /.vs/ 10 | yarn-error.log* 11 | /junit.xml 12 | *.timestamp-* 13 | *.bundled_* 14 | 15 | /dist/ 16 | /static/ 17 | /public/ 18 | /pl-fe.zip 19 | 20 | .idea 21 | .DS_Store 22 | 23 | # Custom build files 24 | /custom/**/* 25 | !/custom/* 26 | /custom/*.* 27 | !/custom/.gitkeep 28 | !/custom/**/.gitkeep 29 | -------------------------------------------------------------------------------- /packages/pl-fe/.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": "eslint --cache", 3 | "*.cjs": "eslint --cache", 4 | "*.mjs": "eslint --cache", 5 | "*.ts": "eslint --cache", 6 | "*.tsx": "eslint --cache", 7 | "src/styles/**/*.scss": "stylelint" 8 | } 9 | -------------------------------------------------------------------------------- /packages/pl-fe/.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 21.4.0 2 | -------------------------------------------------------------------------------- /packages/pl-fe/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:21 as build 2 | WORKDIR /app 3 | COPY package.json . 4 | COPY yarn.lock . 5 | RUN yarn 6 | COPY . . 7 | ARG NODE_ENV=production 8 | RUN yarn build 9 | 10 | FROM nginx:stable-alpine 11 | EXPOSE 5000 12 | ENV PORT=5000 13 | ENV FALLBACK_PORT=4444 14 | ENV BACKEND_URL=http://localhost:4444 15 | ENV CSP= 16 | COPY installation/docker.conf.template /etc/nginx/templates/default.conf.template 17 | COPY --from=build /app/dist /usr/share/nginx/html 18 | -------------------------------------------------------------------------------- /packages/pl-fe/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:21 2 | 3 | RUN apt-get update &&\ 4 | apt-get install -y inotify-tools &&\ 5 | # clean up apt 6 | rm -rf /var/lib/apt/lists/* 7 | 8 | WORKDIR /app 9 | ENV NODE_ENV=development 10 | 11 | COPY package.json . 12 | COPY yarn.lock . 13 | RUN yarn 14 | 15 | COPY . . 16 | 17 | ENV DEVSERVER_URL=http://0.0.0.0:3036 18 | CMD yarn dev -------------------------------------------------------------------------------- /packages/pl-fe/README.md: -------------------------------------------------------------------------------- 1 | See [../../README.md](../../README.md) for more information on the project. 2 | -------------------------------------------------------------------------------- /packages/pl-fe/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pl-fe", 3 | "description": "Mastodon-compatible social media front-end.", 4 | "keywords": ["fediverse"], 5 | "website": "https://github.com/mkljczk/pl-fe", 6 | "stack": "container" 7 | } 8 | -------------------------------------------------------------------------------- /packages/pl-fe/compose-dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile.dev 6 | image: pl-fe-dev 7 | ports: 8 | - "3036:3036" 9 | volumes: 10 | - .:/app -------------------------------------------------------------------------------- /packages/pl-fe/custom/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/custom/.gitkeep -------------------------------------------------------------------------------- /packages/pl-fe/custom/instance/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/custom/instance/.gitkeep -------------------------------------------------------------------------------- /packages/pl-fe/custom/locales/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/custom/locales/.gitkeep -------------------------------------------------------------------------------- /packages/pl-fe/custom/modules/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/custom/modules/.gitkeep -------------------------------------------------------------------------------- /packages/pl-fe/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/favicon.ico -------------------------------------------------------------------------------- /packages/pl-fe/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').ConfigFn} */ 2 | const config = ({ env }) => ({ 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | cssnano: env === 'production' ? {} : false, 7 | }, 8 | }); 9 | 10 | module.exports = config; 11 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/accounts_counter_follow.json: -------------------------------------------------------------------------------- 1 | { 2 | "9vMAje101ngtjlMj7w": { 3 | "followers_count": 2, 4 | "following_count": 3, 5 | "statuses_count": 2 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/accounts_counter_initial.json: -------------------------------------------------------------------------------- 1 | { 2 | "9vMAje101ngtjlMj7w": { 3 | "followers_count": 2, 4 | "following_count": 2, 5 | "statuses_count": 2 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/accounts_counter_unfollow.json: -------------------------------------------------------------------------------- 1 | { 2 | "9vMAje101ngtjlMj7w": { 3 | "followers_count": 2, 4 | "following_count": 1, 5 | "statuses_count": 2 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "vapid_key": "BHczIFh4Wn3Q_7wDgehaB8Ti3Uu8BoyOgXxkOVuEJRuEqxtd9TAno8K9ycz4myiQ1ruiyVfG6xT1JLeXtpxDzUs", 3 | "token_type": "Bearer", 4 | "client_secret": "cm_8Zip_UYyYq1DPQ-CRFUolrz894MmWYUC0aeVcklM", 5 | "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", 6 | "created_at": 1594764335, 7 | "name": "SoapboxFE_2020-07-14T22:05:17.054Z", 8 | "client_id": "bjiy8AxGKXXesfZcyp_iN-uQVE6Cnl03efWoSdOPh9M", 9 | "expires_in": 600, 10 | "scope": "read write follow push admin", 11 | "refresh_token": "IXoCKCsZi3ZCuCjIkeadvEoHRdqOYHklZmv9jvkJ5VA", 12 | "website": null, 13 | "id": "134", 14 | "access_token": "XSkQFSV1R_IvycQmw_uD5z6hQmNyuhh9PtMQbv8TgG8" 15 | } 16 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/blocks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "22", 4 | "username": "twoods", 5 | "acct": "twoods", 6 | "display_name": "Tiger Woods" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/gotosocial-account.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "00YSECR4P7E64BD5MBA639PRVT", 3 | "username": "alex", 4 | "acct": "alex", 5 | "display_name": "Alex Gleason", 6 | "locked": false, 7 | "bot": false, 8 | "created_at": "2022-02-23T22:43:55Z", 9 | "note": "

My GoToSocial profile

", 10 | "url": "http://localhost/@alex", 11 | "avatar": "", 12 | "avatar_static": "", 13 | "header": "", 14 | "header_static": "", 15 | "followers_count": 0, 16 | "following_count": 0, 17 | "statuses_count": 1, 18 | "last_status_at": "2022-02-23T22:54:14Z", 19 | "emojis": [], 20 | "fields": [], 21 | "source": { 22 | "privacy": "unlisted", 23 | "language": "en", 24 | "note": "

My GoToSocial profile

", 25 | "fields": [] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/markers.json: -------------------------------------------------------------------------------- 1 | { 2 | "notifications": { 3 | "last_read_id": "35098814", 4 | "version": 361, 5 | "updated_at": "2019-11-26T22:37:25.239Z", 6 | "pleroma": { 7 | "unread_count": 3 8 | } 9 | }, 10 | "home": { 11 | "last_read_id": "103206604258487607", 12 | "version": 468, 13 | "updated_at": "2019-11-26T22:37:25.235Z", 14 | "pleroma": { 15 | "unread_count": 32 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/mastodon-account.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "106801667066418367", 3 | "username": "benis911", 4 | "acct": "benis911", 5 | "display_name": "", 6 | "locked": false, 7 | "bot": false, 8 | "discoverable": null, 9 | "group": false, 10 | "created_at": "2021-08-22T00:00:00.000Z", 11 | "note": "", 12 | "url": "https://mastodon.social/@benis911", 13 | "avatar": "https://mastodon.social/avatars/original/missing.png", 14 | "avatar_static": "https://mastodon.social/avatars/original/missing.png", 15 | "header": "https://mastodon.social/headers/original/missing.png", 16 | "header_static": "https://mastodon.social/headers/original/missing.png", 17 | "followers_count": 1, 18 | "following_count": 0, 19 | "statuses_count": 5, 20 | "last_status_at": "2022-02-23", 21 | "emojis": [], 22 | "fields": [] 23 | } 24 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/mitra-instance.json: -------------------------------------------------------------------------------- 1 | { 2 | "uri": "mitra.social", 3 | "title": "Mitra", 4 | "short_description": "Federated social network with smart contracts", 5 | "description": "This is an instance of [Mitra](https://codeberg.org/silverpill/mitra), federated social network built on [ActivityPub](https://activitypub.rocks/) protocol.\nRegistration is invitation-only.\nAdmin:\n - [@silverpill@mitra.social](https://mitra.social/profile/dd4ebc18-269d-4c7b-a310-03d29c6ab551)\n - Matrix: @silverpill:poa.st\n", 6 | "version": "3.0.0 (compatible; Mitra 0.4.0)", 7 | "registrations": false, 8 | "login_message": "Sign this message to log in to https://mitra.social. Do not sign this message on other sites!", 9 | "post_character_limit": 5000, 10 | "blockchain_explorer_url": null, 11 | "blockchain_contract_address": null, 12 | "ipfs_gateway_url": "https://ipfs.mitra.social" 13 | } 14 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/patron-instance.json: -------------------------------------------------------------------------------- 1 | { 2 | "funding": { 3 | "amount": 3500, 4 | "patrons": 3, 5 | "currency": "usd", 6 | "interval": "monthly" 7 | }, 8 | "goals": [ 9 | { 10 | "amount": 20000, 11 | "currency": "usd", 12 | "interval": "monthly", 13 | "text": "I'll be able to afford an avocado." 14 | } 15 | ], 16 | "url": "https://patron.gleasonator.com" 17 | } 18 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/patron-user.json: -------------------------------------------------------------------------------- 1 | { 2 | "is_patron": true, 3 | "url": "https://gleasonator.com/users/dave" 4 | } 5 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/relationship.json: -------------------------------------------------------------------------------- 1 | { 2 | "showing_reblogs": true, 3 | "followed_by": false, 4 | "subscribing": false, 5 | "blocked_by": false, 6 | "requested": false, 7 | "domain_blocking": false, 8 | "following": false, 9 | "endorsed": false, 10 | "blocking": true, 11 | "muting": false, 12 | "id": "9vMAje101ngtjlMj7w", 13 | "muting_notifications": true 14 | } 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/rules.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "1", 4 | "text": "Illegal activity and behavior", 5 | "subtext": "Content that depicts illegal or criminal acts, threats of violence." 6 | }, 7 | { 8 | "id": "2", 9 | "text": "Intellectual property infringement", 10 | "subtext": "Impersonating another account or business, infringing on intellectual property rights." 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/status-quotes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "account": { 4 | "id": "ABDSjI3Q0R8aDaz1U0" 5 | }, 6 | "content": "quoast", 7 | "id": "AJsajx9hY4Q7IKQXEe", 8 | "pleroma": { 9 | "quote": { 10 | "content": "

10

", 11 | "id": "AJmoVikzI3SkyITyim" 12 | } 13 | } 14 | } 15 | ] 16 | -------------------------------------------------------------------------------- /packages/pl-fe/src/__fixtures__/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "UVBP2e17b4pTpb_h8fImIm3F5a66IBVb-JkyZHs4gLE", 3 | "expires_in": 600, 4 | "me": "https://social.teci.world/users/curtis", 5 | "refresh_token": "c2DpbVxYZBJDogNn-VBNFES72yXPNUYQCv0CrXGOplY", 6 | "scope": "read write follow push admin", 7 | "token_type": "Bearer" 8 | } 9 | -------------------------------------------------------------------------------- /packages/pl-fe/src/actions/account-notes.ts: -------------------------------------------------------------------------------- 1 | import { importEntities } from 'pl-fe/actions/importer'; 2 | 3 | import { getClient } from '../api'; 4 | 5 | import type { AppDispatch, RootState } from 'pl-fe/store'; 6 | 7 | const submitAccountNote = (accountId: string, value: string) => 8 | (dispatch: AppDispatch, getState: () => RootState) => 9 | getClient(getState).accounts.updateAccountNote(accountId, value) 10 | .then(response => dispatch(importEntities({ relationships: [response] }))); 11 | 12 | export { submitAccountNote }; 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/actions/apps.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Apps: manage OAuth applications. 3 | * Particularly useful for auth. 4 | * https://docs.joinmastodon.org/methods/apps/ 5 | * @module pl-fe/actions/apps 6 | * @see module:pl-fe/actions/auth 7 | */ 8 | 9 | import { PlApiClient, type CreateApplicationParams } from 'pl-api'; 10 | 11 | import * as BuildConfig from 'pl-fe/build-config'; 12 | 13 | const createApp = (params: CreateApplicationParams, baseURL?: string) => { 14 | const client = new PlApiClient(baseURL || BuildConfig.BACKEND_URL || ''); 15 | 16 | return client.apps.createApplication(params); 17 | }; 18 | 19 | export { 20 | createApp, 21 | }; 22 | -------------------------------------------------------------------------------- /packages/pl-fe/src/actions/chats.ts: -------------------------------------------------------------------------------- 1 | import { changeSetting } from 'pl-fe/actions/settings'; 2 | import { useSettingsStore } from 'pl-fe/stores/settings'; 3 | 4 | import type { AppDispatch, RootState } from 'pl-fe/store'; 5 | 6 | const toggleMainWindow = () => 7 | (dispatch: AppDispatch, getState: () => RootState) => { 8 | const main = useSettingsStore.getState().settings.chats.mainWindow; 9 | const state = main === 'minimized' ? 'open' : 'minimized'; 10 | return dispatch(changeSetting(['chats', 'mainWindow'], state)); 11 | }; 12 | 13 | export { 14 | toggleMainWindow, 15 | }; 16 | -------------------------------------------------------------------------------- /packages/pl-fe/src/actions/polls.ts: -------------------------------------------------------------------------------- 1 | import { getClient } from '../api'; 2 | 3 | import { importEntities } from './importer'; 4 | 5 | import type { AppDispatch, RootState } from 'pl-fe/store'; 6 | 7 | const vote = (pollId: string, choices: number[]) => 8 | (dispatch: AppDispatch, getState: () => RootState) => 9 | getClient(getState()).polls.vote(pollId, choices).then((data) => { 10 | dispatch(importEntities({ polls: [data] })); 11 | }); 12 | 13 | const fetchPoll = (pollId: string) => 14 | (dispatch: AppDispatch, getState: () => RootState) => 15 | getClient(getState()).polls.getPoll(pollId).then((data) => { 16 | dispatch(importEntities({ polls: [data] })); 17 | }); 18 | 19 | export { 20 | vote, 21 | fetchPoll, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/pl-fe/src/actions/push-subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { getClient } from '../api'; 2 | 3 | import type { CreatePushNotificationsSubscriptionParams } from 'pl-api'; 4 | import type { AppDispatch, RootState } from 'pl-fe/store'; 5 | 6 | const createPushSubscription = (params: CreatePushNotificationsSubscriptionParams) => 7 | (dispatch: AppDispatch, getState: () => RootState) => 8 | getClient(getState).pushNotifications.createSubscription(params); 9 | 10 | export { 11 | createPushSubscription, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/groups/use-create-group.ts: -------------------------------------------------------------------------------- 1 | import { Entities } from 'pl-fe/entity-store/entities'; 2 | import { useCreateEntity } from 'pl-fe/entity-store/hooks/use-create-entity'; 3 | import { useClient } from 'pl-fe/hooks/use-client'; 4 | import { normalizeGroup, type Group } from 'pl-fe/normalizers/group'; 5 | 6 | import type { Group as BaseGroup, CreateGroupParams } from 'pl-api'; 7 | 8 | const useCreateGroup = () => { 9 | const client = useClient(); 10 | 11 | const { createEntity, ...rest } = useCreateEntity( 12 | [Entities.GROUPS, 'search', ''], 13 | (params: CreateGroupParams) => client.experimental.groups.createGroup(params), 14 | { transform: normalizeGroup }, 15 | ); 16 | 17 | return { 18 | createGroup: createEntity, 19 | ...rest, 20 | }; 21 | }; 22 | 23 | export { useCreateGroup }; 24 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/groups/use-delete-group.ts: -------------------------------------------------------------------------------- 1 | import { Entities } from 'pl-fe/entity-store/entities'; 2 | import { useDeleteEntity } from 'pl-fe/entity-store/hooks/use-delete-entity'; 3 | import { useClient } from 'pl-fe/hooks/use-client'; 4 | 5 | const useDeleteGroup = () => { 6 | const client = useClient(); 7 | 8 | const { deleteEntity, isSubmitting } = useDeleteEntity( 9 | Entities.GROUPS, 10 | (groupId: string) => client.experimental.groups.deleteGroup(groupId), 11 | ); 12 | 13 | return { 14 | mutate: deleteEntity, 15 | isSubmitting, 16 | }; 17 | }; 18 | 19 | export { useDeleteGroup }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/groups/use-join-group.ts: -------------------------------------------------------------------------------- 1 | import { Entities } from 'pl-fe/entity-store/entities'; 2 | import { useCreateEntity } from 'pl-fe/entity-store/hooks/use-create-entity'; 3 | import { useClient } from 'pl-fe/hooks/use-client'; 4 | 5 | import { useGroups } from './use-groups'; 6 | 7 | import type { Group } from 'pl-api'; 8 | 9 | const useJoinGroup = (group: Pick) => { 10 | const client = useClient(); 11 | const { invalidate } = useGroups(); 12 | 13 | const { createEntity, isSubmitting } = useCreateEntity( 14 | [Entities.GROUP_RELATIONSHIPS, group.id], 15 | () => client.experimental.groups.joinGroup(group.id), 16 | ); 17 | 18 | return { 19 | mutate: createEntity, 20 | isSubmitting, 21 | invalidate, 22 | }; 23 | }; 24 | 25 | export { useJoinGroup }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/groups/use-leave-group.ts: -------------------------------------------------------------------------------- 1 | import { Entities } from 'pl-fe/entity-store/entities'; 2 | import { useCreateEntity } from 'pl-fe/entity-store/hooks/use-create-entity'; 3 | import { useClient } from 'pl-fe/hooks/use-client'; 4 | 5 | import { useGroups } from './use-groups'; 6 | 7 | import type { Group } from 'pl-api'; 8 | 9 | const useLeaveGroup = (group: Pick) => { 10 | const client = useClient(); 11 | const { invalidate } = useGroups(); 12 | 13 | const { createEntity, isSubmitting } = useCreateEntity( 14 | [Entities.GROUP_RELATIONSHIPS, group.id], 15 | () => client.experimental.groups.leaveGroup(group.id), 16 | ); 17 | 18 | return { 19 | mutate: createEntity, 20 | isSubmitting, 21 | invalidate, 22 | }; 23 | }; 24 | 25 | export { useLeaveGroup }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/streaming/use-bubble-stream.ts: -------------------------------------------------------------------------------- 1 | import { useTimelineStream } from './use-timeline-stream'; 2 | 3 | interface UseBubbleStreamOpts { 4 | onlyMedia?: boolean; 5 | enabled?: boolean; 6 | } 7 | 8 | const useBubbleStream = ({ onlyMedia, enabled }: UseBubbleStreamOpts = {}) => 9 | useTimelineStream(`bubble${onlyMedia ? ':media' : ''}`, {}, enabled); 10 | 11 | export { useBubbleStream }; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/streaming/use-community-stream.ts: -------------------------------------------------------------------------------- 1 | import { useTimelineStream } from './use-timeline-stream'; 2 | 3 | interface UseCommunityStreamOpts { 4 | onlyMedia?: boolean; 5 | enabled?: boolean; 6 | } 7 | 8 | const useCommunityStream = ({ onlyMedia, enabled }: UseCommunityStreamOpts = {}) => 9 | useTimelineStream(`public:local${onlyMedia ? ':media' : ''}`, {}, enabled); 10 | 11 | export { useCommunityStream }; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/streaming/use-direct-stream.ts: -------------------------------------------------------------------------------- 1 | import { useTimelineStream } from './use-timeline-stream'; 2 | 3 | const useDirectStream = () => useTimelineStream('direct'); 4 | 5 | export { useDirectStream }; 6 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/streaming/use-group-stream.ts: -------------------------------------------------------------------------------- 1 | import { useTimelineStream } from './use-timeline-stream'; 2 | 3 | const useGroupStream = (groupId: string) => useTimelineStream('group', { group: groupId } as any); 4 | 5 | export { useGroupStream }; 6 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/streaming/use-hashtag-stream.ts: -------------------------------------------------------------------------------- 1 | import { useTimelineStream } from './use-timeline-stream'; 2 | 3 | const useHashtagStream = (tag: string) => useTimelineStream('hashtag', { tag }); 4 | 5 | export { useHashtagStream }; 6 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/streaming/use-list-stream.ts: -------------------------------------------------------------------------------- 1 | import { useLoggedIn } from 'pl-fe/hooks/use-logged-in'; 2 | 3 | import { useTimelineStream } from './use-timeline-stream'; 4 | 5 | const useListStream = (listId: string) => { 6 | const { isLoggedIn } = useLoggedIn(); 7 | 8 | return useTimelineStream('list', { list: listId }, isLoggedIn); 9 | }; 10 | 11 | export { useListStream }; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/streaming/use-public-stream.ts: -------------------------------------------------------------------------------- 1 | import { useTimelineStream } from './use-timeline-stream'; 2 | 3 | interface UsePublicStreamOpts { 4 | onlyMedia?: boolean; 5 | } 6 | 7 | const usePublicStream = ({ onlyMedia }: UsePublicStreamOpts = {}) => 8 | useTimelineStream(`public${onlyMedia ? ':media' : ''}`); 9 | 10 | export { usePublicStream }; 11 | -------------------------------------------------------------------------------- /packages/pl-fe/src/api/hooks/streaming/use-remote-stream.ts: -------------------------------------------------------------------------------- 1 | import { useTimelineStream } from './use-timeline-stream'; 2 | 3 | interface UseRemoteStreamOpts { 4 | instance: string; 5 | onlyMedia?: boolean; 6 | } 7 | 8 | const useRemoteStream = ({ instance, onlyMedia }: UseRemoteStreamOpts) => 9 | useTimelineStream(`public:remote${onlyMedia ? ':media' : ''}`, { instance } as any); 10 | 11 | export { useRemoteStream }; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/images/audio-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/images/audio-placeholder.png -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/images/avatar-missing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/images/avatar-missing.png -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/images/header-missing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/images/header-missing.png -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/images/video-placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/images/video-placeholder.png -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/images/web-push/web-push-icon_expand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/images/web-push/web-push-icon_expand.png -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/images/web-push/web-push-icon_favourite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/images/web-push/web-push-icon_favourite.png -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/images/web-push/web-push-icon_reblog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/images/web-push/web-push-icon_reblog.png -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/sounds/LICENSE.md: -------------------------------------------------------------------------------- 1 | # Sound licenses 2 | 3 | - `chat.mp3` 4 | - `chat.ogg` 5 | 6 | © [notificationsounds.com](https://notificationsounds.com/notification-sounds/intuition-561), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by-sa/4.0/). 7 | -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/sounds/boop.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/sounds/boop.mp3 -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/sounds/boop.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/sounds/boop.ogg -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/sounds/chat.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/sounds/chat.mp3 -------------------------------------------------------------------------------- /packages/pl-fe/src/assets/sounds/chat.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/assets/sounds/chat.ogg -------------------------------------------------------------------------------- /packages/pl-fe/src/build-config.ts: -------------------------------------------------------------------------------- 1 | import type { PlFeEnv } from './build-config-compiletime'; 2 | 3 | export const { 4 | NODE_ENV, 5 | BACKEND_URL, 6 | FE_SUBDIRECTORY, 7 | WITH_LANDING_PAGE, 8 | } = import.meta.compileTime('./build-config-compiletime.ts'); 9 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/badge.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from 'pl-fe/jest/test-helpers'; 4 | 5 | import Badge from './badge'; 6 | 7 | describe('', () => { 8 | it('renders correctly', () => { 9 | render(); 10 | 11 | expect(screen.getByTestId('badge')).toHaveTextContent('Patron'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/dropdown-menu/index.ts: -------------------------------------------------------------------------------- 1 | export { default, type Menu } from './dropdown-menu'; 2 | export type { MenuItem } from './dropdown-menu-item'; 3 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/fork-awesome-icon.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * ForkAwesomeIcon: renders a ForkAwesome icon. 3 | * Full list: https://forkaweso.me/Fork-Awesome/icons/ 4 | * @module pl-fe/components/fork_awesome_icon 5 | * @see pl-fe/components/icon 6 | */ 7 | 8 | import clsx from 'clsx'; 9 | import React from 'react'; 10 | 11 | interface IForkAwesomeIcon extends React.HTMLAttributes { 12 | id: string; 13 | className?: string; 14 | fixedWidth?: boolean; 15 | } 16 | 17 | const ForkAwesomeIcon: React.FC = ({ id, className, fixedWidth, ...rest }) => ( 18 | 24 | ); 25 | 26 | export { ForkAwesomeIcon as default }; 27 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/hashtag-link.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Link from './link'; 4 | 5 | interface IHashtagLink { 6 | hashtag: string; 7 | } 8 | 9 | const HashtagLink: React.FC = ({ hashtag }) => ( 10 | e.stopPropagation()}> 11 | #{hashtag} 12 | 13 | ); 14 | 15 | export { HashtagLink as default }; 16 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/icon-with-counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Icon, { IIcon } from 'pl-fe/components/icon'; 4 | import Counter from 'pl-fe/components/ui/counter'; 5 | 6 | interface IIconWithCounter extends React.HTMLAttributes { 7 | count: number; 8 | countMax?: number; 9 | icon?: string; 10 | src?: string; 11 | } 12 | 13 | const IconWithCounter: React.FC = ({ icon, count, countMax, ...rest }) => ( 14 |
15 | 16 | 17 | {count > 0 && ( 18 | 19 | 20 | 21 | )} 22 |
23 | ); 24 | 25 | export { IconWithCounter as default }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/icon.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Icon: abstract component to render SVG icons. 3 | * @module pl-fe/components/icon 4 | */ 5 | 6 | import clsx from 'clsx'; 7 | import React from 'react'; 8 | import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports 9 | 10 | interface IIcon extends React.HTMLAttributes { 11 | src: string; 12 | id?: string; 13 | alt?: string; 14 | className?: string; 15 | } 16 | 17 | /** 18 | * @deprecated Use the UI Icon component directly. 19 | */ 20 | const Icon: React.FC = ({ src, alt, className, ...rest }) => ( 21 |
25 | } /> 26 |
27 | ); 28 | 29 | export { type IIcon, Icon as default }; 30 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/landing-gradient.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** Fullscreen gradient used as a backdrop to public pages. */ 4 | const LandingGradient: React.FC = () => ( 5 |
6 | ); 7 | 8 | export { LandingGradient as default }; 9 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/link.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link as Comp, LinkProps } from 'react-router-dom'; 3 | 4 | const Link = (props: LinkProps) => ( 5 | 9 | ); 10 | 11 | export { Link as default }; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/load-more.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedMessage } from 'react-intl'; 3 | 4 | import Button from 'pl-fe/components/ui/button'; 5 | 6 | interface ILoadMore { 7 | onClick: React.MouseEventHandler; 8 | disabled?: boolean; 9 | visible?: boolean; 10 | className?: string; 11 | } 12 | 13 | const LoadMore: React.FC = ({ onClick, disabled, visible = true, className }) => { 14 | if (!visible) { 15 | return null; 16 | } 17 | 18 | return ( 19 | 22 | ); 23 | }; 24 | 25 | export { LoadMore as default }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/loading-screen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import LandingGradient from 'pl-fe/components/landing-gradient'; 4 | import Spinner from 'pl-fe/components/ui/spinner'; 5 | 6 | /** Fullscreen loading indicator. */ 7 | const LoadingScreen: React.FC = React.memo(() => ( 8 |
9 | 10 | 11 |
12 |
13 | 14 |
15 |
16 |
17 | )); 18 | 19 | export { LoadingScreen as default }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/markup.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Text, { IText } from './ui/text'; 4 | 5 | interface IMarkup extends IText { 6 | } 7 | 8 | /** Styles HTML markup returned by the API, such as in account bios and statuses. */ 9 | const Markup = React.forwardRef((props, ref) => ); 10 | 11 | export { Markup as default }; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/outline-box.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | interface IOutlineBox extends React.HTMLAttributes { 5 | children: React.ReactNode; 6 | className?: string; 7 | } 8 | 9 | /** Wraps children in a container with an outline. */ 10 | const OutlineBox: React.FC = ({ children, className, ...rest }) => ( 11 |
15 | {children} 16 |
17 | ); 18 | 19 | export { OutlineBox as default }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/avatar.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from 'pl-fe/jest/test-helpers'; 4 | 5 | import Avatar from './avatar'; 6 | 7 | const src = '/static/alice.jpg'; 8 | 9 | describe('', () => { 10 | it('renders', () => { 11 | render(); 12 | 13 | expect(screen.getByRole('img')).toBeInTheDocument(); 14 | }); 15 | 16 | it('handles size props', () => { 17 | render(); 18 | 19 | expect(screen.getByTestId('still-image-container').getAttribute('style')).toMatch(/50px/i); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/banner.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | interface IBanner { 5 | theme: 'frosted' | 'opaque'; 6 | children: React.ReactNode; 7 | className?: string; 8 | } 9 | 10 | /** Displays a sticky full-width banner at the bottom of the screen. */ 11 | const Banner: React.FC = ({ theme, children, className }) => ( 12 |
19 |
20 | {children} 21 |
22 |
23 | ); 24 | 25 | export { Banner as default }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ICheckbox extends Pick, 'disabled' | 'id' | 'name' | 'onChange' | 'checked' | 'required'> { } 4 | 5 | /** A pretty checkbox input. */ 6 | const Checkbox = React.forwardRef((props, ref) => ( 7 | 13 | )); 14 | 15 | export { Checkbox as default }; 16 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/column.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from 'pl-fe/jest/test-helpers'; 4 | 5 | import { Column } from './column'; 6 | 7 | describe('', () => { 8 | it('renders correctly with minimal props', () => { 9 | render(); 10 | 11 | expect(screen.getByRole('button')).toHaveTextContent('Back'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/combobox.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --reach-combobox: 1; 3 | } 4 | 5 | [data-reach-combobox-popover] { 6 | @apply rounded-md shadow-lg bg-white dark:bg-gray-900 dark:ring-2 dark:ring-primary-700 z-[100]; 7 | } 8 | 9 | [data-reach-combobox-list] { 10 | @apply list-none m-0 py-1 px-0 select-none; 11 | } 12 | 13 | [data-reach-combobox-option] { 14 | @apply block px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 cursor-pointer; 15 | } 16 | 17 | [data-reach-combobox-option][aria-selected="true"] { 18 | @apply bg-gray-100 dark:bg-gray-800; 19 | } 20 | 21 | [data-reach-combobox-option]:hover { 22 | @apply bg-gray-100 dark:bg-gray-800; 23 | } 24 | 25 | [data-reach-combobox-option][aria-selected="true"]:hover { 26 | @apply bg-gray-100 dark:bg-gray-800; 27 | } 28 | 29 | [data-suggested-value] { 30 | @apply font-bold; 31 | } -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/combobox.tsx: -------------------------------------------------------------------------------- 1 | import './combobox.css'; 2 | 3 | export { 4 | Combobox as default, 5 | Combobox, 6 | ComboboxInput, 7 | ComboboxPopover, 8 | ComboboxList, 9 | ComboboxOption, 10 | ComboboxOptionText, 11 | } from '@reach/combobox'; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import AnimatedNumber from 'pl-fe/components/animated-number'; 4 | 5 | interface ICounter { 6 | /** Number this counter should display. */ 7 | count: number; 8 | /** Optional max number (ie: N+) */ 9 | countMax?: number; 10 | } 11 | 12 | /** A simple counter for notifications, etc. */ 13 | const Counter: React.FC = ({ count, countMax }) => ( 14 | 15 | 16 | 17 | ); 18 | 19 | export { Counter as default }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/divider.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from 'pl-fe/jest/test-helpers'; 4 | 5 | import Divider from './divider'; 6 | 7 | describe('', () => { 8 | it('renders without text', () => { 9 | render(); 10 | 11 | expect(screen.queryAllByTestId('divider-text')).toHaveLength(0); 12 | }); 13 | 14 | it('renders text', () => { 15 | const text = 'Hello'; 16 | render(); 17 | 18 | expect(screen.getByTestId('divider-text')).toHaveTextContent(text); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/emoji.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from 'pl-fe/jest/test-helpers'; 4 | 5 | import Emoji from './emoji'; 6 | 7 | describe('', () => { 8 | it('renders a simple emoji', () => { 9 | render(); 10 | 11 | const img = screen.getByRole('img'); 12 | expect(img.getAttribute('src')).toBe('/packs/emoji/1f600.svg'); 13 | expect(img.getAttribute('alt')).toBe('😀'); 14 | }); 15 | 16 | // https://emojipedia.org/emoji-flag-sequence/ 17 | it('renders a sequence emoji', () => { 18 | render(); 19 | 20 | const img = screen.getByRole('img'); 21 | expect(img.getAttribute('src')).toBe('/packs/emoji/1f1fa-1f1f8.svg'); 22 | expect(img.getAttribute('alt')).toBe('🇺🇸'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/file-input.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | 3 | interface IFileInput extends Pick, 'onChange' | 'required' | 'disabled' | 'name' | 'accept'> { } 4 | 5 | const FileInput = forwardRef((props, ref) => ( 6 | 12 | )); 13 | 14 | export { FileInput as default }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/form-actions.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from 'pl-fe/jest/test-helpers'; 4 | 5 | import FormActions from './form-actions'; 6 | 7 | describe('', () => { 8 | it('renders successfully', () => { 9 | render(
child
); 10 | 11 | expect(screen.getByTestId('child')).toBeInTheDocument(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/form-actions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import HStack from './hstack'; 4 | 5 | interface IFormActions { 6 | children: React.ReactNode; 7 | } 8 | 9 | /** Container element to house form actions. */ 10 | const FormActions: React.FC = ({ children }) => ( 11 | 12 | {children} 13 | 14 | ); 15 | 16 | export { FormActions as default }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/form.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { fireEvent, render, screen } from 'pl-fe/jest/test-helpers'; 4 | 5 | import Form from './form'; 6 | 7 | describe('
', () => { 8 | it('renders children', () => { 9 | const onSubmitMock = vi.fn(); 10 | render( 11 | children
, 12 | ); 13 | 14 | expect(screen.getByTestId('form')).toHaveTextContent('children'); 15 | }); 16 | 17 | it('handles onSubmit prop', () => { 18 | const onSubmitMock = vi.fn(); 19 | render( 20 |
children
, 21 | ); 22 | 23 | fireEvent.submit( 24 | screen.getByTestId('form'), { 25 | preventDefault: () => {}, 26 | }, 27 | ); 28 | expect(onSubmitMock).toHaveBeenCalled(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IForm { 4 | /** Form submission event handler. */ 5 | onSubmit?: (event: React.FormEvent) => void; 6 | /** Class name override for the
element. */ 7 | className?: string; 8 | /** Elements to display within the Form. */ 9 | children: React.ReactNode; 10 | } 11 | 12 | /** Form element with custom styles. */ 13 | const Form: React.FC = ({ onSubmit, children, ...filteredProps }) => { 14 | const handleSubmit: React.FormEventHandler = React.useCallback((event) => { 15 | event.preventDefault(); 16 | 17 | if (onSubmit) { 18 | onSubmit(event); 19 | } 20 | }, [onSubmit]); 21 | 22 | return ( 23 | 24 | {children} 25 | 26 | ); 27 | }; 28 | 29 | export { Form as default }; 30 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/portal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect, useState } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | interface IPortal { 5 | children: React.ReactNode; 6 | } 7 | 8 | /** 9 | * Portal 10 | */ 11 | const Portal: React.FC = ({ children }) => { 12 | const [isRendered, setIsRendered] = useState(false); 13 | 14 | useLayoutEffect(() => { 15 | setIsRendered(true); 16 | }, []); 17 | 18 | if (!isRendered) { 19 | return null; 20 | } 21 | 22 | return ( 23 | ReactDOM.createPortal( 24 | children, 25 | document.getElementById('plfe') as HTMLDivElement, 26 | ) 27 | ); 28 | }; 29 | 30 | export { Portal as default }; 31 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/svg-icon.test.tsx: -------------------------------------------------------------------------------- 1 | import IconCode from '@tabler/icons/outline/code.svg'; 2 | import React from 'react'; 3 | 4 | import { render, screen } from 'pl-fe/jest/test-helpers'; 5 | 6 | import SvgIcon from './svg-icon'; 7 | 8 | describe('', () => { 9 | it('renders loading element with default size', async () => { 10 | render(); 11 | 12 | const svg = screen.getByTestId('svg-icon-loader'); 13 | expect(svg.getAttribute('width')).toBe('24'); 14 | expect(svg.getAttribute('height')).toBe('24'); 15 | expect(svg.getAttribute('class')).toBe('text-primary-500'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/tabs.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --reach-tabs: 1; 3 | } 4 | 5 | [data-reach-tabs] { 6 | @apply relative pb-[3px]; 7 | } 8 | 9 | [data-reach-tab-list] { 10 | @apply flex; 11 | } 12 | 13 | [data-reach-tab] { 14 | @apply flex-1 flex justify-center items-center 15 | py-4 px-1 text-center font-medium text-sm text-gray-700 16 | dark:text-gray-600 hover:text-gray-800 dark:hover:text-gray-500 17 | focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:ring-gray-800 dark:ring-offset-0 dark:focus-visible:ring-primary-500; 18 | } 19 | 20 | [data-reach-tab][data-selected] { 21 | @apply text-gray-900 dark:text-gray-100; 22 | } 23 | -------------------------------------------------------------------------------- /packages/pl-fe/src/components/ui/tag.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import IconButton from './icon-button'; 4 | import Text from './text'; 5 | 6 | interface ITag { 7 | /** Name of the tag. */ 8 | tag: string; 9 | /** Callback when the X icon is pressed. */ 10 | onDelete: (tag: string) => void; 11 | } 12 | 13 | /** A single editable Tag (used by TagInput). */ 14 | const Tag: React.FC = ({ tag, onDelete }) => ( 15 |
16 | {tag} 17 | 18 | onDelete(tag)} 22 | /> 23 |
24 | ); 25 | 26 | export { Tag as default }; 27 | -------------------------------------------------------------------------------- /packages/pl-fe/src/containers/account-container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useAccount } from 'pl-fe/api/hooks/accounts/use-account'; 4 | import Account, { IAccount } from 'pl-fe/components/account'; 5 | 6 | interface IAccountContainer extends Omit { 7 | id: string; 8 | withRelationship?: boolean; 9 | } 10 | 11 | const AccountContainer: React.FC = ({ id, withRelationship, ...props }) => { 12 | const { account } = useAccount(id, { withRelationship }); 13 | 14 | return ( 15 | 16 | ); 17 | }; 18 | 19 | export { AccountContainer as default }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/custom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions for dealing with custom build configuration. 3 | */ 4 | import * as BuildConfig from 'pl-fe/build-config'; 5 | 6 | /** Require a custom JSON file if it exists */ 7 | const custom = (filename: string, fallback: any = {}): any => { 8 | if (BuildConfig.NODE_ENV === 'test') return fallback; 9 | 10 | const modules = import.meta.glob('../custom/*.json', { eager: true }); 11 | const key = `../../custom/${filename}.json`; 12 | 13 | return modules[key] ? modules[key] : fallback; 14 | }; 15 | 16 | export { custom }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/entity-store/entities.ts: -------------------------------------------------------------------------------- 1 | import type { GroupMember, GroupRelationship, Relationship } from 'pl-api'; 2 | import type { Account } from 'pl-fe/normalizers/account'; 3 | import type { Group } from 'pl-fe/normalizers/group'; 4 | 5 | enum Entities { 6 | ACCOUNTS = 'Accounts', 7 | GROUPS = 'Groups', 8 | GROUP_MEMBERSHIPS = 'GroupMemberships', 9 | GROUP_RELATIONSHIPS = 'GroupRelationships', 10 | RELATIONSHIPS = 'Relationships', 11 | } 12 | 13 | interface EntityTypes { 14 | [Entities.ACCOUNTS]: Account; 15 | [Entities.GROUPS]: Group; 16 | [Entities.GROUP_MEMBERSHIPS]: GroupMember; 17 | [Entities.GROUP_RELATIONSHIPS]: GroupRelationship; 18 | [Entities.RELATIONSHIPS]: Relationship; 19 | } 20 | 21 | export { Entities, type EntityTypes }; 22 | -------------------------------------------------------------------------------- /packages/pl-fe/src/entity-store/hooks/use-transaction.ts: -------------------------------------------------------------------------------- 1 | import { entitiesTransaction } from 'pl-fe/entity-store/actions'; 2 | import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; 3 | 4 | import type { EntityTypes } from 'pl-fe/entity-store/entities'; 5 | import type { EntitiesTransaction, Entity } from 'pl-fe/entity-store/types'; 6 | 7 | type Updater = Record TEntity> 8 | 9 | type Changes = Partial<{ 10 | [K in keyof EntityTypes]: Updater 11 | }> 12 | 13 | const useTransaction = () => { 14 | const dispatch = useAppDispatch(); 15 | 16 | const transaction = (changes: Changes): void => { 17 | dispatch(entitiesTransaction(changes as EntitiesTransaction)); 18 | }; 19 | 20 | return { transaction }; 21 | }; 22 | 23 | export { useTransaction }; 24 | -------------------------------------------------------------------------------- /packages/pl-fe/src/entity-store/hooks/utils.ts: -------------------------------------------------------------------------------- 1 | import type { EntitiesPath, ExpandedEntitiesPath } from './types'; 2 | 3 | const parseEntitiesPath = (expandedPath: ExpandedEntitiesPath) => { 4 | const [entityType, ...listKeys] = expandedPath; 5 | const listKey = (listKeys || []).join(':'); 6 | const path: EntitiesPath = [entityType, listKey]; 7 | 8 | return { 9 | entityType, 10 | listKey, 11 | path, 12 | }; 13 | }; 14 | 15 | export { parseEntitiesPath }; 16 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/auth-login/components/otp-auth-form.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen } from 'pl-fe/jest/test-helpers'; 4 | 5 | import OtpAuthForm from './otp-auth-form'; 6 | 7 | describe('', () => { 8 | it('renders correctly', () => { 9 | render(); 10 | 11 | expect(screen.getByRole('heading')).toHaveTextContent('OTP Login'); 12 | expect(screen.getByTestId('form')).toBeInTheDocument(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/birthdays/date-picker.ts: -------------------------------------------------------------------------------- 1 | import DatePicker from 'react-datepicker'; 2 | import 'react-datepicker/dist/react-datepicker.css'; 3 | 4 | export { DatePicker as default }; 5 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/chats/components/chat-pending-upload.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ProgressBar from 'pl-fe/components/ui/progress-bar'; 4 | 5 | interface IChatPendingUpload { 6 | progress: number; 7 | } 8 | 9 | /** Displays a loading thumbnail for an upload in the chat composer. */ 10 | const ChatPendingUpload: React.FC = ({ progress }) => ( 11 |
12 | 13 |
14 | ); 15 | 16 | export { ChatPendingUpload as default }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/chats/components/chat-widget/chat-widget.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | 4 | import { ChatProvider } from 'pl-fe/contexts/chat-context'; 5 | 6 | import ChatPane from '../chat-pane/chat-pane'; 7 | 8 | const ChatWidget = () => { 9 | const history = useHistory(); 10 | 11 | const path = history.location.pathname; 12 | const isChatsPath = Boolean(path.match(/^\/chats/)); 13 | 14 | if (isChatsPath) { 15 | return null; 16 | } 17 | 18 | return ( 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export { ChatWidget as default }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/chats/components/ui/pane.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | interface IPane { 5 | /** Whether the pane is open or minimized. */ 6 | isOpen: boolean; 7 | /** Children to display in the pane. */ 8 | children: React.ReactNode; 9 | } 10 | 11 | /** Chat pane UI component for desktop. */ 12 | const Pane: React.FC = ({ isOpen = false, children }) => ( 13 |
20 | {children} 21 |
22 | ); 23 | 24 | export { Pane }; 25 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/compose/components/autosuggest-account.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useAccount } from 'pl-fe/api/hooks/accounts/use-account'; 4 | import Account from 'pl-fe/components/account'; 5 | 6 | interface IAutosuggestAccount { 7 | id: string; 8 | } 9 | 10 | const AutosuggestAccount: React.FC = ({ id }) => { 11 | const { account } = useAccount(id); 12 | if (!account) return null; 13 | 14 | return ( 15 |
16 | 17 |
18 | ); 19 | 20 | }; 21 | 22 | export { AutosuggestAccount as default }; 23 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/compose/components/text-character-counter.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | import { length } from 'stringz'; 4 | 5 | interface ITextCharacterCounter { 6 | max: number; 7 | text: string; 8 | } 9 | 10 | const TextCharacterCounter: React.FC = ({ text, max }) => { 11 | const checkRemainingText = (diff: number) => ( 12 | = 0, 15 | 'text-secondary-600': diff < 0, 16 | })} 17 | > 18 | {diff} 19 | 20 | ); 21 | 22 | const diff = max - length(text); 23 | return checkRemainingText(diff); 24 | }; 25 | 26 | export { TextCharacterCounter as default }; 27 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/compose/components/upload-progress.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import UploadProgress from 'pl-fe/components/upload-progress'; 4 | import { useCompose } from 'pl-fe/hooks/use-compose'; 5 | 6 | interface IComposeUploadProgress { 7 | composeId: string; 8 | } 9 | 10 | /** File upload progress bar for post composer. */ 11 | const ComposeUploadProgress: React.FC = ({ composeId }) => { 12 | const compose = useCompose(composeId); 13 | 14 | const active = compose.is_uploading; 15 | const progress = compose.progress; 16 | 17 | if (!active) { 18 | return null; 19 | } 20 | 21 | return ( 22 | 23 | ); 24 | }; 25 | 26 | export { ComposeUploadProgress as default }; 27 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/compose/editor/plugins/ref-plugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 2 | import { LexicalEditor } from 'lexical'; 3 | import React, { useEffect } from 'react'; 4 | 5 | /** Set the ref to the current Lexical editor instance. */ 6 | const RefPlugin = React.forwardRef((_props, ref) => { 7 | const [editor] = useLexicalComposerContext(); 8 | 9 | useEffect(() => { 10 | if (ref && typeof ref !== 'function') { 11 | ref.current = editor; 12 | } 13 | }, [editor]); 14 | 15 | return null; 16 | }); 17 | 18 | export { RefPlugin as default }; 19 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/compose/editor/plugins/submit-plugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 2 | import { KEY_ENTER_COMMAND } from 'lexical'; 3 | import { useEffect } from 'react'; 4 | 5 | interface ISubmitPlugin { 6 | composeId: string; 7 | handleSubmit?: () => void; 8 | } 9 | 10 | const SubmitPlugin: React.FC = ({ composeId, handleSubmit }) => { 11 | const [editor] = useLexicalComposerContext(); 12 | 13 | useEffect(() => { 14 | if (handleSubmit) { 15 | return editor.registerCommand(KEY_ENTER_COMMAND, (event) => { 16 | if (event?.ctrlKey) { 17 | handleSubmit(); 18 | return true; 19 | } 20 | return false; 21 | }, 1); 22 | } 23 | }, [handleSubmit]); 24 | 25 | return null; 26 | }; 27 | 28 | export { SubmitPlugin as default }; 29 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/compose/editor/utils/get-dom-range-rect.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This source code is derived from code from Meta Platforms, Inc. 3 | * and affiliates, licensed under the MIT license located in the 4 | * LICENSE file in the /src/features/compose/editor directory. 5 | */ 6 | 7 | /* eslint-disable eqeqeq */ 8 | 9 | export const getDOMRangeRect = ( 10 | nativeSelection: Selection, 11 | rootElement: HTMLElement, 12 | ): DOMRect => { 13 | const domRange = nativeSelection.getRangeAt(0); 14 | 15 | let rect; 16 | 17 | if (nativeSelection.anchorNode === rootElement) { 18 | let inner = rootElement; 19 | while (inner.firstElementChild != null) { 20 | inner = inner.firstElementChild as HTMLElement; 21 | } 22 | rect = inner.getBoundingClientRect(); 23 | } else { 24 | rect = domRange.getBoundingClientRect(); 25 | } 26 | 27 | return rect; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/compose/util/counter.ts: -------------------------------------------------------------------------------- 1 | import { urlRegex } from './url-regex'; 2 | 3 | const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx'; 4 | 5 | const countableText = (inputText: string) => 6 | inputText 7 | .replace(urlRegex, urlPlaceholder) 8 | .replace(/(^|[^/\w])@(([a-z0-9_]+)@[a-z0-9.-]+[a-z0-9]+)/ig, '$1@$3'); 9 | 10 | export { countableText }; 11 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/crypto-donate/utils/block-explorer.ts: -------------------------------------------------------------------------------- 1 | import blockExplorers from './block-explorers.json'; 2 | 3 | type BlockExplorers = Record; 4 | 5 | const getExplorerUrl = (ticker: string, address: string): string | null => { 6 | const template = (blockExplorers as BlockExplorers)[ticker]; 7 | if (!template) return null; 8 | return template.replace('{address}', address); 9 | }; 10 | 11 | export { getExplorerUrl }; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/crypto-donate/utils/coin-db.ts: -------------------------------------------------------------------------------- 1 | import coinDB from './manifest-map'; 2 | 3 | /** Get title from CoinDB based on ticker symbol */ 4 | const getTitle = (ticker: string): string => { 5 | const title = coinDB[ticker]?.name; 6 | return typeof title === 'string' ? title : ''; 7 | }; 8 | 9 | export { getTitle }; 10 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/crypto-donate/utils/manifest-map.ts: -------------------------------------------------------------------------------- 1 | // Converts cryptocurrency-icon's manifest file from a list to a map. 2 | // See: https://github.com/spothq/cryptocurrency-icons/blob/master/manifest.json 3 | 4 | import manifest from 'cryptocurrency-icons/manifest.json'; 5 | 6 | const manifestMap = manifest.reduce((acc: Record, entry) => { 7 | acc[entry.symbol.toLowerCase()] = entry; 8 | return acc; 9 | }, {}); 10 | 11 | export { manifestMap as default }; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/developers/components/indicator.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | interface IIndicator { 5 | state?: 'active' | 'pending' | 'error' | 'inactive'; 6 | size?: 'sm'; 7 | } 8 | 9 | /** Indicator dot component. */ 10 | const Indicator: React.FC = ({ state = 'inactive', size = 'sm' }) => ( 11 |
20 | ); 21 | 22 | export { Indicator as default }; 23 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/emoji/components/emoji-picker.tsx: -------------------------------------------------------------------------------- 1 | import { Picker as EmojiPicker } from 'emoji-mart'; 2 | import React, { useRef, useEffect } from 'react'; 3 | 4 | import { joinPublicPath } from 'pl-fe/utils/static'; 5 | 6 | import data from '../data'; 7 | 8 | const getSpritesheetURL = (set: string) => require('emoji-datasource/img/twitter/sheets/32.png'); 9 | 10 | const getImageURL = (set: string, name: string) => joinPublicPath(`/packs/emoji/${name}.svg`); 11 | 12 | const Picker: React.FC = (props) => { 13 | const ref = useRef(null); 14 | 15 | useEffect(() => { 16 | const input = { ...props, data, ref, getImageURL, getSpritesheetURL }; 17 | 18 | new EmojiPicker(input); 19 | }, []); 20 | 21 | return
; 22 | }; 23 | 24 | export { Picker as default }; 25 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/emoji/mapping.ts: -------------------------------------------------------------------------------- 1 | interface UnicodeMap { 2 | [s: string]: { 3 | unified: string; 4 | shortcode: string; 5 | }; 6 | } 7 | 8 | export default import.meta.compileTime('./mapping-compiletime.ts'); 9 | 10 | export type { UnicodeMap }; 11 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/groups/components/discover/group-list-item.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { buildGroup } from 'pl-fe/jest/factory'; 4 | import { render, screen } from 'pl-fe/jest/test-helpers'; 5 | 6 | import GroupListItem from './group-list-item'; 7 | 8 | describe(' { 9 | it('should render correctly', () => { 10 | const group = buildGroup({ 11 | display_name: 'group name here', 12 | locked: false, 13 | members_count: 6, 14 | }); 15 | render(); 16 | 17 | expect(screen.getByTestId('group-list-item')).toHaveTextContent(group.display_name); 18 | expect(screen.getByTestId('group-list-item')).toHaveTextContent('Public'); 19 | expect(screen.getByTestId('group-list-item')).toHaveTextContent('6 members'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/pl-fe-config/components/icon-picker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import IconPickerDropdown from './icon-picker-dropdown'; 4 | 5 | interface IIconPicker { 6 | value: string; 7 | onChange: (icon: string) => void; 8 | } 9 | 10 | const IconPicker: React.FC = ({ value, onChange }) => ( 11 |
12 | 13 |
14 | ); 15 | 16 | export { IconPicker as default }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/placeholder/components/placeholder-account.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import HStack from 'pl-fe/components/ui/hstack'; 4 | 5 | import PlaceholderAvatar from './placeholder-avatar'; 6 | import PlaceholderDisplayName from './placeholder-display-name'; 7 | 8 | /** Fake account to display while data is loading. */ 9 | const PlaceholderAccount: React.FC = React.memo(() => ( 10 | 11 |
12 | 13 |
14 | 15 |
16 | 17 |
18 |
19 | )); 20 | 21 | export { PlaceholderAccount as default }; 22 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/placeholder/components/placeholder-card.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | import { randomIntFromInterval, generateText } from '../utils'; 5 | 6 | /** Fake link preview to display while data is loading. */ 7 | const PlaceholderCard: React.FC = React.memo(() => ( 8 |
12 |
 
13 | 14 |
15 |

{generateText(randomIntFromInterval(5, 25))}

16 |

{generateText(randomIntFromInterval(5, 75))}

17 |

{generateText(randomIntFromInterval(5, 15))}

18 |
19 |
20 | )); 21 | 22 | export { PlaceholderCard as default }; 23 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/placeholder/components/placeholder-chat.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import HStack from 'pl-fe/components/ui/hstack'; 4 | import Stack from 'pl-fe/components/ui/stack'; 5 | 6 | import PlaceholderAvatar from './placeholder-avatar'; 7 | import PlaceholderDisplayName from './placeholder-display-name'; 8 | 9 | /** Fake chat to display while data is loading. */ 10 | const PlaceholderChat = () => ( 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | ); 21 | 22 | export { PlaceholderChat as default }; 23 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/placeholder/components/placeholder-hashtag.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { generateText, randomIntFromInterval } from '../utils'; 4 | 5 | /** Fake hashtag to display while data is loading. */ 6 | const PlaceholderHashtag: React.FC = () => { 7 | const length = randomIntFromInterval(15, 30); 8 | 9 | return ( 10 |
11 |

{generateText(length)}

12 |
13 | ); 14 | }; 15 | 16 | export { PlaceholderHashtag as default }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/placeholder/components/placeholder-sidebar-trends.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Stack from 'pl-fe/components/ui/stack'; 4 | 5 | import { randomIntFromInterval, generateText } from '../utils'; 6 | 7 | const PlaceholderSidebarTrends = ({ limit }: { limit: number }) => { 8 | const trend = randomIntFromInterval(6, 3); 9 | const stat = randomIntFromInterval(10, 3); 10 | 11 | return ( 12 | <> 13 | {new Array(limit).fill(undefined).map((_, idx) => ( 14 | 15 |

{generateText(trend)}

16 |

{generateText(stat)}

17 |
18 | ))} 19 | 20 | ); 21 | }; 22 | 23 | export { PlaceholderSidebarTrends as default }; 24 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/placeholder/components/placeholder-status-content.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { randomIntFromInterval, generateText } from '../utils'; 4 | 5 | interface IPlaceholderStatusContent { 6 | maxLength: number; 7 | minLength: number; 8 | } 9 | 10 | /** Fake status content while data is loading. */ 11 | const PlaceholderStatusContent: React.FC = ({ minLength, maxLength }) => { 12 | const length = randomIntFromInterval(maxLength, minLength); 13 | 14 | return ( 15 |
16 |

{generateText(length)}

17 |
18 | ); 19 | }; 20 | 21 | export { PlaceholderStatusContent as default }; 22 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/placeholder/utils.ts: -------------------------------------------------------------------------------- 1 | const PLACEHOLDER_CHAR = '█'; 2 | 3 | const generateText = (length: number) => { 4 | let text = ''; 5 | 6 | for (let i = 0; i < length; i++) { 7 | text += PLACEHOLDER_CHAR; 8 | } 9 | 10 | return text; 11 | }; 12 | 13 | // https://stackoverflow.com/a/7228322/8811886 14 | const randomIntFromInterval = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1) + min); 15 | 16 | export { generateText, randomIntFromInterval }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/status/containers/quoted-status-container.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import QuotedStatus from 'pl-fe/components/quoted-status'; 4 | import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; 5 | import { makeGetStatus } from 'pl-fe/selectors'; 6 | 7 | interface IQuotedStatusContainer { 8 | /** Status ID to the quoted status. */ 9 | statusId: string; 10 | } 11 | 12 | const QuotedStatusContainer: React.FC = ({ statusId }) => { 13 | const getStatus = useCallback(makeGetStatus(), []); 14 | 15 | const status = useAppSelector(state => getStatus(state, { id: statusId })); 16 | 17 | if (!status) { 18 | return null; 19 | } 20 | 21 | return ( 22 | 23 | ); 24 | }; 25 | 26 | export { QuotedStatusContainer as default }; 27 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/theme-editor/components/color.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ColorPicker from 'pl-fe/features/pl-fe-config/components/color-picker'; 4 | 5 | import type { ColorChangeHandler } from 'react-color'; 6 | 7 | interface IColor { 8 | color: string; 9 | onChange?: (color: string) => void; 10 | } 11 | 12 | /** Color input. */ 13 | const Color: React.FC = ({ color, onChange }) => { 14 | 15 | const handleChange: ColorChangeHandler = (result) => { 16 | onChange?.(result.hex); 17 | }; 18 | 19 | return ( 20 | 25 | ); 26 | }; 27 | 28 | export { Color as default }; 29 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/ui/components/background-shapes.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | interface IBackgroundShapes { 5 | /** Whether the shapes should be absolute positioned or fixed. */ 6 | position?: 'fixed' | 'absolute'; 7 | } 8 | 9 | /** Gradient that appears in the background of the UI. */ 10 | const BackgroundShapes: React.FC = ({ position = 'fixed' }) => ( 11 |
12 |
13 |
14 | ); 15 | 16 | export { BackgroundShapes as default }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/ui/components/column-loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Card, { CardBody } from 'pl-fe/components/ui/card'; 4 | import Spinner from 'pl-fe/components/ui/spinner'; 5 | 6 | const ColumnLoading = () => ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | 14 | export { ColumnLoading as default }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/ui/components/hotkeys.tsx: -------------------------------------------------------------------------------- 1 | import { HotKeys as _HotKeys, type HotKeysProps } from '@mkljczk/react-hotkeys'; 2 | import React from 'react'; 3 | 4 | /** 5 | * Wrapper component around `react-hotkeys`. 6 | * `react-hotkeys` is a legacy component, so confining its import to one place is beneficial. 7 | */ 8 | const HotKeys = React.forwardRef(({ children, ...rest }, ref) => ( 9 | <_HotKeys {...rest} ref={ref}> 10 | {children} 11 | 12 | )); 13 | 14 | export { HotKeys }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/ui/components/modal-loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Modal from 'pl-fe/components/ui/modal'; 4 | import Spinner from 'pl-fe/components/ui/spinner'; 5 | 6 | const ModalLoading = () => ( 7 | 8 | 9 | 10 | ); 11 | 12 | export { ModalLoading as default }; 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/ui/components/panels/profile-fields-panel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Stack from 'pl-fe/components/ui/stack'; 4 | import Widget from 'pl-fe/components/ui/widget'; 5 | 6 | import ProfileField from '../profile-field'; 7 | 8 | import type { Account } from 'pl-fe/normalizers/account'; 9 | 10 | interface IProfileFieldsPanel { 11 | account: Pick; 12 | } 13 | 14 | /** Custom profile fields for sidebar. */ 15 | const ProfileFieldsPanel: React.FC = ({ account }) => ( 16 | 17 | 18 | {account.fields.map((field, i) => ( 19 | 20 | ))} 21 | 22 | 23 | ); 24 | 25 | export { ProfileFieldsPanel as default }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/ui/components/panels/sign-up-panel.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { storeOpen } from 'pl-fe/jest/mock-stores'; 4 | import { render, screen } from 'pl-fe/jest/test-helpers'; 5 | 6 | import SignUpPanel from './sign-up-panel'; 7 | 8 | describe('', () => { 9 | it('doesn\'t render by default', () => { 10 | render(); 11 | expect(screen.queryByTestId('sign-up-panel')).not.toBeInTheDocument(); 12 | }); 13 | 14 | describe('with registrations enabled', () => { 15 | it('successfully renders', () => { 16 | render(, undefined, storeOpen); 17 | expect(screen.getByTestId('sign-up-panel')).toBeInTheDocument(); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/ui/components/subscribe-button.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { buildAccount, buildRelationship } from 'pl-fe/jest/factory'; 4 | import { render, screen } from 'pl-fe/jest/test-helpers'; 5 | 6 | import SubscribeButton from './subscription-button'; 7 | 8 | const justin = { 9 | id: '1', 10 | acct: 'justin-username', 11 | display_name: 'Justin L', 12 | avatar: 'test.jpg', 13 | }; 14 | 15 | describe('', () => { 16 | let store: any; 17 | 18 | describe('with "accountNotifies" disabled', () => { 19 | it('renders nothing', () => { 20 | const account = buildAccount({ ...justin, relationship: buildRelationship({ following: true }) }); 21 | 22 | render(, undefined, store); 23 | expect(screen.queryAllByTestId('icon-button')).toHaveLength(0); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/ui/components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { changeSetting } from 'pl-fe/actions/settings'; 4 | import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; 5 | import { useSettings } from 'pl-fe/hooks/use-settings'; 6 | 7 | import ThemeSelector from './theme-selector'; 8 | 9 | /** Stateful theme selector. */ 10 | const ThemeToggle: React.FC = () => { 11 | const dispatch = useAppDispatch(); 12 | const { themeMode } = useSettings(); 13 | 14 | const handleChange = (themeMode: string) => { 15 | dispatch(changeSetting(['themeMode'], themeMode)); 16 | }; 17 | 18 | return ( 19 | 23 | ); 24 | }; 25 | 26 | export { ThemeToggle as default }; 27 | -------------------------------------------------------------------------------- /packages/pl-fe/src/features/ui/util/optional-motion.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Motion, MotionProps } from 'react-motion'; 3 | 4 | import { useSettings } from 'pl-fe/hooks/use-settings'; 5 | 6 | import ReducedMotion from './reduced-motion'; 7 | 8 | const OptionalMotion = (props: MotionProps) => { 9 | const { reduceMotion } = useSettings(); 10 | 11 | return ( 12 | reduceMotion ? : 13 | ); 14 | }; 15 | 16 | export default OptionalMotion; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/__mocks__/resize-observer.ts: -------------------------------------------------------------------------------- 1 | let listener: ((rect: any) => void) | undefined = undefined; 2 | const mockDisconnect = vi.fn(); 3 | 4 | class ResizeObserver { 5 | 6 | constructor(ls: any) { 7 | listener = ls; 8 | } 9 | 10 | observe() { 11 | // do nothing 12 | } 13 | unobserve() { 14 | // do nothing 15 | } 16 | disconnect() { 17 | mockDisconnect(); 18 | } 19 | 20 | } 21 | 22 | // eslint-disable-next-line compat/compat 23 | (window as any).ResizeObserver = ResizeObserver; 24 | 25 | export { ResizeObserver as default, listener, mockDisconnect }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/forms/use-preview.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | /** Return a preview URL for a file. */ 4 | const usePreview = (file: File | null | undefined): string | undefined => useMemo(() => { 5 | if (file) { 6 | return URL.createObjectURL(file); 7 | } 8 | }, [file]); 9 | 10 | export { usePreview }; 11 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/forms/use-text-field.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** 4 | * Returns props for ``. 5 | * If `initialValue` changes from undefined to a string, it will set the value. 6 | */ 7 | const useTextField = (initialValue?: string | undefined) => { 8 | const [value, setValue] = useState(initialValue); 9 | const hasInitialValue = typeof initialValue === 'string'; 10 | 11 | const onChange: React.ChangeEventHandler = (e) => { 12 | setValue(e.target.value); 13 | }; 14 | 15 | useEffect(() => { 16 | if (hasInitialValue) { 17 | setValue(initialValue); 18 | } 19 | }, [hasInitialValue]); 20 | 21 | return { 22 | value: value || '', 23 | onChange, 24 | }; 25 | }; 26 | 27 | export { useTextField }; 28 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-app-dispatch.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux'; 2 | 3 | import type { AppDispatch } from 'pl-fe/store'; 4 | 5 | const useAppDispatch = () => useDispatch(); 6 | 7 | export { useAppDispatch }; 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-app-selector.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useSelector } from 'react-redux'; 2 | 3 | import type { RootState } from 'pl-fe/store'; 4 | 5 | const useAppSelector: TypedUseSelectorHook = useSelector; 6 | 7 | export { useAppSelector }; 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-client.ts: -------------------------------------------------------------------------------- 1 | import { getClient } from 'pl-fe/api'; 2 | 3 | import { useAppSelector } from './use-app-selector'; 4 | 5 | const useClient = () => useAppSelector(getClient); 6 | 7 | export { useClient }; 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-compose.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from './use-app-selector'; 2 | 3 | import type { Compose } from 'pl-fe/reducers/compose'; 4 | 5 | /** Get compose for given key with fallback to 'default' */ 6 | const useCompose = (composeId: ID extends 'default' ? never : ID): Compose => 7 | useAppSelector((state) => state.compose[composeId] || state.compose.default); 8 | 9 | export { useCompose }; 10 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-debounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const useDebounce = (value: string, delay: number): string => { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => setDebouncedValue(value), delay); 8 | 9 | return () => { 10 | clearTimeout(timer); 11 | }; 12 | }, [value, delay]); 13 | 14 | return debouncedValue; 15 | }; 16 | 17 | export { useDebounce }; 18 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-features.ts: -------------------------------------------------------------------------------- 1 | import { Features } from 'pl-api'; 2 | 3 | import { useAppSelector } from './use-app-selector'; 4 | import { useInstance } from './use-instance'; 5 | 6 | /** Get features for the current instance. */ 7 | const useFeatures = (): Features => { 8 | useInstance(); 9 | const features = useAppSelector(state => state.auth.client.features); 10 | 11 | return features; 12 | }; 13 | 14 | export { useFeatures }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-get-state.ts: -------------------------------------------------------------------------------- 1 | import { useAppDispatch } from './use-app-dispatch'; 2 | 3 | import type { RootState } from 'pl-fe/store'; 4 | 5 | /** 6 | * Provides a `getState()` function to hooks. 7 | * You should prefer `useAppSelector` when possible. 8 | */ 9 | const useGetState = () => { 10 | const dispatch = useAppDispatch(); 11 | return () => dispatch((_, getState: () => RootState) => getState()); 12 | }; 13 | 14 | export { useGetState }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-instance.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from './use-app-selector'; 2 | 3 | /** Get the Instance for the current backend. */ 4 | const useInstance = () => useAppSelector((state) => state.instance); 5 | 6 | export { useInstance }; 7 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-is-mobile.ts: -------------------------------------------------------------------------------- 1 | import { useScreenWidth } from './use-screen-width'; 2 | 3 | export function useIsMobile() { 4 | const screenWidth = useScreenWidth(); 5 | return screenWidth <= 581; 6 | } 7 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-loading.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useLoading = (initialState: boolean = false) => { 4 | const [isLoading, setIsLoading] = useState(initialState); 5 | 6 | const setPromise = (promise: Promise) => { 7 | setIsLoading(true); 8 | 9 | promise 10 | .then(() => setIsLoading(false)) 11 | .catch(() => setIsLoading(false)); 12 | 13 | return promise; 14 | }; 15 | 16 | return [isLoading, setPromise] as const; 17 | }; 18 | 19 | export { useLoading }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-locale.ts: -------------------------------------------------------------------------------- 1 | import messages from 'pl-fe/messages'; 2 | import { useSettingsStore } from 'pl-fe/stores/settings'; 3 | 4 | /** Locales which should be presented in right-to-left. */ 5 | const RTL_LOCALES = ['ar', 'ckb', 'fa', 'he']; 6 | 7 | /** Get valid locale from settings. */ 8 | const useLocale = (fallback = 'en') => { 9 | const localeWithVariant = useSettingsStore().settings.locale.replace('_', '-'); 10 | const localeFirstPart = localeWithVariant.split('-')[0]; 11 | return Object.keys(messages).includes(localeWithVariant) ? localeWithVariant : Object.keys(messages).includes(localeFirstPart) ? localeFirstPart : fallback; 12 | }; 13 | 14 | const useLocaleDirection = (locale = 'en') => RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr'; 15 | 16 | export { useLocale, useLocaleDirection }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-logged-in.ts: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from './use-app-selector'; 2 | 3 | const useLoggedIn = () => { 4 | const me = useAppSelector(state => state.me); 5 | 6 | return { 7 | isLoggedIn: typeof me === 'string', 8 | isLoginLoading: me === null, 9 | isLoginFailed: me === false, 10 | me, 11 | }; 12 | }; 13 | 14 | export { useLoggedIn }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-logo.ts: -------------------------------------------------------------------------------- 1 | import { usePlFeConfig } from './use-pl-fe-config'; 2 | import { useSettings } from './use-settings'; 3 | import { useTheme } from './use-theme'; 4 | 5 | const useLogo = () => { 6 | const { logo, logoDarkMode, logoAlignment } = usePlFeConfig(); 7 | const { demo } = useSettings(); 8 | 9 | const darkMode = ['dark', 'black'].includes(useTheme()); 10 | 11 | // Use the right logo if provided, otherwise return null; 12 | const src = (darkMode && logoDarkMode) 13 | ? logoDarkMode 14 | : logo || logoDarkMode; 15 | 16 | return { src: demo ? null : src, alignment: logoAlignment }; 17 | }; 18 | 19 | export { useLogo }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-own-account.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { makeGetAccount } from 'pl-fe/selectors'; 4 | 5 | import { useAppSelector } from './use-app-selector'; 6 | 7 | /** Get the logged-in account from the store, if any. */ 8 | const useOwnAccount = () => { 9 | const getAccount = useCallback(makeGetAccount(), []); 10 | 11 | const account = useAppSelector((state) => { 12 | const { me } = state; 13 | 14 | if (typeof me === 'string') { 15 | return getAccount(state, me); 16 | } 17 | }); 18 | 19 | return { account: account || undefined }; 20 | }; 21 | 22 | export { 23 | useOwnAccount, 24 | }; 25 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-pl-fe-config.ts: -------------------------------------------------------------------------------- 1 | import { getPlFeConfig } from 'pl-fe/actions/pl-fe'; 2 | 3 | import { useAppSelector } from './use-app-selector'; 4 | 5 | /** Get the pl-fe config from the store */ 6 | const usePlFeConfig = () => useAppSelector((state) => getPlFeConfig(state)); 7 | 8 | export { usePlFeConfig }; 9 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-previous.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | 3 | /** Get the last version of this value. */ 4 | // https://usehooks.com/usePrevious/ 5 | const usePrevious = (value: T): T | undefined => { 6 | const ref = useRef(); 7 | 8 | useEffect(() => { 9 | ref.current = value; 10 | }, [value]); 11 | 12 | return ref.current; 13 | }; 14 | 15 | export { usePrevious }; 16 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-registration-status.ts: -------------------------------------------------------------------------------- 1 | import { useFeatures } from './use-features'; 2 | import { useInstance } from './use-instance'; 3 | 4 | const useRegistrationStatus = () => { 5 | const instance = useInstance(); 6 | const features = useFeatures(); 7 | 8 | return { 9 | /** Registrations are open. */ 10 | isOpen: features.accountCreation && instance.registrations.enabled, 11 | }; 12 | }; 13 | 14 | export { useRegistrationStatus }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-screen-width.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export function useScreenWidth() { 4 | const [screenWidth, setScreenWidth] = useState(window.innerWidth); 5 | 6 | useEffect(() => { 7 | const checkWindowSize = () => { 8 | setScreenWidth(window.innerWidth); 9 | }; 10 | 11 | window.addEventListener('resize', checkWindowSize); 12 | 13 | return () => { 14 | window.removeEventListener('resize', checkWindowSize); 15 | }; 16 | }, []); 17 | 18 | return screenWidth; 19 | } 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-settings.ts: -------------------------------------------------------------------------------- 1 | import { useSettingsStore } from 'pl-fe/stores/settings'; 2 | 3 | /** Get the user settings from the store */ 4 | const useSettings = () => useSettingsStore().settings; 5 | 6 | export { useSettings }; 7 | -------------------------------------------------------------------------------- /packages/pl-fe/src/hooks/use-theme.ts: -------------------------------------------------------------------------------- 1 | import { useSettings } from './use-settings'; 2 | import { useSystemTheme } from './use-system-theme'; 3 | 4 | type Theme = 'light' | 'dark' | 'black'; 5 | 6 | /** 7 | * Returns the actual theme being displayed (eg "light" or "dark") 8 | * regardless of whether that's by system theme or direct setting. 9 | */ 10 | const useTheme = (): Theme => { 11 | const { themeMode } = useSettings(); 12 | const systemTheme = useSystemTheme(); 13 | 14 | return themeMode === 'system' ? systemTheme : themeMode; 15 | }; 16 | 17 | export { useTheme }; 18 | -------------------------------------------------------------------------------- /packages/pl-fe/src/iframe.ts: -------------------------------------------------------------------------------- 1 | /** ID of this iframe (given by embed.js) when embedded on a page. */ 2 | let iframeId: string; 3 | 4 | /** Receive iframe messages. */ 5 | // https://github.com/mastodon/mastodon/pull/4853 6 | const handleMessage = (e: MessageEvent) => { 7 | if (e.data?.type === 'setHeight') { 8 | iframeId = e.data?.id; 9 | } 10 | }; 11 | 12 | window.addEventListener('message', handleMessage); 13 | 14 | export { iframeId }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/instance/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/instance/images/logo.png -------------------------------------------------------------------------------- /packages/pl-fe/src/instance/images/shortcuts/chats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/instance/images/shortcuts/chats.png -------------------------------------------------------------------------------- /packages/pl-fe/src/instance/images/shortcuts/notifications.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/instance/images/shortcuts/notifications.png -------------------------------------------------------------------------------- /packages/pl-fe/src/instance/images/shortcuts/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mkljczk/pl-fe/08822a32d8d1fb38b786f98d8e9ff44bdf573af6/packages/pl-fe/src/instance/images/shortcuts/search.png -------------------------------------------------------------------------------- /packages/pl-fe/src/is-mobile.ts: -------------------------------------------------------------------------------- 1 | /** Breakpoint at which the application is considered "mobile". */ 2 | const LAYOUT_BREAKPOINT = 630; 3 | 4 | /** Check if the width is small enough to be considered "mobile". */ 5 | const isMobile = (width: number) => width <= LAYOUT_BREAKPOINT; 6 | 7 | /** Whether the device is iOS (best guess). */ 8 | const iOS: boolean = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream; 9 | 10 | const userTouching = window.matchMedia('(pointer: coarse)'); 11 | 12 | /** Whether the device is iOS (best guess). */ 13 | const isIOS = (): boolean => iOS; 14 | 15 | export { isMobile, userTouching, isIOS }; 16 | -------------------------------------------------------------------------------- /packages/pl-fe/src/jest/mock-stores.tsx: -------------------------------------------------------------------------------- 1 | import { instanceSchema } from 'pl-api'; 2 | import * as v from 'valibot'; 3 | 4 | /** Store with registrations open. */ 5 | const storeOpen = { instance: v.parse(instanceSchema, { registrations: true }) }; 6 | 7 | export { 8 | storeOpen, 9 | }; 10 | -------------------------------------------------------------------------------- /packages/pl-fe/src/layouts/admin-layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Layout from 'pl-fe/components/ui/layout'; 4 | import { LatestAccountsPanel } from 'pl-fe/features/ui/util/async-components'; 5 | 6 | import LinkFooter from '../features/ui/components/link-footer'; 7 | 8 | interface IAdminLayout { 9 | children: React.ReactNode; 10 | } 11 | 12 | const AdminLayout: React.FC = ({ children }) => ( 13 | <> 14 | 15 | {children} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | 25 | export { AdminLayout as default }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/layouts/chats-layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface IChatsLayout { 4 | children: React.ReactNode; 5 | } 6 | 7 | /** Custom layout for chats on desktop. */ 8 | const ChatsLayout: React.FC = ({ children }) => ( 9 |
10 | {children} 11 |
12 | ); 13 | 14 | export { ChatsLayout as default }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/layouts/empty-layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Layout from 'pl-fe/components/ui/layout'; 4 | 5 | interface IEmptyLayout { 6 | children: React.ReactNode; 7 | } 8 | 9 | const EmptyLayout: React.FC = ({ children }) => ( 10 | <> 11 | 12 | {children} 13 | 14 | 15 | 16 | 17 | ); 18 | 19 | export { EmptyLayout as default }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/layouts/manage-groups-layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Layout from 'pl-fe/components/ui/layout'; 4 | import LinkFooter from 'pl-fe/features/ui/components/link-footer'; 5 | import { MyGroupsPanel, NewGroupPanel } from 'pl-fe/features/ui/util/async-components'; 6 | 7 | interface IGroupsLayout { 8 | children: React.ReactNode; 9 | } 10 | 11 | /** Layout to display groups. */ 12 | const ManageGroupsLayout: React.FC = ({ children }) => ( 13 | <> 14 | 15 | {children} 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | 26 | export { ManageGroupsLayout as default }; 27 | -------------------------------------------------------------------------------- /packages/pl-fe/src/middleware/sounds.ts: -------------------------------------------------------------------------------- 1 | import { play, soundCache } from 'pl-fe/utils/sounds'; 2 | 3 | import type { Sounds } from 'pl-fe/utils/sounds'; 4 | import type { AnyAction, Middleware } from 'redux'; 5 | 6 | interface Action extends AnyAction { 7 | meta: { 8 | sound: Sounds; 9 | }; 10 | } 11 | 12 | /** Middleware to play sounds in response to certain Redux actions. */ 13 | const soundsMiddleware = (): Middleware => () => next => anyAction => { 14 | const action = anyAction as Action; 15 | if (action.meta?.sound && soundCache[action.meta.sound]) { 16 | play(soundCache[action.meta.sound]); 17 | } 18 | 19 | return next(action); 20 | }; 21 | 22 | export { 23 | soundsMiddleware as default, 24 | }; 25 | -------------------------------------------------------------------------------- /packages/pl-fe/src/modals/component-modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Modal from 'pl-fe/components/ui/modal'; 4 | 5 | import type { BaseModalProps } from 'pl-fe/features/ui/components/modal-root'; 6 | 7 | interface ComponentModalProps { 8 | component: React.ComponentType; 9 | componentProps: Record; 10 | } 11 | 12 | const ComponentModal: React.FC = ({ onClose, component: Component, componentProps = {} }) => ( 13 | 14 | 15 | 16 | ); 17 | 18 | export { ComponentModal as default, type ComponentModalProps }; 19 | -------------------------------------------------------------------------------- /packages/pl-fe/src/modals/crypto-donate-modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Modal from 'pl-fe/components/ui/modal'; 4 | import DetailedCryptoAddress from 'pl-fe/features/crypto-donate/components/detailed-crypto-address'; 5 | 6 | import type { ICryptoAddress } from 'pl-fe/features/crypto-donate/components/crypto-address'; 7 | import type { BaseModalProps } from 'pl-fe/features/ui/components/modal-root'; 8 | 9 | const CryptoDonateModal: React.FC = ({ onClose, ...props }) => { 10 | 11 | return ( 12 | 13 | 14 | 15 | ); 16 | 17 | }; 18 | 19 | export { CryptoDonateModal as default }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/normalizers/account.ts: -------------------------------------------------------------------------------- 1 | import type { Account as BaseAccount } from 'pl-api'; 2 | 3 | const normalizeAccount = (account: BaseAccount) => { 4 | const missingAvatar: string = require('pl-fe/assets/images/avatar-missing.png'); 5 | const missingHeader: string = require('pl-fe/assets/images/header-missing.png'); 6 | 7 | return { 8 | mute_expires_at: null, 9 | ...account, 10 | avatar: account.avatar || account.avatar_static || missingAvatar, 11 | avatar_static: account.avatar_static || account.avatar || missingAvatar, 12 | header: account.header || account.header_static || missingHeader, 13 | header_static: account.header_static || account.header || missingHeader, 14 | }; 15 | }; 16 | 17 | type Account = ReturnType; 18 | 19 | export { normalizeAccount, type Account }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/normalizers/admin-report.ts: -------------------------------------------------------------------------------- 1 | import type { AdminReport as BaseAdminReport } from 'pl-api'; 2 | 3 | const normalizeAdminReport = ({ 4 | account, target_account, action_taken_by_account, assigned_account, statuses, ...report 5 | }: BaseAdminReport) => ({ 6 | ...report, 7 | account_id: account?.id || null, 8 | target_account_id: target_account?.id || null, 9 | action_taken_by_account_id: action_taken_by_account?.id || null, 10 | assigned_account_id: assigned_account?.id || null, 11 | status_ids: statuses.map(status => status.id), 12 | }); 13 | 14 | type AdminReport = ReturnType; 15 | 16 | export { normalizeAdminReport, type AdminReport }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/normalizers/chat-message.ts: -------------------------------------------------------------------------------- 1 | import type { ChatMessage as BaseChatMessage } from 'pl-api'; 2 | 3 | const normalizeChatMessage = (chatMessage: BaseChatMessage & { pending?: boolean; deleting?: boolean }) => ({ 4 | type: 'message' as const, 5 | pending: false, 6 | deleting: false, 7 | ...chatMessage, 8 | }); 9 | 10 | type ChatMessage = ReturnType; 11 | 12 | export { normalizeChatMessage, type ChatMessage }; 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/normalizers/group-member.ts: -------------------------------------------------------------------------------- 1 | import { normalizeAccount } from './account'; 2 | 3 | import type { GroupMember as BaseGroupMember } from 'pl-api'; 4 | 5 | const normalizeGroupMember = (groupMember: BaseGroupMember) => ({ 6 | ...groupMember, 7 | account: normalizeAccount(groupMember.account), 8 | }); 9 | 10 | type GroupMember = ReturnType; 11 | 12 | export { normalizeGroupMember, type GroupMember }; 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/normalizers/notification.ts: -------------------------------------------------------------------------------- 1 | import omit from 'lodash/omit'; 2 | 3 | import type { Notification as BaseNotification, NotificationGroup } from 'pl-api'; 4 | 5 | const normalizeNotification = (notification: BaseNotification): NotificationGroup => ({ 6 | ...(omit(notification, ['account', 'status', 'target'])), 7 | group_key: notification.id, 8 | notifications_count: 1, 9 | most_recent_notification_id: notification.id, 10 | page_min_id: notification.id, 11 | page_max_id: notification.id, 12 | latest_page_notification_at: notification.created_at, 13 | sample_account_ids: [notification.account.id], 14 | // @ts-ignore 15 | status_id: notification.status?.id, 16 | // @ts-ignore 17 | target_id: notification.target?.id, 18 | }); 19 | 20 | export { normalizeNotification }; 21 | -------------------------------------------------------------------------------- /packages/pl-fe/src/pages/auth/external-login.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedMessage } from 'react-intl'; 3 | 4 | import { BigCard } from 'pl-fe/components/big-card'; 5 | import ExternalLoginForm from 'pl-fe/features/external-login/components/external-login-form'; 6 | 7 | /** Page for logging into a remote instance */ 8 | const ExternalLoginPage: React.FC = () => ( 9 | }> 10 | 11 | 12 | ); 13 | 14 | export { ExternalLoginPage as default }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/pages/auth/logout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { Redirect } from 'react-router-dom'; 4 | 5 | import { logOut } from 'pl-fe/actions/auth'; 6 | import Spinner from 'pl-fe/components/ui/spinner'; 7 | 8 | /** Component that logs the user out when rendered */ 9 | const LogoutPage: React.FC = () => { 10 | const dispatch = useDispatch(); 11 | const [done, setDone] = useState(false); 12 | 13 | useEffect(() => { 14 | dispatch(logOut() as any) 15 | .then(() => setDone(true)) 16 | .catch(console.warn); 17 | }, []); 18 | 19 | if (done) { 20 | return ; 21 | } else { 22 | return ; 23 | } 24 | }; 25 | 26 | export { LogoutPage as default }; 27 | -------------------------------------------------------------------------------- /packages/pl-fe/src/pages/chats/chats.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ChatProvider } from 'pl-fe/contexts/chat-context'; 4 | import ChatPage from 'pl-fe/features/chats/components/chat-page/chat-page'; 5 | 6 | interface IChatIndex { 7 | params?: { 8 | chatId?: string; 9 | }; 10 | } 11 | 12 | const ChatIndex: React.FC = ({ params }) => ( 13 | 14 | 15 | 16 | ); 17 | 18 | export { ChatIndex as default }; 19 | -------------------------------------------------------------------------------- /packages/pl-fe/src/pages/utils/generic-not-found.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import MissingIndicator from '../../components/missing-indicator'; 4 | 5 | const GenericNotFoundPage = () => ( 6 | 7 | ); 8 | 9 | export { GenericNotFoundPage as default }; 10 | -------------------------------------------------------------------------------- /packages/pl-fe/src/pages/utils/intentional-error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * IntentionalError: 5 | * For testing logging/monitoring & previewing ErrorBoundary design. 6 | */ 7 | const IntentionalErrorPage: React.FC = () => { 8 | throw new Error('This error is intentional.'); 9 | }; 10 | 11 | export { IntentionalErrorPage as default }; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/pages/utils/new-status.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | 4 | import { useModalsStore } from 'pl-fe/stores/modals'; 5 | 6 | const NewStatusPage = () => { 7 | const { openModal } = useModalsStore(); 8 | 9 | useEffect(() => { 10 | openModal('COMPOSE'); 11 | }, []); 12 | 13 | return ( 14 | 15 | ); 16 | }; 17 | 18 | export { NewStatusPage as default }; 19 | -------------------------------------------------------------------------------- /packages/pl-fe/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'; 2 | import 'intersection-observer'; 3 | import { install as installResizeObserver } from 'resize-observer'; 4 | 5 | // Needed by @tanstack/virtual, I guess 6 | if (!window.ResizeObserver) { 7 | installResizeObserver(); 8 | } 9 | -------------------------------------------------------------------------------- /packages/pl-fe/src/precheck.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Precheck: information about the site before anything renders. 3 | * @module pl-fe/precheck 4 | */ 5 | 6 | /** Whether pre-rendered data exists in Pleroma's format. */ 7 | const hasPrerenderPleroma = Boolean(document.getElementById('initial-results')); 8 | 9 | /** Whether pre-rendered data exists in Mastodon's format. */ 10 | const hasPrerenderMastodon = Boolean(document.getElementById('initial-state')); 11 | 12 | /** Whether initial data was loaded into the page by server-side-rendering (SSR). */ 13 | const isPrerendered = hasPrerenderPleroma || hasPrerenderMastodon; 14 | 15 | export { isPrerendered }; 16 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/__mocks__/client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | const queryClient = new QueryClient({ 4 | defaultOptions: { 5 | queries: { 6 | staleTime: 0, 7 | gcTime: Infinity, 8 | retry: false, 9 | }, 10 | }, 11 | }); 12 | 13 | export { queryClient }; 14 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/account-lists/use-blocks.ts: -------------------------------------------------------------------------------- 1 | import { makePaginatedResponseQuery } from '../utils/make-paginated-response-query'; 2 | import { minifyAccountList } from '../utils/minify-list'; 3 | 4 | const useBlocks = makePaginatedResponseQuery( 5 | () => ['accountsLists', 'blocked'], 6 | (client) => client.filtering.getBlocks({ with_relationships: true }).then(minifyAccountList), 7 | ); 8 | 9 | const useMutes = makePaginatedResponseQuery( 10 | () => ['accountsLists', 'muted'], 11 | (client) => client.filtering.getMutes({ with_relationships: true }).then(minifyAccountList), 12 | ); 13 | 14 | export { useBlocks, useMutes }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/accounts/account-scrobble.ts: -------------------------------------------------------------------------------- 1 | import { queryOptions } from '@tanstack/react-query'; 2 | 3 | import { getClient } from 'pl-fe/api'; 4 | 5 | const accountScrobbleQueryOptions = (accountId?: string) => queryOptions({ 6 | queryKey: ['scrobbles', accountId!], 7 | queryFn: async () => (await getClient().accounts.getScrobbles(accountId!, { limit: 1 })).items[0] || null, 8 | placeholderData: undefined, 9 | enabled: () => !!accountId && getClient().features.scrobbles, 10 | staleTime: 3 * 60 * 1000, 11 | }); 12 | 13 | export { accountScrobbleQueryOptions }; 14 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/accounts/use-birthday-reminders.ts: -------------------------------------------------------------------------------- 1 | 2 | import { useQuery } from '@tanstack/react-query'; 3 | 4 | import { importEntities } from 'pl-fe/actions/importer'; 5 | import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; 6 | import { useClient } from 'pl-fe/hooks/use-client'; 7 | 8 | const useBirthdayReminders = (month: number, day: number) => { 9 | const client = useClient(); 10 | const dispatch = useAppDispatch(); 11 | 12 | return useQuery({ 13 | queryKey: ['accountsLists', 'birthdayReminders', month, day], 14 | queryFn: () => client.accounts.getBirthdays(day, month).then((accounts) => { 15 | dispatch(importEntities({ accounts })); 16 | 17 | return accounts.map(({ id }) => id); 18 | }), 19 | }); 20 | }; 21 | 22 | export { useBirthdayReminders }; 23 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/accounts/use-endorsed-accounts.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { importEntities } from 'pl-fe/actions/importer'; 4 | import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; 5 | import { useClient } from 'pl-fe/hooks/use-client'; 6 | 7 | const useEndorsedAccounts = (accountId: string) => { 8 | const client = useClient(); 9 | const dispatch = useAppDispatch(); 10 | 11 | return useQuery({ 12 | queryKey: ['accountsLists', 'endorsedAccounts', accountId], 13 | queryFn: () => client.accounts.getAccountEndorsements(accountId).then(({ items: accounts }) => { 14 | dispatch(importEntities({ accounts })); 15 | return accounts.map(({ id }) => id); 16 | }), 17 | }); 18 | }; 19 | 20 | export { useEndorsedAccounts }; 21 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | 3 | const queryClient = new QueryClient({ 4 | defaultOptions: { 5 | queries: { 6 | refetchOnWindowFocus: false, 7 | staleTime: 60000, // 1 minute 8 | gcTime: Infinity, 9 | retry: false, 10 | }, 11 | }, 12 | }); 13 | 14 | export { queryClient }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/events/use-event-participations.ts: -------------------------------------------------------------------------------- 1 | import { makePaginatedResponseQuery } from 'pl-fe/queries/utils/make-paginated-response-query'; 2 | import { minifyAccountList } from 'pl-fe/queries/utils/minify-list'; 3 | 4 | const useEventParticipations = makePaginatedResponseQuery( 5 | (statusId: string) => ['accountsLists', 'eventParticipations', statusId], 6 | (client, params) => client.events.getEventParticipations(...params).then(minifyAccountList), 7 | ); 8 | 9 | export { useEventParticipations }; 10 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/hashtags/use-hashtag.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { useClient } from 'pl-fe/hooks/use-client'; 4 | 5 | const useHashtag = (tag: string) => { 6 | const client = useClient(); 7 | 8 | return useQuery({ 9 | queryKey: ['hashtags', tag.toLocaleLowerCase()], 10 | queryFn: () => client.myAccount.getTag(tag), 11 | }); 12 | }; 13 | 14 | export { useHashtag }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/pl-fe/use-about-page.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { staticFetch } from 'pl-fe/api'; 4 | 5 | const fetchAboutPage = async (slug: string, locale?: string) => { 6 | const filename = `${slug}${locale ? `.${locale}` : ''}.html`; 7 | 8 | const { data } = await staticFetch(`/instance/about/${filename}`); 9 | 10 | return data; 11 | }; 12 | 13 | const useAboutPage = (slug = 'index', locale?: string) => 14 | useQuery({ 15 | queryKey: ['pl-fe', 'aboutPages', slug, locale], 16 | queryFn: () => fetchAboutPage(slug, locale), 17 | }); 18 | 19 | export { useAboutPage }; 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/relationships.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query'; 2 | 3 | import { useClient } from 'pl-fe/hooks/use-client'; 4 | 5 | const useFetchRelationships = () => { 6 | const client = useClient(); 7 | 8 | return useMutation({ 9 | mutationFn: ({ accountIds }: { accountIds: string[]}) => 10 | client.accounts.getRelationships(accountIds), 11 | }); 12 | }; 13 | 14 | export { useFetchRelationships }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/search/use-search-location.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { useClient } from 'pl-fe/hooks/use-client'; 4 | 5 | const useSearchLocation = (query: string) => { 6 | const client = useClient(); 7 | 8 | return useQuery({ 9 | queryKey: ['search', 'location', query], 10 | queryFn: ({ signal }) => client.search.searchLocation(query, { signal }), 11 | gcTime: 60 * 1000, 12 | enabled: !!query.trim(), 13 | }); 14 | }; 15 | 16 | export { useSearchLocation }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/statuses/use-status-quotes.ts: -------------------------------------------------------------------------------- 1 | import { makePaginatedResponseQuery } from 'pl-fe/queries/utils/make-paginated-response-query'; 2 | import { minifyStatusList } from 'pl-fe/queries/utils/minify-list'; 3 | 4 | const useStatusQuotes = makePaginatedResponseQuery( 5 | (statusId: string) => ['statusLists', 'quotes', statusId], 6 | (client, params) => client.statuses.getStatusQuotes(...params).then(minifyStatusList), 7 | ); 8 | 9 | export { useStatusQuotes }; 10 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/statuses/use-status-translation.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { useClient } from 'pl-fe/hooks/use-client'; 4 | 5 | import type { Translation } from 'pl-api'; 6 | 7 | const useStatusTranslation = (statusId: string, targetLanguage?: string) => { 8 | const client = useClient(); 9 | 10 | return useQuery({ 11 | queryKey: ['statuses', 'translations', statusId, targetLanguage], 12 | queryFn: () => client.statuses.translateStatus(statusId, targetLanguage) 13 | .then(translation => translation).catch(() => false), 14 | enabled: !!targetLanguage, 15 | }); 16 | }; 17 | 18 | export { useStatusTranslation }; 19 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/trends.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { useClient } from 'pl-fe/hooks/use-client'; 4 | import { useFeatures } from 'pl-fe/hooks/use-features'; 5 | import { useLoggedIn } from 'pl-fe/hooks/use-logged-in'; 6 | 7 | import type { Tag } from 'pl-api'; 8 | 9 | const useTrends = () => { 10 | const client = useClient(); 11 | const features = useFeatures(); 12 | const { isLoggedIn } = useLoggedIn(); 13 | 14 | const result = useQuery>({ 15 | queryKey: ['trends', 'tags'], 16 | queryFn: () => client.trends.getTrendingTags(), 17 | placeholderData: [], 18 | staleTime: 600000, // 10 minutes 19 | enabled: isLoggedIn && features.trends, 20 | }); 21 | 22 | return result; 23 | }; 24 | 25 | export { useTrends as default }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/trends/use-trending-links.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { useClient } from 'pl-fe/hooks/use-client'; 4 | import { useFeatures } from 'pl-fe/hooks/use-features'; 5 | 6 | const useTrendingLinks = () => { 7 | const client = useClient(); 8 | const features = useFeatures(); 9 | 10 | return useQuery({ 11 | queryKey: ['trends', 'links'], 12 | queryFn: () => client.trends.getTrendingLinks(), 13 | enabled: features.trendingLinks, 14 | }); 15 | }; 16 | 17 | export { useTrendingLinks }; 18 | -------------------------------------------------------------------------------- /packages/pl-fe/src/queries/utils/mutation-options.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultError } from '@tanstack/query-core'; 2 | import type { UseMutationOptions } from '@tanstack/react-query'; 3 | 4 | // From https://github.com/TanStack/query/discussions/6096#discussioncomment-9685102 5 | const mutationOptions = < 6 | TData = unknown, 7 | TError = DefaultError, 8 | TVariables = void, 9 | TContext = unknown, 10 | >(options: UseMutationOptions): UseMutationOptions => options; 11 | 12 | export { mutationOptions }; 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/ready.ts: -------------------------------------------------------------------------------- 1 | const ready = (loaded: () => void): void => { 2 | if (['interactive', 'complete'].includes(document.readyState)) { 3 | loaded(); 4 | } else { 5 | document.addEventListener('DOMContentLoaded', loaded); 6 | } 7 | }; 8 | 9 | export { ready as default }; 10 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/admin.test.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmutableRecord } from 'immutable'; 2 | 3 | import reducer from './admin'; 4 | 5 | describe('admin reducer', () => { 6 | it('should return the initial state', () => { 7 | const result = reducer(undefined, {} as any); 8 | expect(ImmutableRecord.isRecord(result)).toBe(true); 9 | expect(result.needsReboot).toBe(false); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/filters.test.ts: -------------------------------------------------------------------------------- 1 | import { List as ImmutableList } from 'immutable'; 2 | 3 | import reducer from './filters'; 4 | 5 | describe('filters reducer', () => { 6 | it('should return the initial state', () => { 7 | expect(reducer(undefined, {} as any)).toEqual(ImmutableList()); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/filters.ts: -------------------------------------------------------------------------------- 1 | import { FILTERS_FETCH_SUCCESS, type FiltersAction } from '../actions/filters'; 2 | 3 | import type { Filter } from 'pl-api'; 4 | 5 | type State = Array; 6 | 7 | const filters = (state: State = [], action: FiltersAction): State => { 8 | switch (action.type) { 9 | case FILTERS_FETCH_SUCCESS: 10 | return action.filters; 11 | default: 12 | return state; 13 | } 14 | }; 15 | 16 | export { filters as default }; 17 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmutableRecord } from 'immutable'; 2 | 3 | import reducer from '.'; 4 | 5 | describe('root reducer', () => { 6 | it('should return the initial state', () => { 7 | const result = reducer(undefined, {} as any); 8 | expect(ImmutableRecord.isRecord(result)).toBe(true); 9 | expect(result.instance.version).toEqual('0.0.0'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/lists.test.ts: -------------------------------------------------------------------------------- 1 | import { Map as ImmutableMap } from 'immutable'; 2 | 3 | import reducer from './lists'; 4 | 5 | describe('lists reducer', () => { 6 | it('should return the initial state', () => { 7 | expect(reducer(undefined, {} as any)).toEqual(ImmutableMap()); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/lists.ts: -------------------------------------------------------------------------------- 1 | import type { List } from 'pl-api'; 2 | 3 | type State = Record; 4 | 5 | const initialState: State = {}; 6 | 7 | const lists = (state: State = initialState, action: any) => { 8 | switch (action.type) { 9 | default: 10 | return state; 11 | } 12 | }; 13 | 14 | export { lists as default }; 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/me.test.ts: -------------------------------------------------------------------------------- 1 | import reducer from './me'; 2 | 3 | describe('me reducer', () => { 4 | it('should return the initial state', () => { 5 | expect(reducer(undefined, {} as any)).toEqual(null); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/meta.test.ts: -------------------------------------------------------------------------------- 1 | import { Record as ImmutableRecord } from 'immutable'; 2 | 3 | import reducer from './meta'; 4 | 5 | describe('meta reducer', () => { 6 | it('should return the initial state', () => { 7 | const result = reducer(undefined, {} as any); 8 | expect(ImmutableRecord.isRecord(result)).toBe(true); 9 | expect(result.instance_fetch_failed).toBe(false); 10 | expect(result.swUpdating).toBe(false); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/meta.ts: -------------------------------------------------------------------------------- 1 | import { STANDALONE_CHECK_SUCCESS, type InstanceAction } from 'pl-fe/actions/instance'; 2 | 3 | const initialState = { 4 | /** Whether /api/v1/instance 404'd (and we should display the external auth form). */ 5 | instance_fetch_failed: false, 6 | }; 7 | 8 | const meta = (state = initialState, action: InstanceAction): typeof initialState => { 9 | switch (action.type) { 10 | case STANDALONE_CHECK_SUCCESS: 11 | return { instance_fetch_failed: !action.ok }; 12 | default: 13 | return state; 14 | } 15 | }; 16 | 17 | export { meta as default }; 18 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/onboarding.ts: -------------------------------------------------------------------------------- 1 | import { ONBOARDING_START, ONBOARDING_END } from 'pl-fe/actions/onboarding'; 2 | 3 | import type { OnboardingActions } from 'pl-fe/actions/onboarding'; 4 | 5 | type OnboardingState = { 6 | needsOnboarding: boolean; 7 | } 8 | 9 | const initialState: OnboardingState = { 10 | needsOnboarding: false, 11 | }; 12 | 13 | const onboarding = (state: OnboardingState = initialState, action: OnboardingActions): OnboardingState => { 14 | switch (action.type) { 15 | case ONBOARDING_START: 16 | return { ...state, needsOnboarding: true }; 17 | case ONBOARDING_END: 18 | return { ...state, needsOnboarding: false }; 19 | default: 20 | return state; 21 | } 22 | }; 23 | 24 | export { onboarding as default }; 25 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/polls.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'mutative'; 2 | 3 | import { POLLS_IMPORT, type ImporterAction } from 'pl-fe/actions/importer'; 4 | 5 | import type { Poll } from 'pl-api'; 6 | 7 | type State = Record; 8 | 9 | const initialState: State = {}; 10 | 11 | const polls = (state: State = initialState, action: ImporterAction): State => { 12 | switch (action.type) { 13 | case POLLS_IMPORT: 14 | return create(state, (draft) => action.polls.forEach(poll => draft[poll.id] = poll)); 15 | default: 16 | return state; 17 | } 18 | }; 19 | 20 | export { polls as default }; 21 | -------------------------------------------------------------------------------- /packages/pl-fe/src/reducers/push-notifications.test.ts: -------------------------------------------------------------------------------- 1 | import reducer from './push-notifications'; 2 | 3 | describe('push_notifications reducer', () => { 4 | it('should return the initial state', () => { 5 | expect(reducer(undefined, {} as any).toJS()).toEqual({ 6 | subscription: null, 7 | alerts: { 8 | follow: true, 9 | follow_request: true, 10 | favourite: true, 11 | reblog: true, 12 | mention: true, 13 | poll: true, 14 | }, 15 | isSubscribed: false, 16 | browserSupport: false, 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/schemas/pleroma.ts: -------------------------------------------------------------------------------- 1 | import * as v from 'valibot'; 2 | 3 | import { coerceObject } from './utils'; 4 | 5 | const mrfSimpleSchema = coerceObject(v.entriesFromList( 6 | [ 7 | 'accept', 8 | 'avatar_removal', 9 | 'banner_removal', 10 | 'federated_timeline_removal', 11 | 'followers_only', 12 | 'media_nsfw', 13 | 'media_removal', 14 | 'reject', 15 | 'reject_deletes', 16 | 'report_removal', 17 | ], 18 | v.fallback(v.array(v.string()), []), 19 | )); 20 | 21 | type MRFSimple = v.InferOutput; 22 | 23 | export { mrfSimpleSchema, type MRFSimple }; 24 | -------------------------------------------------------------------------------- /packages/pl-fe/src/storage/kv-store.ts: -------------------------------------------------------------------------------- 1 | import localforage from 'localforage'; 2 | 3 | interface IKVStore extends LocalForage { 4 | getItemOrError: (key: string) => Promise; 5 | } 6 | 7 | // localForage 8 | // https://localforage.github.io/localForage/#settings-api-config 9 | const KVStore = localforage.createInstance({ 10 | name: 'pl-fe', 11 | description: 'pl-fe offline data store', 12 | driver: localforage.INDEXEDDB, 13 | storeName: 'keyvaluepairs', 14 | }) as IKVStore; 15 | 16 | // localForage returns 'null' when a key isn't found. 17 | // In the Redux action flow, we want it to fail harder. 18 | KVStore.getItemOrError = (key: string) => KVStore.getItem(key).then(value => { 19 | if (value === null) { 20 | throw new Error(`KVStore: null value for key ${key}`); 21 | } else { 22 | return value; 23 | } 24 | }); 25 | 26 | export { 27 | KVStore as default, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/pl-fe/src/stores/ui.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | type State = { 4 | isDropdownMenuOpen: boolean; 5 | openDropdownMenu: () => void; 6 | closeDropdownMenu: () => void; 7 | isSidebarOpen: boolean; 8 | openSidebar: () => void; 9 | closeSidebar: () => void; 10 | } 11 | 12 | const useUiStore = create((set) => ({ 13 | isDropdownMenuOpen: false, 14 | openDropdownMenu: () => set({ isDropdownMenuOpen: true }), 15 | closeDropdownMenu: () => set({ isDropdownMenuOpen: false }), 16 | isSidebarOpen: false, 17 | openSidebar: () => set({ isSidebarOpen: true }), 18 | closeSidebar: () => set({ isSidebarOpen: false }), 19 | })); 20 | 21 | export { useUiStore }; 22 | 23 | -------------------------------------------------------------------------------- /packages/pl-fe/src/styles/application.scss: -------------------------------------------------------------------------------- 1 | @use 'basics'; 2 | @use 'loading'; 3 | @use 'ui'; 4 | @use 'emoji-picker'; 5 | @use 'accessibility'; 6 | @use 'markup'; 7 | 8 | // COMPONENTS 9 | @use 'components/modal'; 10 | @use 'components/compose-form'; 11 | @use 'components/status'; 12 | @use 'components/detailed-status'; 13 | @use 'components/media-gallery'; 14 | @use 'components/notification'; 15 | @use 'components/columns'; 16 | @use 'components/video-player'; 17 | @use 'components/icon'; 18 | @use 'forms'; 19 | @use 'utilities'; 20 | @use 'components/datepicker'; 21 | @use 'mfm'; 22 | -------------------------------------------------------------------------------- /packages/pl-fe/src/styles/basics.scss: -------------------------------------------------------------------------------- 1 | body { 2 | -webkit-overflow-scrolling: touch; 3 | -ms-overflow-style: -ms-autohiding-scrollbar; 4 | } 5 | 6 | // Note: this is needed for React HotKeys performance. Removing this 7 | // will cause severe performance degradation on Safari. 8 | div[tabindex='-1']:focus { 9 | @apply outline-0; 10 | } 11 | 12 | ::selection { 13 | @apply bg-primary-600 text-white; 14 | } 15 | 16 | noscript { 17 | @apply text-center; 18 | } 19 | 20 | .emojione { 21 | @apply w-4 h-4 -mt-[0.2ex] mb-[0.2ex] inline-block align-middle object-contain; 22 | } 23 | 24 | body.system-font, 25 | body.system-font .font-sans { 26 | font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; 27 | } 28 | 29 | body.system-font .font-mono { 30 | font-family: ui-monospace, mono; 31 | } -------------------------------------------------------------------------------- /packages/pl-fe/src/styles/components/columns.scss: -------------------------------------------------------------------------------- 1 | .empty-column-indicator { 2 | @apply bg-primary-50 dark:bg-gray-700 text-gray-900 dark:text-gray-300 text-center p-10 flex flex-1 items-center justify-center min-h-[160px] rounded-lg; 3 | 4 | & > span { 5 | @apply max-w-[400px]; 6 | } 7 | 8 | a { 9 | @apply text-primary-600 dark:text-primary-400 no-underline hover:underline; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/styles/components/compose-form.scss: -------------------------------------------------------------------------------- 1 | .compose-form__upload-thumbnail { 2 | &.video { 3 | @apply bg-cover; 4 | background-image: url('../assets/images/video-placeholder.png'); 5 | } 6 | 7 | &.audio { 8 | @apply bg-cover; 9 | background-image: url('../assets/images/audio-placeholder.png'); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/styles/components/detailed-status.scss: -------------------------------------------------------------------------------- 1 | .thread__status { 2 | .status__wrapper { 3 | @apply shadow-none p-0; 4 | } 5 | 6 | .status__content-wrapper { 7 | @apply pl-[54px] rtl:pl-0 rtl:pr-[calc(54px)]; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/pl-fe/src/styles/components/icon.scss: -------------------------------------------------------------------------------- 1 | .svg-icon { 2 | @apply h-4 w-4 flex items-center justify-center transition duration-200; 3 | 4 | svg { 5 | // Apparently this won't skew the image as long as it has a viewbox 6 | @apply h-full w-full transition duration-200; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/pl-fe/src/styles/components/modal.scss: -------------------------------------------------------------------------------- 1 | .media-modal { 2 | .audio-player.detailed, 3 | .extended-video-player { 4 | @apply flex items-center justify-center; 5 | } 6 | 7 | .audio-player { 8 | @apply max-w-[800px] max-h-[600px]; 9 | } 10 | 11 | .extended-video-player { 12 | @apply w-full h-full; 13 | 14 | video { 15 | @apply max-w-full max-h-[80%]; 16 | } 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /packages/pl-fe/src/styles/components/notification.scss: -------------------------------------------------------------------------------- 1 | .notification .status__wrapper { 2 | @apply p-0; 3 | } 4 | -------------------------------------------------------------------------------- /packages/pl-fe/src/styles/emoji-picker.scss: -------------------------------------------------------------------------------- 1 | em-emoji-picker { 2 | --rgb-background: 255 255 255; 3 | --rgb-accent: var(--color-primary-600); 4 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); 5 | --shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 6 | } 7 | 8 | .dark em-emoji-picker { 9 | --rgb-background: var(--color-primary-900); 10 | } 11 | 12 | .black em-emoji-picker { 13 | --rgb-background: var(--color-gray-900); 14 | } 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/styles/utilities.scss: -------------------------------------------------------------------------------- 1 | .divide-x-dot > *:not(:last-child)::after { 2 | @apply px-1; 3 | content: '·'; 4 | } 5 | 6 | .mention { 7 | @apply text-primary-600 dark:text-accent-blue hover:underline; 8 | } 9 | 10 | .emoji-lg img.emojione { 11 | @apply h-9 w-9 #{!important}; 12 | } 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/types/colors.ts: -------------------------------------------------------------------------------- 1 | type Rgb = { r: number; g: number; b: number }; 2 | type Hsl = { h: number; s: number; l: number }; 3 | 4 | type TailwindColorObject = { 5 | [key: number]: string; 6 | }; 7 | 8 | type TailwindColorPalette = { 9 | [key: string]: TailwindColorObject | string | null; 10 | } 11 | 12 | export type { Rgb, Hsl, TailwindColorObject, TailwindColorPalette }; 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/types/entities.ts: -------------------------------------------------------------------------------- 1 | // Utility types 2 | type APIEntity = Record; 3 | 4 | export { 5 | // Utility types 6 | APIEntity, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/types/history.ts: -------------------------------------------------------------------------------- 1 | import { useHistory } from 'react-router-dom'; 2 | 3 | type History = ReturnType; 4 | 5 | export type { 6 | History, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/types/pl-fe.ts: -------------------------------------------------------------------------------- 1 | type Me = string | null | false; 2 | 3 | export { Me }; 4 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/accounts.test.ts: -------------------------------------------------------------------------------- 1 | import { AccountRecord } from 'pl-fe/normalizers/account'; 2 | 3 | import { 4 | getDomain, 5 | } from './accounts'; 6 | 7 | import type { ReducerAccount } from 'pl-fe/reducers/accounts'; 8 | 9 | describe('getDomain', () => { 10 | const account = AccountRecord({ 11 | acct: 'alice', 12 | url: 'https://party.com/users/alice', 13 | }) as ReducerAccount; 14 | it('returns the domain', () => { 15 | expect(getDomain(account)).toEqual('party.com'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/base64.test.ts: -------------------------------------------------------------------------------- 1 | import * as base64 from './base64'; 2 | 3 | describe('base64', () => { 4 | describe('decode', () => { 5 | it('returns a uint8 array', () => { 6 | const arr = base64.decode('dGVzdA=='); 7 | expect(arr).toEqual(new Uint8Array([116, 101, 115, 116])); 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/base64.ts: -------------------------------------------------------------------------------- 1 | const decode = (base64: string) => { 2 | const rawData = window.atob(base64); 3 | const outputArray = new Uint8Array(rawData.length); 4 | 5 | for (let i = 0; i < rawData.length; ++i) { 6 | outputArray[i] = rawData.charCodeAt(i); 7 | } 8 | 9 | return outputArray; 10 | }; 11 | 12 | export { decode }; 13 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/code.ts: -------------------------------------------------------------------------------- 1 | import type { Code } from './code-compiletime'; 2 | 3 | export default import.meta.compileTime('./code-compiletime.ts'); 4 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/colors.test.ts: -------------------------------------------------------------------------------- 1 | import tintify from './colors'; 2 | 3 | const AZURE = '#0482d8'; 4 | 5 | describe('tintify()', () => { 6 | it('generates tints from a base color', () => { 7 | const result = tintify(AZURE); 8 | 9 | expect(result).toEqual({ 10 | '100': '#e6f3fb', 11 | '200': '#c0e0f5', 12 | '300': '#4fa8e4', 13 | '400': '#369be0', 14 | '50': '#f2f9fd', 15 | '500': '#0482d8', 16 | '600': '#0475c2', 17 | '700': '#0362a2', 18 | '800': '#012741', 19 | '900': '#011929', 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/comparators.test.ts: -------------------------------------------------------------------------------- 1 | import { compareId } from './comparators'; 2 | 3 | test('compareId', () => { 4 | expect(compareId('3', '3')).toBe(0); 5 | expect(compareId('10', '1')).toBe(1); 6 | expect(compareId('99', '100')).toBe(-1); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/config-db.test.ts: -------------------------------------------------------------------------------- 1 | import { List as ImmutableList, fromJS } from 'immutable'; 2 | 3 | import config_db from 'pl-fe/__fixtures__/config_db.json'; 4 | 5 | import { ConfigDB } from './config-db'; 6 | 7 | test('find', () => { 8 | const configs = fromJS(config_db).get('configs'); 9 | expect(ConfigDB.find(configs as ImmutableList, ':phoenix', ':json_library')).toEqual(fromJS({ 10 | group: ':phoenix', 11 | key: ':json_library', 12 | value: 'Jason', 13 | })); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/copy.ts: -------------------------------------------------------------------------------- 1 | const copy = (text: string, onSuccess?: () => void) => { 2 | if (navigator.clipboard) { 3 | navigator.clipboard.writeText(text); 4 | 5 | if (onSuccess) { 6 | onSuccess(); 7 | } 8 | } else { 9 | const textarea = document.createElement('textarea'); 10 | 11 | textarea.textContent = text; 12 | textarea.style.position = 'fixed'; 13 | 14 | document.body.appendChild(textarea); 15 | 16 | try { 17 | textarea.select(); 18 | document.execCommand('copy'); 19 | } catch { 20 | // Do nothing 21 | } finally { 22 | document.body.removeChild(textarea); 23 | 24 | if (onSuccess) { 25 | onSuccess(); 26 | } 27 | } 28 | } 29 | }; 30 | 31 | export { copy as default }; 32 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/download.ts: -------------------------------------------------------------------------------- 1 | /** Download the file from the response instead of opening it in a tab. */ 2 | // https://stackoverflow.com/a/53230807 3 | const download = (data: string, filename: string): void => { 4 | const url = URL.createObjectURL(new Blob([data])); 5 | const link = document.createElement('a'); 6 | link.href = url; 7 | link.setAttribute('download', filename); 8 | document.body.appendChild(link); 9 | link.click(); 10 | link.remove(); 11 | }; 12 | 13 | export { download }; 14 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/html.test.ts: -------------------------------------------------------------------------------- 1 | import * as html from './html'; 2 | 3 | describe('html', () => { 4 | describe('unsecapeHTML', () => { 5 | it('returns unescaped HTML', () => { 6 | const output = html.unescapeHTML('

lorem

ipsum


<br>'); 7 | expect(output).toEqual('lorem\n\nipsum\n
'); 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/html.ts: -------------------------------------------------------------------------------- 1 | /** Convert HTML to a plaintext representation, preserving whitespace. */ 2 | // NB: This function can still return unsafe HTML 3 | const unescapeHTML = (html: string = ''): string => { 4 | const wrapper = document.createElement('div'); 5 | wrapper.innerHTML = html.replace(//g, '\n').replace(/<\/p><[^>]*>/g, '\n\n').replace(/<[^>]*>/g, ''); 6 | return wrapper.textContent || ''; 7 | }; 8 | 9 | /** Convert HTML to plaintext. */ 10 | // https://stackoverflow.com/a/822486 11 | const stripHTML = (html: string) => { 12 | const div = document.createElement('div'); 13 | div.innerHTML = html; 14 | return div.textContent || div.innerText || ''; 15 | }; 16 | 17 | export { 18 | unescapeHTML, 19 | stripHTML, 20 | }; 21 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/input.test.ts: -------------------------------------------------------------------------------- 1 | import { normalizeUsername } from './input'; 2 | 3 | test('normalizeUsername', () => { 4 | expect(normalizeUsername('@alex')).toBe('alex'); 5 | expect(normalizeUsername('alex@alexgleason.me')).toBe('alex@alexgleason.me'); 6 | expect(normalizeUsername('@alex@gleasonator.com')).toBe('alex@gleasonator.com'); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/input.ts: -------------------------------------------------------------------------------- 1 | /** Trim the username and strip the leading @. */ 2 | const normalizeUsername = (username: string): string => { 3 | const trimmed = username.trim(); 4 | if (trimmed[0] === '@') { 5 | return trimmed.slice(1); 6 | } else { 7 | return trimmed; 8 | } 9 | }; 10 | 11 | export { normalizeUsername }; 12 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/media-aspect-ratio.ts: -------------------------------------------------------------------------------- 1 | const minimumAspectRatio = 9 / 16; // Portrait phone 2 | const maximumAspectRatio = 10; // Generous min-height 3 | 4 | const isPanoramic = (ar: number | null) => { 5 | if (ar === null || isNaN(ar)) return false; 6 | return ar >= maximumAspectRatio; 7 | }; 8 | 9 | const isPortrait = (ar: number | null) => { 10 | if (ar === null || isNaN(ar)) return false; 11 | return ar <= minimumAspectRatio; 12 | }; 13 | 14 | const isNonConformingRatio = (ar: number | null) => { 15 | if (ar === null || isNaN(ar)) return false; 16 | return !isPanoramic(ar) && !isPortrait(ar); 17 | }; 18 | 19 | export { 20 | minimumAspectRatio, 21 | maximumAspectRatio, 22 | isPanoramic, 23 | isPortrait, 24 | isNonConformingRatio, 25 | }; 26 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/normalizers.ts: -------------------------------------------------------------------------------- 1 | import type { CustomEmoji } from 'pl-api'; 2 | 3 | const makeEmojiMap = (emojis: Array) => emojis.reduce((obj: Record, emoji: CustomEmoji) => { 4 | obj[`:${emoji.shortcode}:`] = emoji; 5 | return obj; 6 | }, {}); 7 | 8 | export { 9 | makeEmojiMap, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/notification.ts: -------------------------------------------------------------------------------- 1 | import type { Notification } from 'pl-api'; 2 | 3 | /** Notification types known to pl-fe. */ 4 | const NOTIFICATION_TYPES = [ 5 | 'follow', 6 | 'follow_request', 7 | 'mention', 8 | 'reblog', 9 | 'favourite', 10 | 'poll', 11 | 'status', 12 | 'move', 13 | 'chat_mention', 14 | 'emoji_reaction', 15 | 'update', 16 | 'event_reminder', 17 | 'participation_request', 18 | 'participation_accepted', 19 | 'bite', 20 | ] as const; 21 | 22 | /** Notification types to exclude from the "All" filter by default. */ 23 | const EXCLUDE_TYPES = [ 24 | 'chat_mention', 25 | ] as const; 26 | 27 | type NotificationType = Notification['type']; 28 | 29 | export { 30 | NOTIFICATION_TYPES, 31 | EXCLUDE_TYPES, 32 | NotificationType, 33 | }; 34 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/static.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Static: functions related to static files. 3 | * @module pl-fe/utils/static 4 | */ 5 | 6 | import { join } from 'path-browserify'; 7 | 8 | import * as BuildConfig from 'pl-fe/build-config'; 9 | 10 | /** Gets the path to a file with build configuration being considered. */ 11 | const joinPublicPath = (...paths: string[]): string => join(BuildConfig.FE_SUBDIRECTORY, ...paths); 12 | 13 | export { joinPublicPath }; 14 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/strings.ts: -------------------------------------------------------------------------------- 1 | /** Capitalize the first letter of a string. */ 2 | // https://stackoverflow.com/a/1026087 3 | const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); 4 | 5 | export { capitalize }; 6 | -------------------------------------------------------------------------------- /packages/pl-fe/src/utils/timelines.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from 'pl-fe/schemas/pl-fe/settings'; 2 | 3 | import type { Status } from 'pl-fe/normalizers/status'; 4 | 5 | const shouldFilter = ( 6 | status: Pick, 7 | columnSettings: Settings['timelines'][''], 8 | ) => { 9 | const fallback = { 10 | reblog: true, 11 | reply: true, 12 | direct: false, 13 | }; 14 | 15 | const shows = { 16 | reblog: status.reblog_id !== null, 17 | reply: status.in_reply_to_id !== null, 18 | direct: status.visibility === 'direct', 19 | }; 20 | 21 | return Object.entries(shows).some(([key, value]) => (columnSettings?.shows || fallback)[key as 'reblog' | 'reply' | 'direct'] === false && value); 22 | }; 23 | 24 | export { shouldFilter }; 25 | -------------------------------------------------------------------------------- /packages/pl-fe/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "outDir": "dist", 5 | "sourceMap": true, 6 | "strict": true, 7 | "module": "ESNext", 8 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 9 | "target": "ESNext", 10 | "jsx": "react", 11 | "allowJs": true, 12 | "moduleResolution": "Bundler", 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "paths": { 17 | "pl-fe/*": ["src/*"], 18 | }, 19 | "typeRoots": [ 20 | "./src/types", 21 | "./node_modules/@types", 22 | "./node_modules" 23 | ], 24 | "types": [ 25 | "vite/client", 26 | "vitest/globals", 27 | "vite-plugin-compile-time/client" 28 | ], 29 | }, 30 | "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] 31 | } 32 | -------------------------------------------------------------------------------- /packages/pl-hooks/.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | /dist/** 3 | /static/** 4 | /public/** 5 | /tmp/** 6 | /coverage/** 7 | /custom/** 8 | -------------------------------------------------------------------------------- /packages/pl-hooks/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/pl-hooks/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | pl-hooks 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/pl-hooks/lib/contexts/api-client.ts: -------------------------------------------------------------------------------- 1 | import { PlApiClient } from 'pl-api'; 2 | import React from 'react'; 3 | 4 | const PlHooksApiClientContext = React.createContext<{ 5 | client: PlApiClient; 6 | me: string | null | false; 7 | }>({ 8 | client: new PlApiClient(''), 9 | me: null, 10 | }); 11 | 12 | const PlHooksApiClientProvider = PlHooksApiClientContext.Provider; 13 | 14 | const usePlHooksApiClient = () => React.useContext(PlHooksApiClientContext); 15 | 16 | export { PlHooksApiClientProvider, usePlHooksApiClient }; 17 | -------------------------------------------------------------------------------- /packages/pl-hooks/lib/contexts/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query'; 2 | import React from 'react'; 3 | 4 | const queryClient = new QueryClient({ 5 | defaultOptions: { 6 | queries: { 7 | refetchOnWindowFocus: false, 8 | staleTime: 60000, // 1 minute 9 | gcTime: Infinity, 10 | retry: false, 11 | }, 12 | }, 13 | }); 14 | 15 | const PlHooksQueryClientContext = React.createContext(queryClient); 16 | 17 | const PlHooksQueryClientProvider = PlHooksQueryClientContext.Provider; 18 | 19 | const usePlHooksQueryClient = () => React.useContext(PlHooksQueryClientContext); 20 | 21 | export { queryClient, PlHooksQueryClientProvider, usePlHooksQueryClient }; 22 | -------------------------------------------------------------------------------- /packages/pl-hooks/lib/hooks/accounts/use-account-relationship.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client'; 4 | import { usePlHooksQueryClient } from 'pl-hooks/contexts/query-client'; 5 | 6 | const useAccountRelationship = (accountId?: string) => { 7 | const { client } = usePlHooksApiClient(); 8 | const queryClient = usePlHooksQueryClient(); 9 | 10 | return useQuery({ 11 | queryKey: ['accounts', 'entities', accountId], 12 | queryFn: async () => (await client.accounts.getRelationships([accountId!]))[0], 13 | enabled: !!accountId, 14 | }, queryClient); 15 | }; 16 | 17 | export { useAccountRelationship }; 18 | -------------------------------------------------------------------------------- /packages/pl-hooks/lib/hooks/instance/use-instance.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { instanceSchema } from 'pl-api'; 3 | import * as v from 'valibot'; 4 | 5 | import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client'; 6 | import { usePlHooksQueryClient } from 'pl-hooks/contexts/query-client'; 7 | 8 | const initialData = v.parse(instanceSchema, {}); 9 | 10 | const useInstance = () => { 11 | const { client } = usePlHooksApiClient(); 12 | const queryClient = usePlHooksQueryClient(); 13 | 14 | const query = useQuery({ 15 | queryKey: ['instance'], 16 | queryFn: client.instance.getInstance, 17 | }, queryClient); 18 | 19 | return { ...query, data: query.data || initialData }; 20 | }; 21 | 22 | export { useInstance }; 23 | -------------------------------------------------------------------------------- /packages/pl-hooks/lib/hooks/polls/use-poll.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client'; 4 | import { usePlHooksQueryClient } from 'pl-hooks/contexts/query-client'; 5 | 6 | const usePoll = (pollId?: string) => { 7 | const queryClient = usePlHooksQueryClient(); 8 | const { client } = usePlHooksApiClient(); 9 | 10 | return useQuery({ 11 | queryKey: ['polls', 'entities', pollId], 12 | queryFn: () => client.polls.getPoll(pollId!), 13 | enabled: !!pollId, 14 | }, queryClient); 15 | }; 16 | 17 | export { usePoll }; 18 | -------------------------------------------------------------------------------- /packages/pl-hooks/lib/hooks/statuses/use-status-translation.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import { usePlHooksApiClient } from 'pl-hooks/contexts/api-client'; 4 | import { usePlHooksQueryClient } from 'pl-hooks/contexts/query-client'; 5 | 6 | import type { Translation } from 'pl-api'; 7 | 8 | const useStatusTranslation = (statusId: string, targetLanguage?: string) => { 9 | const { client } = usePlHooksApiClient(); 10 | const queryClient = usePlHooksQueryClient(); 11 | 12 | return useQuery({ 13 | queryKey: ['statuses', 'translations', statusId, targetLanguage], 14 | queryFn: () => client.statuses.translateStatus(statusId, targetLanguage) 15 | .then(translation => translation).catch(() => false), 16 | enabled: !!targetLanguage, 17 | }, queryClient); 18 | }; 19 | 20 | export { useStatusTranslation }; 21 | -------------------------------------------------------------------------------- /packages/pl-hooks/lib/normalizers/account.ts: -------------------------------------------------------------------------------- 1 | import type { Account as BaseAccount } from 'pl-api'; 2 | 3 | const normalizeAccount = ({ moved, relationship, ...account }: BaseAccount) => ({ 4 | ...account, 5 | moved_id: moved?.id || null, 6 | }); 7 | 8 | type Account = ReturnType; 9 | 10 | export { normalizeAccount, type Account as NormalizedAccount }; 11 | -------------------------------------------------------------------------------- /packages/pl-hooks/lib/normalizers/status-edit.ts: -------------------------------------------------------------------------------- 1 | import { StatusEdit } from 'pl-api'; 2 | 3 | const normalizeStatusEdit = ({ account, ...statusEdit }: StatusEdit) => ({ 4 | account_id: account.id, 5 | ...statusEdit, 6 | }); 7 | 8 | export { normalizeStatusEdit }; 9 | -------------------------------------------------------------------------------- /packages/pl-hooks/lib/normalizers/status-list.ts: -------------------------------------------------------------------------------- 1 | import { PaginatedResponse, Status } from 'pl-api'; 2 | 3 | import { importEntities } from 'pl-hooks/importer'; 4 | 5 | const minifyStatusList = ({ previous, next, items, ...response }: PaginatedResponse): PaginatedResponse => { 6 | importEntities({ statuses: items }); 7 | return { 8 | ...response, 9 | previous: previous ? () => previous().then(minifyStatusList) : null, 10 | next: next ? () => next().then(minifyStatusList) : null, 11 | items: items.map(status => status.id), 12 | }; 13 | }; 14 | 15 | export { minifyStatusList }; 16 | -------------------------------------------------------------------------------- /packages/pl-hooks/lib/utils/queries.ts: -------------------------------------------------------------------------------- 1 | import type { InfiniteData } from '@tanstack/react-query'; 2 | import type { PaginatedResponse } from 'pl-api'; 3 | 4 | /** Flatten paginated results into a single array. */ 5 | const flattenPages = (queryData: InfiniteData, 'items'>> | undefined) => { 6 | return queryData?.pages.reduce( 7 | (prev: T[], curr) => [...prev, ...(curr.items)], 8 | [], 9 | ); 10 | }; 11 | 12 | export { flattenPages }; 13 | -------------------------------------------------------------------------------- /packages/pl-hooks/tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["lib"] 4 | } -------------------------------------------------------------------------------- /packages/pl-hooks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | 24 | "paths": { 25 | "pl-hooks/*": ["lib/*"], 26 | }, 27 | }, 28 | "include": ["lib"] 29 | } 30 | -------------------------------------------------------------------------------- /packages/pl-hooks/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url'; 2 | import { resolve } from 'path'; 3 | 4 | import { defineConfig } from 'vite'; 5 | import dts from 'vite-plugin-dts'; 6 | 7 | import pkg from './package.json'; 8 | 9 | export default defineConfig({ 10 | plugins: [dts({ include: ['lib'], insertTypesEntry: true })], 11 | build: { 12 | copyPublicDir: false, 13 | lib: { 14 | entry: resolve(__dirname, 'lib/main.ts'), 15 | fileName: (format) => `main.${format}.js`, 16 | formats: ['es'], 17 | name: 'pl-hooks', 18 | }, 19 | target: 'esnext', 20 | sourcemap: true, 21 | rollupOptions: { 22 | external: Object.keys(pkg.dependencies), 23 | }, 24 | }, 25 | resolve: { 26 | alias: [ 27 | { find: 'pl-hooks', replacement: fileURLToPath(new URL('./lib', import.meta.url)) }, 28 | ], 29 | }, 30 | }); 31 | --------------------------------------------------------------------------------