{"version":3,"file":"main-1wKRS81d.js","sources":["../../src/utils/usePageVisibility.js","../../src/components/background-service.jsx","../../src/components/compose-button.jsx","../../src/components/keyboard-shortcuts-help.jsx","../../src/pages/accounts.jsx","../../src/assets/logo.svg","../../src/utils/push-notifications.js","../../src/pages/settings.jsx","../../src/utils/focus-deck.jsx","../../src/utils/useLocationChange.js","../../src/utils/lists.js","../../src/components/list-add-edit.jsx","../../src/components/account-info.jsx","../../src/components/account-sheet.jsx","../../src/components/drafts.jsx","../../src/components/embed-modal.jsx","../../src/components/generic-accounts.jsx","../../src/components/media-alt-modal.jsx","../../src/utils/color-utils.js","../../src/components/media-modal.jsx","../../src/components/report-modal.jsx","../../src/assets/floating-button.svg","../../src/assets/multi-column.svg","../../src/assets/tab-menu-bar.svg","../../src/utils/followed-tags.js","../../src/components/AsyncText.jsx","../../src/components/shortcuts-settings.jsx","../../src/components/modals.jsx","../../src/components/follow-request-buttons.jsx","../../src/components/notification.jsx","../../src/components/notification-service.jsx","../../src/components/search-form.jsx","../../src/components/search-command.jsx","../../src/components/shortcuts.jsx","../../src/utils/timeline-utils.jsx","../../src/utils/useScroll.js","../../src/utils/useScrollFn.js","../../src/components/media-post.jsx","../../src/components/nav-menu.jsx","../../src/components/timeline.jsx","../../src/pages/account-statuses.jsx","../../src/pages/bookmarks.jsx","../../src/assets/features/catch-up.png","../../src/pages/catchup.jsx","../../src/pages/favourites.jsx","../../src/pages/filters.jsx","../../src/pages/followed-hashtags.jsx","../../src/pages/following.jsx","../../src/pages/hashtag.jsx","../../src/pages/list.jsx","../../src/utils/group-notifications.jsx","../../src/pages/mentions.jsx","../../src/pages/notifications.jsx","../../src/pages/public.jsx","../../src/pages/search.jsx","../../src/pages/trending.jsx","../../src/components/columns.jsx","../../src/pages/home.jsx","../../src/utils/get-instance-status-url.js","../../src/pages/http-route.jsx","../../src/pages/lists.jsx","../../src/data/instances.json?url","../../src/utils/auth.js","../../src/pages/login.jsx","../../src/pages/status.jsx","../../src/pages/status-route.jsx","../../src/assets/features/boosts-carousel.jpg","../../src/assets/features/grouped-notifications.jpg","../../src/assets/features/multi-column.jpg","../../src/assets/features/multi-hashtag-timeline.jpg","../../src/assets/features/nested-comments-thread.jpg","../../src/assets/logo-text.svg","../../src/pages/welcome.jsx","../../src/utils/toast-alert.js","../../src/app.jsx","../../src/main.jsx"],"sourcesContent":["import { useEffect, useRef } from 'preact/hooks';\n\nexport default function usePageVisibility(fn = () => {}, deps = []) {\n const savedCallback = useRef(fn);\n useEffect(() => {\n savedCallback.current = fn;\n }, [deps]);\n\n useEffect(() => {\n const handleVisibilityChange = () => {\n const hidden = document.hidden || document.visibilityState === 'hidden';\n console.log('πŸ‘€ Page visibility changed', hidden ? 'hidden' : 'visible');\n savedCallback.current(!hidden);\n };\n\n document.addEventListener('visibilitychange', handleVisibilityChange);\n return () =>\n document.removeEventListener('visibilitychange', handleVisibilityChange);\n }, []);\n}\n","import { memo } from 'preact/compat';\nimport { useEffect, useRef, useState } from 'preact/hooks';\nimport { useHotkeys } from 'react-hotkeys-hook';\n\nimport { api } from '../utils/api';\nimport showToast from '../utils/show-toast';\nimport states, { saveStatus } from '../utils/states';\nimport useInterval from '../utils/useInterval';\nimport usePageVisibility from '../utils/usePageVisibility';\n\nconst STREAMING_TIMEOUT = 1000 * 3; // 3 seconds\nconst POLL_INTERVAL = 15_000; // 15 seconds\n\nexport default memo(function BackgroundService({ isLoggedIn }) {\n // Notifications service\n // - WebSocket to receive notifications when page is visible\n const [visible, setVisible] = useState(true);\n usePageVisibility(setVisible);\n const checkLatestNotification = async (masto, instance, skipCheckMarkers) => {\n if (states.notificationsLast) {\n const notificationsIterator = masto.v1.notifications.list({\n limit: 1,\n sinceId: states.notificationsLast.id,\n });\n const { value: notifications } = await notificationsIterator.next();\n if (notifications?.length) {\n if (skipCheckMarkers) {\n states.notificationsShowNew = true;\n } else {\n let lastReadId;\n try {\n const markers = await masto.v1.markers.fetch({\n timeline: 'notifications',\n });\n lastReadId = markers?.notifications?.lastReadId;\n } catch (e) {}\n if (lastReadId) {\n states.notificationsShowNew = notifications[0].id !== lastReadId;\n } else {\n states.notificationsShowNew = true;\n }\n }\n }\n }\n };\n\n useEffect(() => {\n let sub;\n let pollNotifications;\n if (isLoggedIn && visible) {\n const { masto, streaming, instance } = api();\n (async () => {\n // 1. Get the latest notification\n await checkLatestNotification(masto, instance);\n\n let hasStreaming = false;\n // 2. Start streaming\n if (streaming) {\n pollNotifications = setTimeout(() => {\n (async () => {\n try {\n hasStreaming = true;\n sub = streaming.user.notification.subscribe();\n console.log('🎏 Streaming notification', sub);\n for await (const entry of sub) {\n if (!sub) break;\n if (!visible) break;\n console.log('πŸ””πŸ”” Notification entry', entry);\n if (entry.event === 'notification') {\n console.log('πŸ””πŸ”” Notification', entry);\n saveStatus(entry.payload, instance, {\n skipThreading: true,\n });\n }\n states.notificationsShowNew = true;\n }\n console.log('πŸ’₯ Streaming notification loop STOPPED');\n } catch (e) {\n hasStreaming = false;\n console.error(e);\n }\n\n if (!hasStreaming) {\n console.log('🎏 Streaming failed, fallback to polling');\n pollNotifications = setInterval(() => {\n checkLatestNotification(masto, instance, true);\n }, POLL_INTERVAL);\n }\n })();\n }, STREAMING_TIMEOUT);\n }\n })();\n }\n return () => {\n sub?.unsubscribe?.();\n sub = null;\n clearTimeout(pollNotifications);\n clearInterval(pollNotifications);\n };\n }, [visible, isLoggedIn]);\n\n // Check for updates service\n const lastCheckDate = useRef();\n const checkForUpdates = () => {\n lastCheckDate.current = Date.now();\n console.log('✨ Check app update');\n fetch('./version.json')\n .then((r) => r.json())\n .then((info) => {\n if (info) states.appVersion = info;\n })\n .catch((e) => {\n console.error(e);\n });\n };\n useInterval(checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes\n usePageVisibility((visible) => {\n if (visible) {\n if (!lastCheckDate.current) {\n checkForUpdates();\n } else {\n const diff = Date.now() - lastCheckDate.current;\n if (diff > 1000 * 60 * 60) {\n // 1 hour\n checkForUpdates();\n }\n }\n }\n });\n\n // Global keyboard shortcuts \"service\"\n useHotkeys('shift+alt+k', () => {\n const currentCloakMode = states.settings.cloakMode;\n states.settings.cloakMode = !currentCloakMode;\n showToast({\n text: `Cloak mode ${currentCloakMode ? 'disabled' : 'enabled'}`,\n });\n });\n\n return null;\n});\n","import { useHotkeys } from 'react-hotkeys-hook';\nimport { useSnapshot } from 'valtio';\n\nimport openCompose from '../utils/open-compose';\nimport openOSK from '../utils/open-osk';\nimport states from '../utils/states';\n\nimport Icon from './icon';\n\nexport default function ComposeButton() {\n const snapStates = useSnapshot(states);\n\n function handleButton(e) {\n if (snapStates.composerState.minimized) {\n states.composerState.minimized = false;\n openOSK();\n return;\n }\n\n if (e.shiftKey) {\n const newWin = openCompose();\n\n if (!newWin) {\n states.showCompose = true;\n }\n } else {\n openOSK();\n states.showCompose = true;\n }\n }\n\n useHotkeys('c, shift+c', handleButton, {\n ignoreEventWhen: (e) => {\n const hasModal = !!document.querySelector('#modal-container > *');\n return hasModal;\n },\n });\n\n return (\n \n \n \n );\n}\n","import './keyboard-shortcuts-help.css';\n\nimport { memo } from 'preact/compat';\nimport { useHotkeys } from 'react-hotkeys-hook';\nimport { useSnapshot } from 'valtio';\n\nimport states from '../utils/states';\n\nimport Icon from './icon';\nimport Modal from './modal';\n\nexport default memo(function KeyboardShortcutsHelp() {\n const snapStates = useSnapshot(states);\n\n function onClose() {\n states.showKeyboardShortcutsHelp = false;\n }\n\n useHotkeys(\n '?, shift+?, shift+slash',\n (e) => {\n console.log('help');\n states.showKeyboardShortcutsHelp = true;\n },\n {\n ignoreEventWhen: (e) => {\n const hasModal = !!document.querySelector('#modal-container > *');\n return hasModal;\n },\n },\n );\n\n return (\n !!snapStates.showKeyboardShortcutsHelp && (\n \n
\n \n
\n

Keyboard shortcuts

\n
\n
\n \n {[\n {\n action: 'Keyboard shortcuts help',\n keys: ?,\n },\n {\n action: 'Next post',\n keys: j,\n },\n {\n action: 'Previous post',\n keys: k,\n },\n {\n action: 'Skip carousel to next post',\n keys: (\n <>\n Shift + j\n \n ),\n },\n {\n action: 'Skip carousel to previous post',\n keys: (\n <>\n Shift + k\n \n ),\n },\n {\n action: 'Load new posts',\n keys: .,\n },\n {\n action: 'Open post details',\n keys: (\n <>\n Enter or o\n \n ),\n },\n {\n action: (\n <>\n Expand content warning or\n
\n toggle expanded/collapsed thread\n \n ),\n keys: x,\n },\n {\n action: 'Close post or dialogs',\n keys: (\n <>\n Esc or Backspace\n \n ),\n },\n {\n action: 'Focus column in multi-column mode',\n keys: (\n <>\n 1 to 9\n \n ),\n },\n {\n action: 'Compose new post',\n keys: c,\n },\n {\n action: 'Compose new post (new window)',\n className: 'insignificant',\n keys: (\n <>\n Shift + c\n \n ),\n },\n {\n action: 'Send post',\n keys: (\n <>\n Ctrl + Enter or ⌘ +{' '}\n Enter\n \n ),\n },\n {\n action: 'Search',\n keys: /,\n },\n {\n action: 'Reply',\n keys: r,\n },\n {\n action: 'Reply (new window)',\n className: 'insignificant',\n keys: (\n <>\n Shift + r\n \n ),\n },\n {\n action: 'Like (favourite)',\n keys: (\n <>\n l or f\n \n ),\n },\n {\n action: 'Boost',\n keys: (\n <>\n Shift + b\n \n ),\n },\n {\n action: 'Bookmark',\n keys: d,\n },\n {\n action: 'Toggle Cloak mode',\n keys: (\n <>\n Shift + Alt + k\n \n ),\n },\n ].map(({ action, className, keys }) => (\n \n \n \n \n ))}\n
{action}{keys}
\n
\n
\n
\n )\n );\n});\n","import './accounts.css';\n\nimport { useAutoAnimate } from '@formkit/auto-animate/preact';\nimport { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';\nimport { useReducer } from 'preact/hooks';\n\nimport Avatar from '../components/avatar';\nimport Icon from '../components/icon';\nimport Link from '../components/link';\nimport Menu2 from '../components/menu2';\nimport MenuConfirm from '../components/menu-confirm';\nimport NameText from '../components/name-text';\nimport { api } from '../utils/api';\nimport states from '../utils/states';\nimport store from '../utils/store';\nimport { getCurrentAccountID, setCurrentAccountID } from '../utils/store-utils';\n\nfunction Accounts({ onClose }) {\n const { masto } = api();\n // Accounts\n const accounts = store.local.getJSON('accounts');\n const currentAccount = getCurrentAccountID();\n const moreThanOneAccount = accounts.length > 1;\n\n const [_, reload] = useReducer((x) => x + 1, 0);\n const [accountsListParent] = useAutoAnimate();\n\n return (\n
\n {!!onClose && (\n \n )}\n
\n

Accounts

\n
\n
\n
\n
    \n {accounts.map((account, i) => {\n const isCurrent = account.info.id === currentAccount;\n const isDefault = i === 0; // first account is always default\n return (\n
  • \n
    \n {moreThanOneAccount && (\n \n \n \n )}\n {\n if (isCurrent) {\n try {\n const info = await masto.v1.accounts\n .$select(account.info.id)\n .fetch();\n console.log('fetched account info', info);\n account.info = info;\n store.local.setJSON('accounts', accounts);\n reload();\n } catch (e) {}\n }\n }}\n />\n {\n if (isCurrent) {\n states.showAccount = `${account.info.username}@${account.instanceURL}`;\n } else {\n setCurrentAccountID(account.info.id);\n location.reload();\n }\n }}\n />\n
    \n
    \n {isDefault && moreThanOneAccount && (\n <>\n Default{' '}\n \n )}\n \n \n \n }\n >\n {\n states.showAccount = `${account.info.username}@${account.instanceURL}`;\n }}\n >\n \n View profile…\n \n \n {moreThanOneAccount && (\n {\n // Move account to the top of the list\n accounts.splice(i, 1);\n accounts.unshift(account);\n store.local.setJSON('accounts', accounts);\n reload();\n }}\n >\n \n Set as default\n \n )}\n \n \n Log out @{account.info.acct}?\n \n }\n disabled={!isCurrent}\n menuItemClassName=\"danger\"\n onClick={() => {\n // const yes = confirm('Log out?');\n // if (!yes) return;\n accounts.splice(i, 1);\n store.local.setJSON('accounts', accounts);\n // location.reload();\n location.href = location.pathname || '/';\n }}\n >\n \n Log out…\n \n \n
    \n
  • \n );\n })}\n
\n

\n \n Add an existing account\n \n

\n {moreThanOneAccount && (\n

\n \n Note: Default account will always be used for first load.\n Switched accounts will persist during the session.\n \n

\n )}\n
\n
\n
\n );\n}\n\nexport default Accounts;\n","export default \"data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20xml:space='preserve'%20fill-rule='evenodd'%20stroke-linejoin='round'%20stroke-miterlimit='2'%20clip-rule='evenodd'%20viewBox='0%200%2064%2064'%3e%3cpath%20fill='none'%20d='M0%200h63.994v63.994H0z'/%3e%3cpath%20fill='%23a4bff7'%20d='M37.774%2011.471c14.639%203.752%2019.034%2016.557%2015.889%2031.304-.696%203.261-2.563%206.661-6.356%208.693-3.204%201.717-8.07%202.537-15.338.55l-9.634-2.404C11.651%2046.992%208.378%2038.733%2010.027%2031.823c3.627-15.201%2015.543-23.48%2027.747-20.352Z'/%3e%3cpath%20fill='%23d8e7fe'%20d='M36.76%2015.429c12.289%203.15%2015.547%2014.114%2012.907%2026.493-.947%204.44-4.937%209.365-16.664%206.143l-9.684-2.417c-7.854-1.923-10.53-7.8-9.318-12.877%203.016-12.639%2012.611-19.943%2022.759-17.342Z'/%3e%3cpath%20fill='%236081e6'%20d='M27.471%2024.991c-1.457-.698-7.229%203.213-7.663%208.926-.182%202.39%204.55%203.237%205.071-.169.725-4.743%203.715-8.218%202.592-8.757Zm10.746%202.005c-2.083.327-.382%205.901-.595%2010.727-.123%202.8%204.388%203.464%204.703%202.011%201.098-5.073-2.066-13.058-4.108-12.738Z'/%3e%3c/svg%3e\"","// Utils for push notifications\nimport { api } from './api';\nimport { getCurrentAccount } from './store-utils';\n\n// Subscription is an object with the following structure:\n// {\n// data: {\n// alerts: {\n// admin: {\n// report: boolean,\n// signUp: boolean,\n// },\n// favourite: boolean,\n// follow: boolean,\n// mention: boolean,\n// poll: boolean,\n// reblog: boolean,\n// status: boolean,\n// update: boolean,\n// }\n// },\n// policy: \"all\" | \"followed\" | \"follower\" | \"none\",\n// subscription: {\n// endpoint: string,\n// keys: {\n// auth: string,\n// p256dh: string,\n// },\n// },\n// }\n\n// Back-end CRUD\n// =============\n\nfunction createBackendPushSubscription(subscription) {\n const { masto } = api();\n return masto.v1.push.subscription.create(subscription);\n}\n\nfunction fetchBackendPushSubscription() {\n const { masto } = api();\n return masto.v1.push.subscription.fetch();\n}\n\nfunction updateBackendPushSubscription(subscription) {\n const { masto } = api();\n return masto.v1.push.subscription.update(subscription);\n}\n\nfunction removeBackendPushSubscription() {\n const { masto } = api();\n return masto.v1.push.subscription.remove();\n}\n\n// Front-end\n// =========\n\nexport function isPushSupported() {\n return 'serviceWorker' in navigator && 'PushManager' in window;\n}\n\nexport function getRegistration() {\n // return navigator.serviceWorker.ready;\n return navigator.serviceWorker.getRegistration();\n}\n\nasync function getSubscription() {\n const registration = await getRegistration();\n const subscription = registration\n ? await registration.pushManager.getSubscription()\n : undefined;\n return { registration, subscription };\n}\n\nfunction urlBase64ToUint8Array(base64String) {\n const padding = '='.repeat((4 - (base64String.length % 4)) % 4);\n const base64 = `${base64String}${padding}`\n .replace(/-/g, '+')\n .replace(/_/g, '/');\n\n const rawData = window.atob(base64);\n const outputArray = new Uint8Array(rawData.length);\n\n for (let i = 0; i < rawData.length; ++i) {\n outputArray[i] = rawData.charCodeAt(i);\n }\n\n return outputArray;\n}\n\n// Front-end <-> back-end\n// ======================\n\nexport async function initSubscription() {\n if (!isPushSupported()) return;\n const { subscription } = await getSubscription();\n let backendSubscription = null;\n try {\n backendSubscription = await fetchBackendPushSubscription();\n } catch (err) {\n if (/(not found|unknown)/i.test(err.message)) {\n // No subscription found\n } else {\n // Other error\n throw err;\n }\n }\n console.log('INIT subscription', {\n subscription,\n backendSubscription,\n });\n\n // Check if the subscription changed\n if (backendSubscription && subscription) {\n const sameEndpoint = backendSubscription.endpoint === subscription.endpoint;\n const { vapidKey } = getCurrentAccount();\n const sameKey = backendSubscription.serverKey === vapidKey;\n if (!sameEndpoint) {\n throw new Error('Backend subscription endpoint changed');\n }\n if (sameKey) {\n // Subscription didn't change\n } else {\n // Subscription changed\n console.error('πŸ”” Subscription changed', {\n sameEndpoint,\n serverKey: backendSubscription.serverKey,\n vapIdKey: vapidKey,\n endpoint1: backendSubscription.endpoint,\n endpoint2: subscription.endpoint,\n sameKey,\n key1: backendSubscription.serverKey,\n key2: vapidKey,\n });\n throw new Error('Backend subscription key and vapid key changed');\n // Only unsubscribe from backend, not from browser\n // await removeBackendPushSubscription();\n // // Now let's resubscribe\n // // NOTE: I have no idea if this works\n // return await updateSubscription({\n // data: backendSubscription.data,\n // policy: backendSubscription.policy,\n // });\n }\n }\n\n if (subscription && !backendSubscription) {\n // check if account's vapidKey is same as subscription's applicationServerKey\n const { vapidKey } = getCurrentAccount();\n const { applicationServerKey } = subscription.options;\n const vapidKeyStr = urlBase64ToUint8Array(vapidKey).toString();\n const applicationServerKeyStr = new Uint8Array(\n applicationServerKey,\n ).toString();\n const sameKey = vapidKeyStr === applicationServerKeyStr;\n if (sameKey) {\n // Subscription didn't change\n } else {\n // Subscription changed\n console.error('πŸ”” Subscription changed', {\n vapidKeyStr,\n applicationServerKeyStr,\n sameKey,\n });\n // Unsubscribe since backend doesn't have a subscription\n await subscription.unsubscribe();\n throw new Error('Subscription key and vapid key changed');\n }\n }\n\n // Check if backend subscription returns 404\n // if (subscription && !backendSubscription) {\n // // Re-subscribe to backend\n // backendSubscription = await createBackendPushSubscription({\n // subscription,\n // data: {},\n // policy: 'all',\n // });\n // }\n\n return { subscription, backendSubscription };\n}\n\nexport async function updateSubscription({ data, policy }) {\n console.log('πŸ”” Updating subscription', { data, policy });\n if (!isPushSupported()) return;\n let { registration, subscription } = await getSubscription();\n let backendSubscription = null;\n\n if (subscription) {\n try {\n backendSubscription = await updateBackendPushSubscription({\n data,\n policy,\n });\n // TODO: save subscription in user settings\n } catch (error) {\n // Backend doesn't have a subscription for this user\n // Create a new one\n backendSubscription = await createBackendPushSubscription({\n subscription,\n data,\n policy,\n });\n // TODO: save subscription in user settings\n }\n } else {\n // User is not subscribed\n const { vapidKey } = getCurrentAccount();\n if (!vapidKey) throw new Error('No server key found');\n subscription = await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: urlBase64ToUint8Array(vapidKey),\n });\n backendSubscription = await createBackendPushSubscription({\n subscription,\n data,\n policy,\n });\n // TODO: save subscription in user settings\n }\n\n return { subscription, backendSubscription };\n}\n\nexport async function removeSubscription() {\n if (!isPushSupported()) return;\n const { subscription } = await getSubscription();\n if (subscription) {\n await removeBackendPushSubscription();\n await subscription.unsubscribe();\n }\n}\n","import './settings.css';\n\nimport { useEffect, useRef, useState } from 'preact/hooks';\nimport { useSnapshot } from 'valtio';\n\nimport logo from '../assets/logo.svg';\n\nimport Icon from '../components/icon';\nimport Link from '../components/link';\nimport RelativeTime from '../components/relative-time';\nimport targetLanguages from '../data/lingva-target-languages';\nimport { api } from '../utils/api';\nimport getTranslateTargetLanguage from '../utils/get-translate-target-language';\nimport localeCode2Text from '../utils/localeCode2Text';\nimport {\n initSubscription,\n isPushSupported,\n removeSubscription,\n updateSubscription,\n} from '../utils/push-notifications';\nimport showToast from '../utils/show-toast';\nimport states from '../utils/states';\nimport store from '../utils/store';\n\nconst DEFAULT_TEXT_SIZE = 16;\nconst TEXT_SIZES = [14, 15, 16, 17, 18, 19, 20];\nconst {\n PHANPY_WEBSITE: WEBSITE,\n PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL,\n PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,\n PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,\n} = import.meta.env;\n\nfunction Settings({ onClose }) {\n const snapStates = useSnapshot(states);\n const currentTheme = store.local.get('theme') || 'auto';\n const themeFormRef = useRef();\n const targetLanguage =\n snapStates.settings.contentTranslationTargetLanguage || null;\n const systemTargetLanguage = getTranslateTargetLanguage();\n const systemTargetLanguageText = localeCode2Text(systemTargetLanguage);\n const currentTextSize = store.local.get('textSize') || DEFAULT_TEXT_SIZE;\n\n const [prefs, setPrefs] = useState(store.account.get('preferences') || {});\n const { masto, authenticated, instance } = api();\n // Get preferences every time Settings is opened\n // NOTE: Disabled for now because I don't expect this to change often. Also for some reason, the /api/v1/preferences endpoint is cached for a while and return old prefs if refresh immediately after changing them.\n // useEffect(() => {\n // const { masto } = api();\n // (async () => {\n // try {\n // const preferences = await masto.v1.preferences.fetch();\n // setPrefs(preferences);\n // store.account.set('preferences', preferences);\n // } catch (e) {\n // // Silently fail\n // console.error(e);\n // }\n // })();\n // }, []);\n\n return (\n
\n {!!onClose && (\n \n )}\n
\n

Settings

\n
\n
\n
\n
    \n
  • \n
    \n \n
    \n
    \n {\n console.log(e);\n e.preventDefault();\n const formData = new FormData(themeFormRef.current);\n const theme = formData.get('theme');\n const html = document.documentElement;\n\n if (theme === 'auto') {\n html.classList.remove('is-light', 'is-dark');\n\n // Disable manual theme \n const $manualMeta = document.querySelector(\n 'meta[data-theme-setting=\"manual\"]',\n );\n if ($manualMeta) {\n $manualMeta.name = '';\n }\n // Enable auto theme s\n const $autoMetas = document.querySelectorAll(\n 'meta[data-theme-setting=\"auto\"]',\n );\n $autoMetas.forEach((m) => {\n m.name = 'theme-color';\n });\n } else {\n html.classList.toggle('is-light', theme === 'light');\n html.classList.toggle('is-dark', theme === 'dark');\n\n // Enable manual theme \n const $manualMeta = document.querySelector(\n 'meta[data-theme-setting=\"manual\"]',\n );\n if ($manualMeta) {\n $manualMeta.name = 'theme-color';\n $manualMeta.content =\n theme === 'light'\n ? $manualMeta.dataset.themeLightColor\n : $manualMeta.dataset.themeDarkColor;\n }\n // Disable auto theme s\n const $autoMetas = document.querySelectorAll(\n 'meta[data-theme-setting=\"auto\"]',\n );\n $autoMetas.forEach((m) => {\n m.name = '';\n });\n }\n document\n .querySelector('meta[name=\"color-scheme\"]')\n .setAttribute(\n 'content',\n theme === 'auto' ? 'dark light' : theme,\n );\n\n if (theme === 'auto') {\n store.local.del('theme');\n } else {\n store.local.set('theme', theme);\n }\n }}\n >\n
    \n \n \n \n
    \n \n
    \n
  • \n
  • \n
    \n \n
    \n
    \n A{' '}\n {\n const value = parseInt(e.target.value, 10);\n const html = document.documentElement;\n // set CSS variable\n html.style.setProperty('--text-size', `${value}px`);\n // save to local storage\n if (value === DEFAULT_TEXT_SIZE) {\n store.local.del('textSize');\n } else {\n store.local.set('textSize', e.target.value);\n }\n }}\n />{' '}\n \n A\n \n \n {TEXT_SIZES.map((size) => (\n \n
    \n
  • \n
\n
\n {authenticated && (\n <>\n

Posting

\n
\n
    \n
  • \n
    \n \n
    \n
    \n {\n const { value } = e.target;\n (async () => {\n try {\n await masto.v1.accounts.updateCredentials({\n source: {\n privacy: value,\n },\n });\n setPrefs({\n ...prefs,\n 'posting:default:visibility': value,\n });\n store.account.set('preferences', {\n ...prefs,\n 'posting:default:visibility': value,\n });\n } catch (e) {\n alert('Failed to update posting privacy');\n console.error(e);\n }\n })();\n }}\n >\n \n \n \n \n
    \n
  • \n
\n
\n

\n {' '}\n \n Synced to your instance server's settings.{' '}\n \n Go to your instance ({instance}) for more settings.\n \n \n

\n \n )}\n

Experiments

\n
\n
    \n
  • \n \n
  • \n
  • \n \n
  • \n
  • \n \n \n
    \n \n
    \n
    \n

    \n Hide \"Translate\" button for\n {snapStates.settings.contentTranslationHideLanguages.length >\n 0 && (\n <>\n {' '}\n (\n {\n snapStates.settings.contentTranslationHideLanguages\n .length\n }\n )\n \n )}\n :\n

    \n {targetLanguages.map((lang) => (\n \n ))}\n
    \n

    \n

    \n \n Note: This feature uses external translation services,\n powered by{' '}\n \n Lingva API\n {' '}\n &{' '}\n \n Lingva Translate\n \n .\n \n

    \n
    \n
    \n \n

    \n \n Automatically show translation for posts in timeline. Only\n works for short posts without content warning,\n media and poll.\n \n

    \n
    \n
\n \n {!!GIPHY_API_KEY && authenticated && (\n
  • \n \n
    \n \n Note: This feature uses external GIF search service, powered\n by{' '}\n \n GIPHY\n \n . G-rated (suitable for viewing by all ages), tracking\n parameters are stripped, referrer information is omitted\n from requests, but search queries and IP address information\n will still reach their servers.\n \n
    \n
  • \n )}\n {!!IMG_ALT_API_URL && authenticated && (\n
  • \n \n
    \n Only for new images while composing new posts.\n
    \n
    \n \n Note: This feature uses external AI service, powered by{' '}\n \n img-alt-api\n \n . May not work well. Only for images and in English.\n \n
    \n
  • \n )}\n {authenticated && (\n
  • \n \n
    \n \n ⚠️⚠️⚠️ Very experimental.\n
    \n Stored in your own profile’s notes. Profile (private) notes\n are mainly used for other profiles, and hidden for own\n profile.\n
    \n
    \n
    \n \n Note: This feature uses currently-logged-in instance server\n API.\n \n
    \n
  • \n )}\n
  • \n \n
    \n \n Replace text as blocks, useful when taking screenshots, for\n privacy reasons.\n \n
    \n
  • \n {authenticated && (\n
  • \n {\n states.showDrafts = true;\n states.showSettings = false;\n }}\n >\n Unsent drafts\n \n
  • \n )}\n \n \n {authenticated && }\n

    About

    \n
    \n \n \n
    \n Phanpy{' '}\n {\n e.preventDefault();\n states.showAccount = 'phanpy@hachyderm.io';\n }}\n >\n @phanpy\n \n
    \n \n Built\n {' '}\n by{' '}\n {\n e.preventDefault();\n states.showAccount = 'cheeaun@mastodon.social';\n }}\n >\n @cheeaun\n \n
    \n \n

    \n \n Sponsor\n {' '}\n ·{' '}\n \n Donate\n {' '}\n ·{' '}\n \n Privacy Policy\n \n

    \n {__BUILD_TIME__ && (\n

    \n {WEBSITE && (\n <>\n Site:{' '}\n {WEBSITE.replace(/https?:\\/\\//g, '').replace(/\\/$/, '')}\n
    \n \n )}\n Version:{' '}\n {\n e.target.select();\n // Copy to clipboard\n try {\n navigator.clipboard.writeText(e.target.value);\n showToast('Version string copied');\n } catch (e) {\n console.warn(e);\n showToast('Unable to copy version string');\n }\n }}\n />{' '}\n {!__FAKE_COMMIT_HASH__ && (\n \n (\n \n \n \n )\n \n )}\n

    \n )}\n
    \n \n \n );\n}\n\nfunction PushNotificationsSection({ onClose }) {\n if (!isPushSupported()) return null;\n\n const { instance } = api();\n const [uiState, setUIState] = useState('default');\n const pushFormRef = useRef();\n const [allowNotifications, setAllowNotifications] = useState(false);\n const [needRelogin, setNeedRelogin] = useState(false);\n const previousPolicyRef = useRef();\n useEffect(() => {\n (async () => {\n setUIState('loading');\n try {\n const { subscription, backendSubscription } = await initSubscription();\n if (\n backendSubscription?.policy &&\n backendSubscription.policy !== 'none'\n ) {\n setAllowNotifications(true);\n const { alerts, policy } = backendSubscription;\n console.log('backendSubscription', backendSubscription);\n previousPolicyRef.current = policy;\n const { elements } = pushFormRef.current;\n const policyEl = elements.namedItem('policy');\n if (policyEl) policyEl.value = policy;\n // alerts is {}, iterate it\n Object.keys(alerts).forEach((alert) => {\n const el = elements.namedItem(alert);\n if (el?.type === 'checkbox') {\n el.checked = true;\n }\n });\n }\n setUIState('default');\n } catch (err) {\n console.warn(err);\n if (/outside.*authorized/i.test(err.message)) {\n setNeedRelogin(true);\n } else {\n alert(err?.message || err);\n }\n setUIState('error');\n }\n })();\n }, []);\n\n const isLoading = uiState === 'loading';\n\n return (\n {\n setTimeout(() => {\n const values = Object.fromEntries(new FormData(pushFormRef.current));\n const allowNotifications = !!values['policy-allow'];\n const params = {\n data: {\n policy: values.policy,\n alerts: {\n mention: !!values.mention,\n favourite: !!values.favourite,\n reblog: !!values.reblog,\n follow: !!values.follow,\n follow_request: !!values.followRequest,\n poll: !!values.poll,\n update: !!values.update,\n status: !!values.status,\n },\n },\n };\n\n let alertsCount = 0;\n // Remove false values from data.alerts\n // API defaults to false anyway\n Object.keys(params.data.alerts).forEach((key) => {\n if (!params.data.alerts[key]) {\n delete params.data.alerts[key];\n } else {\n alertsCount++;\n }\n });\n const policyChanged =\n previousPolicyRef.current !== params.data.policy;\n\n console.log('PN Form', {\n values,\n allowNotifications: allowNotifications,\n params,\n });\n\n if (allowNotifications && alertsCount > 0) {\n if (policyChanged) {\n console.debug('Policy changed.');\n removeSubscription()\n .then(() => {\n updateSubscription(params);\n })\n .catch((err) => {\n console.warn(err);\n alert('Failed to update subscription. Please try again.');\n });\n } else {\n updateSubscription(params).catch((err) => {\n console.warn(err);\n alert('Failed to update subscription. Please try again.');\n });\n }\n } else {\n removeSubscription().catch((err) => {\n console.warn(err);\n alert('Failed to remove subscription. Please try again.');\n });\n }\n }, 100);\n }}\n >\n

    Push Notifications (beta)

    \n
    \n
      \n
    • \n \n
    • \n
    \n
    \n

    \n \n NOTE: Push notifications only work for one account.\n \n

    \n \n );\n}\n\nexport default Settings;\n","const focusDeck = () => {\n let timer = setTimeout(() => {\n const columns = document.getElementById('columns');\n if (columns) {\n // Focus first column\n // columns.querySelector('.deck-container')?.focus?.();\n } else {\n const modals = document.querySelectorAll('#modal-container > *');\n if (modals?.length) {\n // Focus last modal\n const modal = modals[modals.length - 1]; // last one\n const modalFocusElement =\n modal.querySelector('[tabindex=\"-1\"]') || modal;\n if (modalFocusElement) {\n modalFocusElement.focus();\n return;\n }\n }\n const backDrop = document.querySelector('.deck-backdrop');\n if (backDrop) return;\n // Focus last deck\n const pages = document.querySelectorAll('.deck-container');\n const page = pages[pages.length - 1]; // last one\n if (page && page.tabIndex === -1) {\n console.log('FOCUS', page);\n page.focus();\n }\n }\n }, 100);\n return () => clearTimeout(timer);\n};\n\nexport default focusDeck;\n","import { useEffect, useRef } from 'preact/hooks';\nimport { useLocation } from 'react-router-dom';\n\n// Hook that runs a callback when the location changes\n// Won't run on the first render\n\nexport default function useLocationChange(fn) {\n if (!fn) return;\n const location = useLocation();\n const currentLocationRef = useRef(location.pathname);\n useEffect(() => {\n // console.log('location', {\n // current: currentLocationRef.current,\n // next: location.pathname,\n // });\n if (\n currentLocationRef.current &&\n location.pathname !== currentLocationRef.current\n ) {\n fn?.();\n }\n }, [location.pathname, fn]);\n}\n","import { api } from './api';\nimport pmem from './pmem';\nimport store from './store';\n\nconst FETCH_MAX_AGE = 1000 * 60; // 1 minute\nconst MAX_AGE = 24 * 60 * 60 * 1000; // 1 day\n\nexport const fetchLists = pmem(\n async () => {\n const { masto } = api();\n const lists = await masto.v1.lists.list();\n lists.sort((a, b) => a.title.localeCompare(b.title));\n\n if (lists.length) {\n setTimeout(() => {\n // Save to local storage, with saved timestamp\n store.account.set('lists', {\n lists,\n updatedAt: Date.now(),\n });\n }, 1);\n }\n\n return lists;\n },\n {\n maxAge: FETCH_MAX_AGE,\n },\n);\n\nexport async function getLists() {\n try {\n const { lists, updatedAt } = store.account.get('lists') || {};\n if (!lists?.length) return await fetchLists();\n if (Date.now() - updatedAt > MAX_AGE) {\n // Stale-while-revalidate\n fetchLists();\n return lists;\n }\n return lists;\n } catch (e) {\n return [];\n }\n}\n\nexport const fetchList = pmem(\n (id) => {\n const { masto } = api();\n return masto.v1.lists.$select(id).fetch();\n },\n {\n maxAge: FETCH_MAX_AGE,\n },\n);\n\nexport async function getList(id) {\n const { lists } = store.account.get('lists') || {};\n console.log({ lists });\n if (lists?.length) {\n const theList = lists.find((l) => l.id === id);\n if (theList) return theList;\n }\n try {\n return fetchList(id);\n } catch (e) {\n return null;\n }\n}\n\nexport async function getListTitle(id) {\n const list = await getList(id);\n return list?.title || '';\n}\n\nexport function addListStore(list) {\n const { lists } = store.account.get('lists') || {};\n if (lists?.length) {\n lists.push(list);\n lists.sort((a, b) => a.title.localeCompare(b.title));\n store.account.set('lists', {\n lists,\n updatedAt: Date.now(),\n });\n }\n}\n\nexport function updateListStore(list) {\n const { lists } = store.account.get('lists') || {};\n if (lists?.length) {\n const index = lists.findIndex((l) => l.id === list.id);\n if (index !== -1) {\n lists[index] = list;\n lists.sort((a, b) => a.title.localeCompare(b.title));\n store.account.set('lists', {\n lists,\n updatedAt: Date.now(),\n });\n }\n }\n}\n\nexport function deleteListStore(listID) {\n const { lists } = store.account.get('lists') || {};\n if (lists?.length) {\n const index = lists.findIndex((l) => l.id === listID);\n if (index !== -1) {\n lists.splice(index, 1);\n store.account.set('lists', {\n lists,\n updatedAt: Date.now(),\n });\n }\n }\n}\n","import { useEffect, useRef, useState } from 'preact/hooks';\n\nimport { api } from '../utils/api';\nimport { addListStore, deleteListStore, updateListStore } from '../utils/lists';\nimport supports from '../utils/supports';\n\nimport Icon from './icon';\nimport MenuConfirm from './menu-confirm';\n\nfunction ListAddEdit({ list, onClose }) {\n const { masto } = api();\n const [uiState, setUIState] = useState('default');\n const editMode = !!list;\n const nameFieldRef = useRef();\n const repliesPolicyFieldRef = useRef();\n const exclusiveFieldRef = useRef();\n useEffect(() => {\n if (editMode) {\n nameFieldRef.current.value = list.title;\n repliesPolicyFieldRef.current.value = list.repliesPolicy;\n if (exclusiveFieldRef.current) {\n exclusiveFieldRef.current.checked = list.exclusive;\n }\n }\n }, [editMode]);\n const supportsExclusive = supports('@mastodon/list-exclusive');\n\n return (\n
    \n {!!onClose && (\n \n )}{' '}\n
    \n

    {editMode ? 'Edit list' : 'New list'}

    \n
    \n
    \n {\n e.preventDefault(); // Get form values\n\n const formData = new FormData(e.target);\n const title = formData.get('title');\n const repliesPolicy = formData.get('replies_policy');\n const exclusive = formData.get('exclusive') === 'on';\n console.log({\n title,\n repliesPolicy,\n exclusive,\n });\n setUIState('loading');\n\n (async () => {\n try {\n let listResult;\n\n if (editMode) {\n listResult = await masto.v1.lists.$select(list.id).update({\n title,\n replies_policy: repliesPolicy,\n exclusive,\n });\n } else {\n listResult = await masto.v1.lists.create({\n title,\n replies_policy: repliesPolicy,\n exclusive,\n });\n }\n\n console.log(listResult);\n setUIState('default');\n onClose?.({\n state: 'success',\n list: listResult,\n });\n\n setTimeout(() => {\n if (editMode) {\n updateListStore(listResult);\n } else {\n addListStore(listResult);\n }\n }, 1);\n } catch (e) {\n console.error(e);\n setUIState('error');\n alert(\n editMode ? 'Unable to edit list.' : 'Unable to create list.',\n );\n }\n })();\n }}\n >\n
    \n \n
    \n
    \n \n \n \n \n \n
    \n {supportsExclusive && (\n
    \n \n
    \n )}\n
    \n \n {editMode && (\n {\n // const yes = confirm('Delete this list?');\n // if (!yes) return;\n setUIState('loading');\n\n (async () => {\n try {\n await masto.v1.lists.$select(list.id).remove();\n setUIState('default');\n onClose?.({\n state: 'deleted',\n });\n setTimeout(() => {\n deleteListStore(list.id);\n }, 1);\n } catch (e) {\n console.error(e);\n setUIState('error');\n alert('Unable to delete list.');\n }\n })();\n }}\n >\n \n Delete…\n \n \n )}\n
    \n \n
    \n
    \n );\n}\n\nexport default ListAddEdit;\n","import './account-info.css';\n\nimport { MenuDivider, MenuItem } from '@szhsin/react-menu';\nimport {\n useCallback,\n useEffect,\n useMemo,\n useReducer,\n useRef,\n useState,\n} from 'preact/hooks';\nimport punycode from 'punycode';\n\nimport { api } from '../utils/api';\nimport enhanceContent from '../utils/enhance-content';\nimport getHTMLText from '../utils/getHTMLText';\nimport handleContentLinks from '../utils/handle-content-links';\nimport { getLists } from '../utils/lists';\nimport niceDateTime from '../utils/nice-date-time';\nimport pmem from '../utils/pmem';\nimport shortenNumber from '../utils/shorten-number';\nimport showCompose from '../utils/show-compose';\nimport showToast from '../utils/show-toast';\nimport states, { hideAllModals } from '../utils/states';\nimport store from '../utils/store';\nimport { getCurrentAccountID, updateAccount } from '../utils/store-utils';\nimport supports from '../utils/supports';\n\nimport AccountBlock from './account-block';\nimport Avatar from './avatar';\nimport EmojiText from './emoji-text';\nimport Icon from './icon';\nimport Link from './link';\nimport ListAddEdit from './list-add-edit';\nimport Loader from './loader';\nimport Menu2 from './menu2';\nimport MenuConfirm from './menu-confirm';\nimport MenuLink from './menu-link';\nimport Modal from './modal';\nimport SubMenu2 from './submenu2';\nimport TranslationBlock from './translation-block';\n\nconst MUTE_DURATIONS = [\n 60 * 5, // 5 minutes\n 60 * 30, // 30 minutes\n 60 * 60, // 1 hour\n 60 * 60 * 6, // 6 hours\n 60 * 60 * 24, // 1 day\n 60 * 60 * 24 * 3, // 3 days\n 60 * 60 * 24 * 7, // 1 week\n 0, // forever\n];\nconst MUTE_DURATIONS_LABELS = {\n 0: 'Forever',\n 300: '5 minutes',\n 1_800: '30 minutes',\n 3_600: '1 hour',\n 21_600: '6 hours',\n 86_400: '1 day',\n 259_200: '3 days',\n 604_800: '1 week',\n};\n\nconst LIMIT = 80;\n\nconst ACCOUNT_INFO_MAX_AGE = 1000 * 60 * 10; // 10 mins\n\nfunction fetchFamiliarFollowers(currentID, masto) {\n return masto.v1.accounts.familiarFollowers.fetch({\n id: [currentID],\n });\n}\nconst memFetchFamiliarFollowers = pmem(fetchFamiliarFollowers, {\n maxAge: ACCOUNT_INFO_MAX_AGE,\n});\n\nasync function fetchPostingStats(accountID, masto) {\n const fetchStatuses = masto.v1.accounts\n .$select(accountID)\n .statuses.list({\n limit: 20,\n })\n .next();\n\n const { value: statuses } = await fetchStatuses;\n console.log('fetched statuses', statuses);\n const stats = {\n total: statuses.length,\n originals: 0,\n replies: 0,\n boosts: 0,\n };\n // Categories statuses by type\n // - Original posts (not replies to others)\n // - Threads (self-replies + 1st original post)\n // - Boosts (reblogs)\n // - Replies (not-self replies)\n statuses.forEach((status) => {\n if (status.reblog) {\n stats.boosts++;\n } else if (\n !!status.inReplyToId &&\n status.inReplyToAccountId !== status.account.id // Not self-reply\n ) {\n stats.replies++;\n } else {\n stats.originals++;\n }\n });\n\n // Count days since last post\n if (statuses.length) {\n stats.daysSinceLastPost = Math.ceil(\n (Date.now() - new Date(statuses[statuses.length - 1].createdAt)) /\n 86400000,\n );\n }\n\n console.log('posting stats', stats);\n return stats;\n}\nconst memFetchPostingStats = pmem(fetchPostingStats, {\n maxAge: ACCOUNT_INFO_MAX_AGE,\n});\n\nfunction AccountInfo({\n account,\n fetchAccount = () => {},\n standalone,\n instance,\n authenticated,\n}) {\n const { masto } = api({\n instance,\n });\n const { masto: currentMasto, instance: currentInstance } = api();\n const [uiState, setUIState] = useState('default');\n const isString = typeof account === 'string';\n const [info, setInfo] = useState(isString ? null : account);\n\n const sameCurrentInstance = useMemo(\n () => instance === currentInstance,\n [instance, currentInstance],\n );\n\n useEffect(() => {\n if (!isString) {\n setInfo(account);\n return;\n }\n setUIState('loading');\n (async () => {\n try {\n const info = await fetchAccount();\n states.accounts[`${info.id}@${instance}`] = info;\n setInfo(info);\n setUIState('default');\n } catch (e) {\n console.error(e);\n setInfo(null);\n setUIState('error');\n }\n })();\n }, [isString, account, fetchAccount]);\n\n const {\n acct,\n avatar,\n avatarStatic,\n bot,\n createdAt,\n displayName,\n emojis,\n fields,\n followersCount,\n followingCount,\n group,\n // header,\n // headerStatic,\n id,\n lastStatusAt,\n locked,\n note,\n statusesCount,\n url,\n username,\n memorial,\n moved,\n roles,\n hideCollections,\n } = info || {};\n let headerIsAvatar = false;\n let { header, headerStatic } = info || {};\n if (!header || /missing\\.png$/.test(header)) {\n if (avatar && !/missing\\.png$/.test(avatar)) {\n header = avatar;\n headerIsAvatar = true;\n if (avatarStatic && !/missing\\.png$/.test(avatarStatic)) {\n headerStatic = avatarStatic;\n }\n }\n }\n\n const isSelf = useMemo(() => id === getCurrentAccountID(), [id]);\n\n useEffect(() => {\n const infoHasEssentials = !!(\n info?.id &&\n info?.username &&\n info?.acct &&\n info?.avatar &&\n info?.avatarStatic &&\n info?.displayName &&\n info?.url\n );\n if (isSelf && instance && infoHasEssentials) {\n const accounts = store.local.getJSON('accounts');\n let updated = false;\n accounts.forEach((account) => {\n if (account.info.id === info.id && account.instanceURL === instance) {\n account.info = info;\n updated = true;\n }\n });\n if (updated) {\n console.log('Updated account info', info);\n store.local.setJSON('accounts', accounts);\n }\n }\n }, [isSelf, info, instance]);\n\n const accountInstance = useMemo(() => {\n if (!url) return null;\n const domain = punycode.toUnicode(new URL(url).hostname);\n return domain;\n }, [url]);\n\n const [headerCornerColors, setHeaderCornerColors] = useState([]);\n\n const followersIterator = useRef();\n const familiarFollowersCache = useRef([]);\n async function fetchFollowers(firstLoad) {\n if (firstLoad || !followersIterator.current) {\n followersIterator.current = masto.v1.accounts.$select(id).followers.list({\n limit: LIMIT,\n });\n }\n const results = await followersIterator.current.next();\n if (isSelf) return results;\n if (!sameCurrentInstance) return results;\n\n const { value } = results;\n let newValue = [];\n // On first load, fetch familiar followers, merge to top of results' `value`\n // Remove dups on every fetch\n if (firstLoad) {\n let familiarFollowers = [];\n try {\n familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch({\n id: [id],\n });\n } catch (e) {}\n familiarFollowersCache.current = familiarFollowers?.[0]?.accounts || [];\n newValue = [\n ...familiarFollowersCache.current,\n ...value.filter(\n (account) =>\n !familiarFollowersCache.current.some(\n (familiar) => familiar.id === account.id,\n ),\n ),\n ];\n } else if (value?.length) {\n newValue = value.filter(\n (account) =>\n !familiarFollowersCache.current.some(\n (familiar) => familiar.id === account.id,\n ),\n );\n }\n\n return {\n ...results,\n value: newValue,\n };\n }\n\n const followingIterator = useRef();\n async function fetchFollowing(firstLoad) {\n if (firstLoad || !followingIterator.current) {\n followingIterator.current = masto.v1.accounts.$select(id).following.list({\n limit: LIMIT,\n });\n }\n const results = await followingIterator.current.next();\n return results;\n }\n\n const LinkOrDiv = standalone ? 'div' : Link;\n const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;\n\n const [familiarFollowers, setFamiliarFollowers] = useState([]);\n const [postingStats, setPostingStats] = useState();\n const [postingStatsUIState, setPostingStatsUIState] = useState('default');\n const hasPostingStats = !!postingStats?.total;\n\n const renderFamiliarFollowers = async (currentID) => {\n try {\n const followers = await memFetchFamiliarFollowers(\n currentID,\n currentMasto,\n );\n console.log('fetched familiar followers', followers);\n setFamiliarFollowers(\n followers[0].accounts.slice(0, FAMILIAR_FOLLOWERS_LIMIT),\n );\n } catch (e) {\n console.error(e);\n }\n };\n\n const renderPostingStats = async () => {\n if (!id) return;\n setPostingStatsUIState('loading');\n try {\n const stats = await memFetchPostingStats(id, masto);\n setPostingStats(stats);\n setPostingStatsUIState('default');\n } catch (e) {\n console.error(e);\n setPostingStatsUIState('error');\n }\n };\n\n const onRelationshipChange = useCallback(\n ({ relationship, currentID }) => {\n if (!relationship.following) {\n renderFamiliarFollowers(currentID);\n if (!standalone && statusesCount > 0) {\n // Only render posting stats if not standalone and has posts\n renderPostingStats();\n }\n }\n },\n [standalone, id, statusesCount],\n );\n\n const onProfileUpdate = useCallback(\n (newAccount) => {\n if (newAccount.id === id) {\n console.log('Updated account info', newAccount);\n setInfo(newAccount);\n states.accounts[`${newAccount.id}@${instance}`] = newAccount;\n }\n },\n [id, instance],\n );\n\n return (\n \n {uiState === 'error' && (\n
    \n

    Unable to load account.

    \n

    \n \n Go to account page \n \n

    \n
    \n )}\n {uiState === 'loading' ? (\n <>\n
    \n \n
    \n
    \n
    \n

    β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆ

    \n

    β–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆ

    \n
    \n \n
    \n \n \n \n \n
    \n
    \n \n ) : (\n info && (\n <>\n {!!moved && (\n
    \n

    \n {displayName} has indicated that their new account is\n now:\n

    \n {\n e.stopPropagation();\n states.showAccount = moved;\n }}\n />\n
    \n )}\n {!!header && !/missing\\.png$/.test(header) && (\n {\n if (e.target.crossOrigin) {\n if (e.target.src !== headerStatic) {\n e.target.src = headerStatic;\n } else {\n e.target.removeAttribute('crossorigin');\n e.target.src = header;\n }\n } else if (e.target.src !== headerStatic) {\n e.target.src = headerStatic;\n } else {\n e.target.remove();\n }\n }}\n crossOrigin=\"anonymous\"\n onLoad={(e) => {\n e.target.classList.add('loaded');\n try {\n // Get color from four corners of image\n const canvas = window.OffscreenCanvas\n ? new OffscreenCanvas(1, 1)\n : document.createElement('canvas');\n const ctx = canvas.getContext('2d', {\n willReadFrequently: true,\n });\n canvas.width = e.target.width;\n canvas.height = e.target.height;\n ctx.imageSmoothingEnabled = false;\n ctx.drawImage(e.target, 0, 0);\n // const colors = [\n // ctx.getImageData(0, 0, 1, 1).data,\n // ctx.getImageData(e.target.width - 1, 0, 1, 1).data,\n // ctx.getImageData(0, e.target.height - 1, 1, 1).data,\n // ctx.getImageData(\n // e.target.width - 1,\n // e.target.height - 1,\n // 1,\n // 1,\n // ).data,\n // ];\n // Get 10x10 pixels from corners, get average color from each\n const pixelDimension = 10;\n const colors = [\n ctx.getImageData(0, 0, pixelDimension, pixelDimension)\n .data,\n ctx.getImageData(\n e.target.width - pixelDimension,\n 0,\n pixelDimension,\n pixelDimension,\n ).data,\n ctx.getImageData(\n 0,\n e.target.height - pixelDimension,\n pixelDimension,\n pixelDimension,\n ).data,\n ctx.getImageData(\n e.target.width - pixelDimension,\n e.target.height - pixelDimension,\n pixelDimension,\n pixelDimension,\n ).data,\n ].map((data) => {\n let r = 0;\n let g = 0;\n let b = 0;\n let a = 0;\n for (let i = 0; i < data.length; i += 4) {\n r += data[i];\n g += data[i + 1];\n b += data[i + 2];\n a += data[i + 3];\n }\n const dataLength = data.length / 4;\n return [\n r / dataLength,\n g / dataLength,\n b / dataLength,\n a / dataLength,\n ];\n });\n const rgbColors = colors.map((color) => {\n const [r, g, b, a] = lightenRGB(color);\n return `rgba(${r}, ${g}, ${b}, ${a})`;\n });\n setHeaderCornerColors(rgbColors);\n console.log({ colors, rgbColors });\n } catch (e) {\n // Silently fail\n }\n }}\n />\n )}\n
    \n {standalone ? (\n \n {}}\n />\n \n }\n >\n
    \n \n
    \n {\n const handle = `@${acct}`;\n try {\n navigator.clipboard.writeText(handle);\n showToast('Handle copied');\n } catch (e) {\n console.error(e);\n showToast('Unable to copy handle');\n }\n }}\n >\n \n Copy handle\n \n \n \n Go to original profile page\n \n \n \n \n View profile image\n \n \n \n View profile header\n \n \n ) : (\n \n )}\n
    \n
    \n
    \n {!!memorial && In Memoriam}\n {!!bot && (\n \n Automated\n \n )}\n {!!group && (\n \n Group\n \n )}\n {roles?.map((role) => (\n \n {role.name}\n {!!accountInstance && (\n <>\n {' '}\n {accountInstance}\n \n )}\n \n ))}\n \n \n )}\n
    \n {\n // states.showAccount = false;\n setTimeout(() => {\n states.showGenericAccounts = {\n id: 'followers',\n heading: 'Followers',\n fetchAccounts: fetchFollowers,\n instance,\n excludeRelationshipAttrs: isSelf\n ? ['followedBy']\n : [],\n blankCopy: hideCollections\n ? 'This user has chosen to not make this information available.'\n : undefined,\n };\n }, 0);\n }}\n >\n {!!familiarFollowers.length && (\n \n \n {familiarFollowers.map((follower) => (\n \n ))}\n \n \n )}\n \n {shortenNumber(followersCount)}\n {' '}\n Followers\n \n {\n // states.showAccount = false;\n setTimeout(() => {\n states.showGenericAccounts = {\n heading: 'Following',\n fetchAccounts: fetchFollowing,\n instance,\n excludeRelationshipAttrs: isSelf ? ['following'] : [],\n blankCopy: hideCollections\n ? 'This user has chosen to not make this information available.'\n : undefined,\n };\n }, 0);\n }}\n >\n \n {shortenNumber(followingCount)}\n {' '}\n Following\n
    \n \n {\n // hideAllModals();\n // }\n // }\n >\n \n {shortenNumber(statusesCount)}\n {' '}\n Posts\n \n {!!createdAt && (\n
    \n Joined{' '}\n \n
    \n )}\n
    \n
    \n {!!postingStats && (\n