diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 0007cc26..bb2138ff 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -23,7 +23,7 @@ module.exports = { }, extends: [ 'plugin:@typescript-eslint/recommended', - // 'plugin:@typescript-eslint/recommended-requiring-type-checking', // 23-01-2024 681 problems + // 'plugin:@typescript-eslint/recommended-requiring-type-checking', // 30-01-2024 699 problems ], rules: { '@typescript-eslint/no-unused-vars': [ @@ -40,12 +40,12 @@ module.exports = { env: { browser: true, node: true, - mocha: true, + // mocha: true, }, globals: {}, rules: { // Solid - 'solid/reactivity': 'off', // too many 'should be used within JSX' + 'solid/reactivity': 'off', 'solid/no-innerhtml': 'off', /** Unicorn **/ @@ -65,6 +65,7 @@ module.exports = { 'unicorn/no-array-callback-reference': 'warn', 'unicorn/no-array-method-this-argument': 'warn', 'unicorn/no-for-loop': 'off', + 'unicorn/prefer-switch': 'warn', 'sonarjs/no-duplicate-string': ['warn', { threshold: 5 }], 'sonarjs/prefer-immediate-return': 'warn', diff --git a/.gitignore b/.gitignore index e36653bf..c1695636 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ stats.html *.scss.d.ts pnpm-lock.yaml bun.lockb +.jj diff --git a/package.json b/package.json index d52e5fc7..b1a646eb 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "typograf": "7.1.0", "uniqolor": "1.1.0", "vike": "0.4.148", - "vite": "4.5.1", + "vite": "4.5.2", "vite-plugin-mkcert": "1.16.0", "vite-plugin-sass-dts": "1.3.11", "vite-plugin-solid": "2.7.2", diff --git a/src/components/App.tsx b/src/components/App.tsx index 77a05db8..eae090f0 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7,6 +7,7 @@ import { Dynamic } from 'solid-js/web' import { ConfirmProvider } from '../context/confirm' import { ConnectProvider } from '../context/connect' import { EditorProvider } from '../context/editor' +import { FollowingProvider } from '../context/following' import { InboxProvider } from '../context/inbox' import { LocalizeProvider } from '../context/localize' import { MediaQueryProvider } from '../context/mediaQuery' @@ -90,7 +91,7 @@ type Props = PageProps & { is404: boolean } export const App = (props: Props) => { const { page, searchParams } = useRouter() - let is404 = props.is404 + const is404 = createMemo(() => props.is404) createEffect(() => { if (!searchParams().m) { @@ -106,8 +107,7 @@ export const App = (props: Props) => { const pageComponent = createMemo(() => { const result = pagesMap[page()?.route || 'home'] - if (is404 || !result || page()?.path === '/404') { - is404 = false + if (is404() || !result || page()?.path === '/404') { return FourOuFourPage } @@ -122,15 +122,17 @@ export const App = (props: Props) => { - - - - - - - - - + + + + + + + + + + + diff --git a/src/components/Author/AuthorBadge/AuthorBadge.tsx b/src/components/Author/AuthorBadge/AuthorBadge.tsx index a596075a..2c90471b 100644 --- a/src/components/Author/AuthorBadge/AuthorBadge.tsx +++ b/src/components/Author/AuthorBadge/AuthorBadge.tsx @@ -2,13 +2,12 @@ import { openPage } from '@nanostores/router' import { clsx } from 'clsx' import { createEffect, createMemo, createSignal, Match, Show, Switch } from 'solid-js' +import { useFollowing } from '../../../context/following' import { useLocalize } from '../../../context/localize' import { useMediaQuery } from '../../../context/mediaQuery' import { useSession } from '../../../context/session' import { Author, FollowingEntity } from '../../../graphql/schema/core.gen' import { router, useRouter } from '../../../stores/router' -import { follow, unfollow } from '../../../stores/zine/common' -// import { capitalize } from '../../../utils/capitalize' import { isCyrillic } from '../../../utils/cyrillic' import { translit } from '../../../utils/ru2en' import { Button } from '../../_shared/Button' @@ -33,7 +32,7 @@ type Props = { export const AuthorBadge = (props: Props) => { const { mediaMatches } = useMediaQuery() const [isMobileView, setIsMobileView] = createSignal(false) - const [isSubscribing, setIsSubscribing] = createSignal(false) + const [followed, setFollowed] = createSignal(false) createEffect(() => { setIsMobileView(!mediaMatches.sm) @@ -41,33 +40,14 @@ export const AuthorBadge = (props: Props) => { const { author, - subscriptions, - actions: { loadSubscriptions, requireAuthentication }, + actions: { requireAuthentication }, } = useSession() + const { setFollowing } = useFollowing() const { changeSearchParams } = useRouter() const { t, formatDate, lang } = useLocalize() - const subscribed = createMemo(() => { - const sss = subscriptions() - return sss?.authors.some((a: Author) => a?.slug === props.author.slug) - }) - - const subscribe = async (really = true) => { - setIsSubscribing(true) - - await (really - ? follow({ what: FollowingEntity.Author, slug: props.author.slug }) - : unfollow({ what: FollowingEntity.Author, slug: props.author.slug })) - - await loadSubscriptions() - setIsSubscribing(false) - } - const handleSubscribe = (really: boolean) => { - requireAuthentication(() => { - subscribe(really) - }, 'subscribe') - } const initChat = () => { + // eslint-disable-next-line solid/reactivity requireAuthentication(() => { openPage(router, `inbox`) changeSearchParams({ @@ -88,6 +68,14 @@ export const AuthorBadge = (props: Props) => { return props.author.name }) + const handleFollowClick = () => { + const value = !followed() + requireAuthentication(() => { + setFollowed(value) + setFollowing(FollowingEntity.Author, props.author.slug, value) + }, 'subscribe') + } + return (
@@ -135,37 +123,24 @@ export const AuthorBadge = (props: Props) => {
handleSubscribe(!subscribed())} - /> - } + fallback={} > - {t('subscribing...')} - - } - > + } - onClick={() => handleSubscribe(true)} + onClick={handleFollowClick} isSubscribeButton={true} class={clsx(styles.actionButton, { [styles.iconed]: props.iconButtons, - [stylesButton.subscribed]: subscribed(), + [stylesButton.subscribed]: followed(), })} /> } @@ -186,11 +161,11 @@ export const AuthorBadge = (props: Props) => { } - onClick={() => handleSubscribe(false)} + onClick={handleFollowClick} isSubscribeButton={true} class={clsx(styles.actionButton, { [styles.iconed]: props.iconButtons, - [stylesButton.subscribed]: subscribed(), + [stylesButton.subscribed]: followed(), })} /> diff --git a/src/components/Author/AuthorCard/AuthorCard.tsx b/src/components/Author/AuthorCard/AuthorCard.tsx index 587dd564..336d908f 100644 --- a/src/components/Author/AuthorCard/AuthorCard.tsx +++ b/src/components/Author/AuthorCard/AuthorCard.tsx @@ -1,15 +1,15 @@ -import type { Author } from '../../../graphql/schema/core.gen' +import type { Author, Community } from '../../../graphql/schema/core.gen' import { openPage, redirectPage } from '@nanostores/router' import { clsx } from 'clsx' -import { createEffect, createMemo, createSignal, For, Show } from 'solid-js' +import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js' +import { useFollowing } from '../../../context/following' import { useLocalize } from '../../../context/localize' import { useSession } from '../../../context/session' import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen' import { SubscriptionFilter } from '../../../pages/types' import { router, useRouter } from '../../../stores/router' -import { follow, unfollow } from '../../../stores/zine/common' import { isCyrillic } from '../../../utils/cyrillic' import { isAuthor } from '../../../utils/isAuthor' import { translit } from '../../../utils/ru2en' @@ -33,32 +33,14 @@ export const AuthorCard = (props: Props) => { const { t, lang } = useLocalize() const { author, - subscriptions, isSessionLoaded, - actions: { loadSubscriptions, requireAuthentication }, + actions: { requireAuthentication }, } = useSession() - - const [isSubscribing, setIsSubscribing] = createSignal(false) - const [following, setFollowing] = createSignal>(props.following) + const [authorSubs, setAuthorSubs] = createSignal>([]) const [subscriptionFilter, setSubscriptionFilter] = createSignal('all') - - const subscribed = createMemo(() => - subscriptions().authors.some((a: Author) => a?.slug === props.author.slug), - ) - - const subscribe = async (really = true) => { - setIsSubscribing(true) - - await (really - ? follow({ what: FollowingEntity.Author, slug: props.author.slug }) - : unfollow({ what: FollowingEntity.Author, slug: props.author.slug })) - - await loadSubscriptions() - setIsSubscribing(false) - } - const isProfileOwner = createMemo(() => author()?.slug === props.author.slug) - + const [followed, setFollowed] = createSignal() + const { setFollowing } = useFollowing() const name = createMemo(() => { if (lang() !== 'ru' && isCyrillic(props.author.name)) { if (props.author.name === 'Дискурс') { @@ -71,9 +53,12 @@ export const AuthorCard = (props: Props) => { return props.author.name }) + onMount(() => setAuthorSubs(props.following)) + // TODO: reimplement AuthorCard const { changeSearchParams } = useRouter() const initChat = () => { + // eslint-disable-next-line solid/reactivity requireAuthentication(() => { openPage(router, `inbox`) changeSearchParams({ @@ -82,30 +67,30 @@ export const AuthorCard = (props: Props) => { }, 'discussions') } - const handleSubscribe = () => { - requireAuthentication(() => { - subscribe(!subscribed()) - }, 'subscribe') - } - createEffect(() => { if (props.following) { - if (subscriptionFilter() === 'users') { - setFollowing(props.following.filter((s) => 'name' in s)) + if (subscriptionFilter() === 'authors') { + setAuthorSubs(props.following.filter((s) => 'name' in s)) } else if (subscriptionFilter() === 'topics') { - setFollowing(props.following.filter((s) => 'title' in s)) + setAuthorSubs(props.following.filter((s) => 'title' in s)) + } else if (subscriptionFilter() === 'communities') { + setAuthorSubs(props.following.filter((s) => 'title' in s)) } else { - setFollowing(props.following) + setAuthorSubs(props.following) } } }) - const followButtonText = createMemo(() => { - if (isSubscribing()) { - return t('subscribing...') - } + const handleFollowClick = () => { + const value = !followed() + requireAuthentication(() => { + setFollowed(value) + setFollowing(FollowingEntity.Author, props.author.slug, value) + }, 'subscribe') + } - if (subscribed()) { + const followButtonText = createMemo(() => { + if (followed()) { return ( <> {t('Following')} @@ -214,11 +199,11 @@ export const AuthorCard = (props: Props) => { fallback={
{props.following.length} -
  • - @@ -300,7 +285,7 @@ export const AuthorCard = (props: Props) => {
    - + {(subscription) => isAuthor(subscription) ? ( diff --git a/src/components/Feed/Row2.tsx b/src/components/Feed/Row2.tsx index 73c5fa70..5fa5a6be 100644 --- a/src/components/Feed/Row2.tsx +++ b/src/components/Feed/Row2.tsx @@ -1,15 +1,10 @@ import type { Shout } from '../../graphql/schema/core.gen' -import { createComputed, createSignal, Show, For } from 'solid-js' +import { createSignal, createEffect, For, Show } from 'solid-js' import { ArticleCard } from './ArticleCard' -import { ArticleCardProps } from './ArticleCard/ArticleCard' -const x = [ - ['12', '12'], - ['8', '16'], - ['16', '8'], -] +const columnSizes = ['col-md-12', 'col-md-8', 'col-md-16'] export const Row2 = (props: { articles: Shout[] @@ -18,10 +13,10 @@ export const Row2 = (props: { noAuthorLink?: boolean noauthor?: boolean }) => { - const [y, setY] = createSignal(0) + const [columnIndex, setColumnIndex] = createSignal(0) - // FIXME: random can break hydration - createComputed(() => setY(Math.floor(Math.random() * x.length))) + // Update column index on component mount + createEffect(() => setColumnIndex(Math.floor(Math.random() * columnSizes.length))) return ( 0}> @@ -29,31 +24,16 @@ export const Row2 = (props: {
    - {(a, i) => { - // FIXME: refactor this, too ugly now - const className = `col-md-${props.isEqual ? '12' : x[y()][i()]}` - let desktopCoverSize: ArticleCardProps['desktopCoverSize'] - - switch (className) { - case 'col-md-8': { - desktopCoverSize = 'S' - break - } - case 'col-md-12': { - desktopCoverSize = 'M' - break - } - default: { - desktopCoverSize = 'L' - } - } - + {(article, _idx) => { + const className = columnSizes[props.isEqual ? 0 : columnIndex() % columnSizes.length] + const big = className === 'col-md-12' ? 'M' : 'L' + const desktopCoverSize = className === 'col-md-8' ? 'S' : big return (
    { const { t } = useLocalize() const { seen } = useSeenStore() - const { subscriptions } = useSession() + const { subscriptions } = useFollowing() const { page } = useRouter() const { articlesByTopic } = useArticlesStore() const [isSubscriptionsVisible, setSubscriptionsVisible] = createSignal(true) @@ -27,7 +28,6 @@ export const Sidebar = () => { const checkAuthorIsSeen = (authorSlug: string) => { return Boolean(seen()[authorSlug]) } - return (
      @@ -111,7 +111,7 @@ export const Sidebar = () => {
    - 0 || subscriptions().topics.length > 0}> + 0 || subscriptions.topics.length > 0}>

    { @@ -122,22 +122,19 @@ export const Sidebar = () => {

      - - {(author) => ( + + {(a: Author) => (
    • - +
      - -
      {author.name}
      + +
      {a.name}
    • )}
      - + {(topic) => (
    • { } onMount(async () => { - const topics = await apiClient.getRandomTopics({ amount: RANDOM_TOPICS_COUNT }) - setRandomTopics(topics) + if (window.location.pathname === '/' || window.location.pathname === '') { + const topics = await apiClient.getRandomTopics({ amount: RANDOM_TOPICS_COUNT }) + setRandomTopics(topics) + } }) const handleToggleMenuByLink = (event: MouseEvent, route: keyof typeof ROUTES) => { diff --git a/src/components/ProfileSettings/ProfileSettings.tsx b/src/components/ProfileSettings/ProfileSettings.tsx index 490c289a..29382f3b 100644 --- a/src/components/ProfileSettings/ProfileSettings.tsx +++ b/src/components/ProfileSettings/ProfileSettings.tsx @@ -38,7 +38,7 @@ export const ProfileSettings = () => { const [addLinkForm, setAddLinkForm] = createSignal(false) const [incorrectUrl, setIncorrectUrl] = createSignal(false) const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false) - const [userpicFile, setUserpicFile] = createSignal(null) + const [userpicFile, setUserpicFile] = createSignal(null) const [uploadError, setUploadError] = createSignal(false) const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false) const [hostname, setHostname] = createSignal(null) diff --git a/src/components/Topic/Card.tsx b/src/components/Topic/Card.tsx index 23dd980e..917aa396 100644 --- a/src/components/Topic/Card.tsx +++ b/src/components/Topic/Card.tsx @@ -1,12 +1,10 @@ -import type { Topic } from '../../graphql/schema/core.gen' - import { clsx } from 'clsx' import { createMemo, createSignal, Show } from 'solid-js' +import { useFollowing } from '../../context/following' import { useLocalize } from '../../context/localize' import { useSession } from '../../context/session' -import { FollowingEntity } from '../../graphql/schema/core.gen' -import { follow, unfollow } from '../../stores/zine/common' +import { FollowingEntity, type Topic } from '../../graphql/schema/core.gen' import { capitalize } from '../../utils/capitalize' import { Button } from '../_shared/Button' import { CheckButton } from '../_shared/CheckButton' @@ -36,32 +34,21 @@ interface TopicProps { export const TopicCard = (props: TopicProps) => { const { t, lang } = useLocalize() + const title = createMemo(() => + capitalize(lang() === 'en' ? props.topic.slug.replaceAll('-', ' ') : props.topic.title || ''), + ) const { - subscriptions, - isSessionLoaded, - actions: { loadSubscriptions, requireAuthentication }, + author, + actions: { requireAuthentication }, } = useSession() + const { setFollowing, loading: subLoading } = useFollowing() + const [followed, setFollowed] = createSignal() - const [isSubscribing, setIsSubscribing] = createSignal(false) - - const subscribed = createMemo(() => { - return subscriptions().topics.some((topic) => topic.slug === props.topic.slug) - }) - - const subscribe = async (really = true) => { - setIsSubscribing(true) - - await (really - ? follow({ what: FollowingEntity.Topic, slug: props.topic.slug }) - : unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug })) - - await loadSubscriptions() - setIsSubscribing(false) - } - - const handleSubscribe = () => { + const handleFollowClick = () => { + const value = !followed() requireAuthentication(() => { - subscribe(!subscribed()) + setFollowed(value) + setFollowing(FollowingEntity.Topic, props.topic.slug, value) }, 'subscribe') } @@ -69,12 +56,12 @@ export const TopicCard = (props: TopicProps) => { return ( <> - + - + {t('Unfollow')} {t('Following')} @@ -83,10 +70,6 @@ export const TopicCard = (props: TopicProps) => { ) } - const title = createMemo(() => - capitalize(lang() === 'en' ? props.topic.slug.replaceAll('-', ' ') : props.topic.title || ''), - ) - return (
      { }} > - + + } >
    • -
    • -
    • diff --git a/src/components/Views/PublishSettings/PublishSettings.tsx b/src/components/Views/PublishSettings/PublishSettings.tsx index b5a144e4..1fe168be 100644 --- a/src/components/Views/PublishSettings/PublishSettings.tsx +++ b/src/components/Views/PublishSettings/PublishSettings.tsx @@ -1,6 +1,6 @@ import { redirectPage } from '@nanostores/router' import { clsx } from 'clsx' -import { createEffect, createSignal, lazy, onMount, Show } from 'solid-js' +import { createEffect, createMemo, createSignal, lazy, onMount, Show } from 'solid-js' import { createStore } from 'solid-js/store' import { ShoutForm, useEditorContext } from '../../../context/editor' @@ -16,7 +16,6 @@ import { Icon } from '../../_shared/Icon' import { Image } from '../../_shared/Image' import { TopicSelect, UploadModalContent } from '../../Editor' import { Modal } from '../../Nav/Modal' -import { EMPTY_TOPIC } from '../Edit' import styles from './PublishSettings.module.scss' import stylesBeside from '../../Feed/Beside.module.scss' @@ -36,21 +35,26 @@ const shorten = (str: string, maxLen: number) => { return `${result}...` } +const EMPTY_TOPIC: Topic = { + id: -1, + slug: '', +} +const emptyConfig = { + coverImageUrl: '', + mainTopic: EMPTY_TOPIC, + slug: '', + title: '', + subtitle: '', + description: '', + selectedTopics: [], +} + export const PublishSettings = (props: Props) => { const { t } = useLocalize() const { author } = useSession() const { sortedTopics } = useTopicsStore() - const [topics, setTopics] = createSignal(sortedTopics()) - onMount(async () => { - await loadAllTopics() - }) - - createEffect(() => { - setTopics(sortedTopics()) - }) - const composeDescription = () => { if (!props.form.description) { const cleanFootnotes = props.form.body.replaceAll(/.*?<\/footnote>/g, '') @@ -60,23 +64,32 @@ export const PublishSettings = (props: Props) => { return props.form.description } - const initialData: Partial = { - coverImageUrl: props.form.coverImageUrl, - mainTopic: props.form.mainTopic || EMPTY_TOPIC, - slug: props.form.slug, - title: props.form.title, - subtitle: props.form.subtitle, - description: composeDescription(), - selectedTopics: [], - } + const initialData = createMemo(() => { + return { + coverImageUrl: props.form?.coverImageUrl, + mainTopic: props.form?.mainTopic || EMPTY_TOPIC, + slug: props.form?.slug, + title: props.form?.title, + subtitle: props.form?.subtitle, + description: composeDescription(), + selectedTopics: [], + } + }) + + const [settingsForm, setSettingsForm] = createStore(emptyConfig) + + onMount(() => { + setSettingsForm(initialData()) + loadAllTopics() + }) + + createEffect(() => setTopics(sortedTopics())) const { formErrors, actions: { setForm, setFormErrors, saveShout, publishShout }, } = useEditorContext() - const [settingsForm, setSettingsForm] = createStore(initialData) - const handleUploadModalContentCloseSetCover = (image: UploadedFile) => { hideModal() setSettingsForm('coverImageUrl', image.url) @@ -110,7 +123,7 @@ export const PublishSettings = (props: Props) => { }) } const handleCancelClick = () => { - setSettingsForm(initialData) + setSettingsForm(initialData()) handleBackClick() } const handlePublishSubmit = () => { @@ -149,9 +162,9 @@ export const PublishSettings = (props: Props) => { [styles.hasImage]: settingsForm.coverImageUrl, })} > - +
      - {initialData.title} + {initialData().title}
      diff --git a/src/components/Views/Topic.tsx b/src/components/Views/Topic.tsx index a6702147..1b3d059a 100644 --- a/src/components/Views/Topic.tsx +++ b/src/components/Views/Topic.tsx @@ -2,7 +2,7 @@ import type { Shout, Topic } from '../../graphql/schema/core.gen' import { Meta } from '@solidjs/meta' import { clsx } from 'clsx' -import { For, Show, createMemo, onMount, createSignal } from 'solid-js' +import { For, Show, createMemo, onMount, createSignal, createEffect } from 'solid-js' import { useLocalize } from '../../context/localize' import { useRouter } from '../../stores/router' @@ -39,23 +39,27 @@ const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3 export const TopicView = (props: Props) => { const { t, lang } = useLocalize() const { searchParams, changeSearchParams } = useRouter() - const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) - const { sortedArticles } = useArticlesStore({ shouts: props.shouts }) const { topicEntities } = useTopicsStore({ topics: [props.topic] }) - const { authorsByTopic } = useAuthorsStore() - const topic = createMemo(() => - props.topic?.slug in topicEntities() ? topicEntities()[props.topic.slug] : props.topic, + const [topic, setTopic] = createSignal() + createEffect(() => { + const topics = topicEntities() + if (props.topicSlug && !topic() && topics) { + setTopic(topics[props.topicSlug]) + } + }) + const title = createMemo( + () => + `#${capitalize( + lang() === 'en' + ? topic()?.slug.replace(/-/, ' ') + : topic()?.title || topic()?.slug.replace(/-/, ' '), + true, + )}`, ) - const title = () => - `#${capitalize( - lang() === 'en' ? topic()?.slug.replace(/-/, ' ') : topic()?.title || topic()?.slug.replace(/-/, ' '), - true, - )}` - onMount(() => (document.title = title())) const loadMore = async () => { saveScrollPosition() diff --git a/src/context/following.tsx b/src/context/following.tsx new file mode 100644 index 00000000..2246485f --- /dev/null +++ b/src/context/following.tsx @@ -0,0 +1,127 @@ +import { createEffect, createSignal, createContext, Accessor, useContext, JSX, onMount } from 'solid-js' +import { createStore } from 'solid-js/store' + +import { apiClient } from '../graphql/client/core' +import { Author, Community, FollowingEntity, Topic } from '../graphql/schema/core.gen' + +import { useSession } from './session' + +type SubscriptionsData = { + topics?: Topic[] + authors?: Author[] + communities?: Community[] +} + +interface FollowingContextType { + loading: Accessor + subscriptions: SubscriptionsData + setSubscriptions: (subscriptions: SubscriptionsData) => void + setFollowing: (what: FollowingEntity, slug: string, value: boolean) => void + loadSubscriptions: () => void + follow: (what: FollowingEntity, slug: string) => Promise + unfollow: (what: FollowingEntity, slug: string) => Promise +} + +const FollowingContext = createContext() + +export function useFollowing() { + return useContext(FollowingContext) +} + +const EMPTY_SUBSCRIPTIONS = { + topics: [], + authors: [], + communities: [], +} + +export const FollowingProvider = (props: { children: JSX.Element }) => { + const [loading, setLoading] = createSignal(false) + const [subscriptions, setSubscriptions] = createStore(EMPTY_SUBSCRIPTIONS) + const { author } = useSession() + + const fetchData = async () => { + setLoading(true) + try { + if (apiClient.private) { + console.debug('[context.following] fetching subs data...') + const result = await apiClient.getMySubscriptions() + setSubscriptions(result || EMPTY_SUBSCRIPTIONS) + console.info('[context.following] subs:', subscriptions) + } + } catch (error) { + console.info('[context.following] cannot get subs', error) + } finally { + setLoading(false) + } + } + + const follow = async (what: FollowingEntity, slug: string) => { + if (!author()) return + try { + await apiClient.follow({ what, slug }) + setSubscriptions((prevSubscriptions) => { + const updatedSubs = { ...prevSubscriptions } + if (!updatedSubs[what]) updatedSubs[what] = [] + const exists = updatedSubs[what]?.some((entity) => entity.slug === slug) + if (!exists) updatedSubs[what].push(slug) + return updatedSubs + }) + } catch (error) { + console.error(error) + } + } + + const unfollow = async (what: FollowingEntity, slug: string) => { + if (!author()) return + try { + await apiClient.unfollow({ what, slug }) + } catch (error) { + console.error(error) + } + } + + const loadData = (_a?: Author) => { + // console.debug('[context.following] current subs:', subscriptions) + if (!subscriptions?.authors?.length && !subscriptions?.topics?.length) { + // && subscriptions.communites?.length + fetchData() + } + } + createEffect(() => { + if (author()) loadData() + }) + onMount(loadData) + + const setFollowing = (what: FollowingEntity, slug: string, value = true) => { + setSubscriptions((prevSubscriptions) => { + const updatedSubs = { ...prevSubscriptions } + if (!updatedSubs[what]) updatedSubs[what] = [] + if (value) { + const exists = updatedSubs[what]?.some((entity) => entity.slug === slug) + if (!exists) updatedSubs[what].push(slug) + } else { + updatedSubs[what] = (updatedSubs[what] || []).filter((x) => x !== slug) + } + return updatedSubs + }) + try { + ;(value ? follow : unfollow)(what, slug) + } catch (error) { + console.error(error) + } finally { + fetchData() + } + } + + const value: FollowingContextType = { + loading, + subscriptions, + setSubscriptions, + setFollowing, + loadSubscriptions: fetchData, + follow, + unfollow, + } + + return {props.children} +} diff --git a/src/context/reactions.tsx b/src/context/reactions.tsx index c98ebcaa..5af22fe2 100644 --- a/src/context/reactions.tsx +++ b/src/context/reactions.tsx @@ -56,7 +56,7 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => { const createReaction = async (input: ReactionInput): Promise => { const reaction = await apiClient.createReaction(input) - + if (!reaction) return const changes = { [reaction.id]: reaction, } diff --git a/src/context/session.tsx b/src/context/session.tsx index 0b872ab4..d637edb9 100644 --- a/src/context/session.tsx +++ b/src/context/session.tsx @@ -1,5 +1,5 @@ import type { AuthModalSource } from '../components/Nav/AuthModal/types' -import type { Author, Result } from '../graphql/schema/core.gen' +import type { Author } from '../graphql/schema/core.gen' import type { Accessor, JSX, Resource } from 'solid-js' import { @@ -41,19 +41,17 @@ const defaultConfig: ConfigType = { } export type SessionContextType = { - config: ConfigType + config: Accessor session: Resource author: Resource authError: Accessor isSessionLoaded: Accessor - subscriptions: Accessor isAuthenticated: Accessor actions: { loadSession: () => AuthToken | Promise setSession: (token: AuthToken | null) => void // setSession loadAuthor: (info?: unknown) => Author | Promise setAuthor: (a: Author) => void - loadSubscriptions: () => Promise requireAuthentication: ( callback: (() => Promise) | (() => void), modalSource: AuthModalSource, @@ -77,11 +75,6 @@ export function useSession() { return useContext(SessionContext) } -const EMPTY_SUBSCRIPTIONS = { - topics: [], - authors: [], -} - export const SessionProvider = (props: { onStateChangeCallback(state: AuthToken): unknown children: JSX.Element @@ -91,12 +84,12 @@ export const SessionProvider = (props: { actions: { showSnackbar }, } = useSnackbar() const { searchParams, changeSearchParams } = useRouter() - const [configuration, setConfig] = createSignal(defaultConfig) - const authorizer = createMemo(() => new Authorizer(configuration())) + const [config, setConfig] = createSignal(defaultConfig) + const authorizer = createMemo(() => new Authorizer(config())) const [oauthState, setOauthState] = createSignal() // handle callback's redirect_uri - createEffect(async () => { + createEffect(() => { // oauth const state = searchParams()?.state if (state) { @@ -120,43 +113,44 @@ export const SessionProvider = (props: { }) // load - let minuteLater + let minuteLater: NodeJS.Timeout | null const [isSessionLoaded, setIsSessionLoaded] = createSignal(false) const [authError, setAuthError] = createSignal('') - const [session, { refetch: loadSession, mutate: setSession }] = createResource( - async () => { - try { - const s = await authorizer().getSession() - console.info('[context.session] loading session', s) - // Set session expiration time in local storage - const expires_at = new Date(Date.now() + s.expires_in * 1000) - localStorage.setItem('expires_at', `${expires_at.getTime()}`) + // Function to load session data + const sessionData = async () => { + try { + const s = await authorizer().getSession() + console.info('[context.session] loading session', s) - // Set up session expiration check timer - minuteLater = setTimeout(checkSessionIsExpired, 60 * 1000) - console.info(`[context.session] will refresh in ${s.expires_in / 60} mins`) + // Set session expiration time in local storage + const expires_at = new Date(Date.now() + s.expires_in * 1000) + localStorage.setItem('expires_at', `${expires_at.getTime()}`) - // Set the session loaded flag - setIsSessionLoaded(true) + // Set up session expiration check timer + minuteLater = setTimeout(checkSessionIsExpired, 60 * 1000) + console.info(`[context.session] will refresh in ${s.expires_in / 60} mins`) - return s - } catch (error) { - console.info('[context.session] cannot refresh session', error) - setAuthError(error) + // Set the session loaded flag + setIsSessionLoaded(true) - // Set the session loaded flag even if there's an error - setIsSessionLoaded(true) + return s + } catch (error) { + console.info('[context.session] cannot refresh session', error) + setAuthError(error) - return null - } - }, - { - ssrLoadFrom: 'initial', - initialValue: null, - }, - ) + // Set the session loaded flag even if there's an error + setIsSessionLoaded(true) + + return null + } + } + + const [session, { refetch: loadSession, mutate: setSession }] = createResource(sessionData, { + ssrLoadFrom: 'initial', + initialValue: null, + }) const checkSessionIsExpired = () => { const expires_at_data = localStorage.getItem('expires_at') @@ -177,26 +171,17 @@ export const SessionProvider = (props: { } onCleanup(() => clearTimeout(minuteLater)) - - const [author, { refetch: loadAuthor, mutate: setAuthor }] = createResource( - async () => { - const u = session()?.user - return u ? (await apiClient.getAuthorId({ user: u.id.trim() })) || null : null - }, - { - ssrLoadFrom: 'initial', - initialValue: null, - }, - ) - - const [subscriptions, setSubscriptions] = createSignal(EMPTY_SUBSCRIPTIONS) - const loadSubscriptions = async (): Promise => { - const result = await apiClient.getMySubscriptions() - setSubscriptions(result || EMPTY_SUBSCRIPTIONS) + const authorData = async () => { + const u = session()?.user + return u ? (await apiClient.getAuthorId({ user: u.id.trim() })) || null : null } + const [author, { refetch: loadAuthor, mutate: setAuthor }] = createResource(authorData, { + ssrLoadFrom: 'initial', + initialValue: null, + }) // when session is loaded - createEffect(async () => { + createEffect(() => { if (session()) { const token = session()?.access_token if (token) { @@ -206,23 +191,24 @@ export const SessionProvider = (props: { notifierClient.connect(token) inboxClient.connect(token) } - if (!author()) { - const a = await loadAuthor() - if (a) { - await loadSubscriptions() - addAuthors([a]) - } else { - reset() - } - } + if (!author()) loadAuthor() + setIsSessionLoaded(true) } } }) + // when author is loaded + createEffect(() => { + if (author()) { + addAuthors([author()]) + } else { + reset() + } + }) + const reset = () => { setIsSessionLoaded(true) - setSubscriptions(EMPTY_SUBSCRIPTIONS) setSession(null) setAuthor(null) } @@ -252,10 +238,10 @@ export const SessionProvider = (props: { ) const [authCallback, setAuthCallback] = createSignal<() => void>(() => {}) - const requireAuthentication = async (callback: () => void, modalSource: AuthModalSource) => { + const requireAuthentication = (callback: () => void, modalSource: AuthModalSource) => { setAuthCallback((_cb) => callback) if (!session()) { - await loadSession() + loadSession() if (!session()) { showModal('auth', modalSource) } @@ -323,7 +309,6 @@ export const SessionProvider = (props: { const isAuthenticated = createMemo(() => Boolean(author())) const actions = { loadSession, - loadSubscriptions, requireAuthentication, signUp, signIn, @@ -339,9 +324,8 @@ export const SessionProvider = (props: { } const value: SessionContextType = { authError, - config: configuration(), + config, session, - subscriptions, isSessionLoaded, author, actions, diff --git a/src/graphql/client/core.ts b/src/graphql/client/core.ts index 38ed9705..04e8acf6 100644 --- a/src/graphql/client/core.ts +++ b/src/graphql/client/core.ts @@ -12,6 +12,7 @@ import type { QueryLoad_Authors_ByArgs, QueryLoad_Shouts_SearchArgs, QueryLoad_Shouts_Random_TopArgs, + Community, } from '../schema/core.gen' import { createGraphQLClient } from '../createGraphQLClient' @@ -37,20 +38,24 @@ import authorBy from '../query/core/author-by' import authorFollowers from '../query/core/author-followers' import authorId from '../query/core/author-id' import authorsAll from '../query/core/authors-all' -import authorFollowed from '../query/core/authors-followed-by' import authorsLoadBy from '../query/core/authors-load-by' import mySubscriptions from '../query/core/my-followed' import reactionsLoadBy from '../query/core/reactions-load-by' import topicBySlug from '../query/core/topic-by-slug' import topicsAll from '../query/core/topics-all' -import userFollowedTopics from '../query/core/topics-by-author' +import authorFollowedAuthors from '../query/core/authors-followed-by' +import authorFollowedTopics from '../query/core/topics-followed-by' +import authorFollowedCommunities from '../query/core/communities-followed-by' import topicsRandomQuery from '../query/core/topics-random' const publicGraphQLClient = createGraphQLClient('core') export const apiClient = { private: null, - connect: (token: string) => (apiClient.private = createGraphQLClient('core', token)), // NOTE: use it after token appears + connect: (token: string) => { + // NOTE: use it after token appears + apiClient.private = createGraphQLClient('core', token) + }, getRandomTopShouts: async (params: QueryLoad_Shouts_Random_TopArgs) => { const response = await publicGraphQLClient.query(loadShoutsTopRandom, params).toPromise() @@ -119,14 +124,18 @@ export const apiClient = { const response = await publicGraphQLClient.query(authorFollowers, { slug }).toPromise() return response.data.get_author_followers }, - getAuthorFollowingUsers: async ({ slug }: { slug: string }): Promise => { - const response = await publicGraphQLClient.query(authorFollowed, { slug }).toPromise() + getAuthorFollowingAuthors: async ({ slug }: { slug: string }): Promise => { + const response = await publicGraphQLClient.query(authorFollowedAuthors, { slug }).toPromise() return response.data.get_author_followed }, getAuthorFollowingTopics: async ({ slug }: { slug: string }): Promise => { - const response = await publicGraphQLClient.query(userFollowedTopics, { slug }).toPromise() + const response = await publicGraphQLClient.query(authorFollowedTopics, { slug }).toPromise() return response.data.get_topics_by_author }, + getAuthorFollowingCommunities: async ({ slug }: { slug: string }): Promise => { + const response = await publicGraphQLClient.query(authorFollowedCommunities, { slug }).toPromise() + return response.data.get_communities_by_author + }, updateProfile: async (input: ProfileInput) => { const response = await apiClient.private.mutation(updateProfile, { profile: input }).toPromise() return response.data.update_profile @@ -200,7 +209,7 @@ export const apiClient = { const resp = await publicGraphQLClient.query(shoutsLoadBy, { options }).toPromise() if (resp.error) console.error(resp) - return resp.data.load_shouts_by + return resp.data?.load_shouts_by }, getShoutsSearch: async ({ text, limit, offset }: QueryLoad_Shouts_SearchArgs) => { diff --git a/src/graphql/mutation/core/reaction-create.ts b/src/graphql/mutation/core/reaction-create.ts index c85d3353..72852d97 100644 --- a/src/graphql/mutation/core/reaction-create.ts +++ b/src/graphql/mutation/core/reaction-create.ts @@ -8,7 +8,6 @@ export default gql` id body kind - range created_at reply_to stat { diff --git a/src/graphql/query/core/authors-load-by.ts b/src/graphql/query/core/authors-load-by.ts index 11e11be2..427889c4 100644 --- a/src/graphql/query/core/authors-load-by.ts +++ b/src/graphql/query/core/authors-load-by.ts @@ -1,7 +1,7 @@ import { gql } from '@urql/core' export default gql` - query AuthorsAllQuery($by: AuthorsBy, $limit: Int, $offset: Int) { + query AuthorsAllQuery($by: AuthorsBy!, $limit: Int, $offset: Int) { load_authors_by(by: $by, limit: $limit, offset: $offset) { id slug diff --git a/src/graphql/query/core/communities-followed-by.ts b/src/graphql/query/core/communities-followed-by.ts new file mode 100644 index 00000000..d5d33128 --- /dev/null +++ b/src/graphql/query/core/communities-followed-by.ts @@ -0,0 +1,17 @@ +import { gql } from '@urql/core' + +export default gql` + query LoadCommunitiesFollowedBy($slug: String, $user: String, $author_id: Int) { + get_communities_by_author(slug: $slug, user: $user, author_id: $author_id) { + id + slug + title + pic + stat { + shouts + followers + authors + } + } + } +` diff --git a/src/graphql/query/core/topics-by-author.ts b/src/graphql/query/core/topics-followed-by.ts similarity index 77% rename from src/graphql/query/core/topics-by-author.ts rename to src/graphql/query/core/topics-followed-by.ts index b5d7db23..b5299177 100644 --- a/src/graphql/query/core/topics-by-author.ts +++ b/src/graphql/query/core/topics-followed-by.ts @@ -1,7 +1,7 @@ import { gql } from '@urql/core' export default gql` - query UserFollowingTopicsQuery($slug: String, $user: String, $author_id: Int) { + query LoadTopicsFollowedBy($slug: String, $user: String, $author_id: Int) { get_topics_by_author(slug: $slug, user: $user, author_id: $author_id) { id slug diff --git a/src/pages/types.ts b/src/pages/types.ts index c54208c1..b36a7612 100644 --- a/src/pages/types.ts +++ b/src/pages/types.ts @@ -50,4 +50,4 @@ export type UploadedFile = { originalFilename?: string } -export type SubscriptionFilter = 'all' | 'users' | 'topics' +export type SubscriptionFilter = 'all' | 'authors' | 'topics' | 'communities' diff --git a/src/stores/zine/common.ts b/src/stores/zine/common.ts deleted file mode 100644 index c874816b..00000000 --- a/src/stores/zine/common.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { apiClient } from '../../graphql/client/core' -import { FollowingEntity } from '../../graphql/schema/core.gen' - -export const follow = async ({ what, slug }: { what: FollowingEntity; slug: string }) => { - await apiClient.follow({ what, slug }) -} -export const unfollow = async ({ what, slug }: { what: FollowingEntity; slug: string }) => { - await apiClient.unfollow({ what, slug }) -} diff --git a/src/utils/getImageUrl.ts b/src/utils/getImageUrl.ts index ef59e6d8..6f249115 100644 --- a/src/utils/getImageUrl.ts +++ b/src/utils/getImageUrl.ts @@ -15,7 +15,7 @@ export const getImageUrl = ( src: string, options: { width?: number; height?: number; noSizeUrlPart?: boolean } = {}, ) => { - const filename = src.split('/').pop() + const filename = src?.split('/').pop() const isAudio = src.toLowerCase().split('.').pop() in ['wav', 'mp3', 'ogg', 'aif', 'flac'] const base = isAudio ? cdnUrl : `${thumborUrl}/unsafe/` const sizeUrlPart = isAudio ? '' : getSizeUrlPart(options) diff --git a/src/utils/useEscKeyDownHandler.ts b/src/utils/useEscKeyDownHandler.ts index 3e19f505..b3430c31 100644 --- a/src/utils/useEscKeyDownHandler.ts +++ b/src/utils/useEscKeyDownHandler.ts @@ -1,9 +1,9 @@ import { onCleanup, onMount } from 'solid-js' -export const useEscKeyDownHandler = (onEscKeyDown: () => void) => { +export const useEscKeyDownHandler = (onEscKeyDown: (ev) => void) => { const keydownHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') { - onEscKeyDown() + onEscKeyDown(e) } }