\n {title}\n
\n )}\n\n {description}\n
\n )}\n{"version":3,"file":"main-9vVntYq7.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/components/list-add-edit.jsx","../../src/components/account-info.jsx","../../src/components/account-sheet.jsx","../../src/components/drafts.jsx","../../src/utils/relationships.js","../../src/components/generic-accounts.jsx","../../src/components/media-alt-modal.jsx","../../src/utils/color-utils.js","../../src/components/media-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/pages/favourites.jsx","../../src/pages/followed-hashtags.jsx","../../src/pages/following.jsx","../../src/pages/hashtag.jsx","../../src/pages/list.jsx","../../src/pages/mentions.jsx","../../src/utils/group-notifications.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';\n\nimport openCompose from '../utils/open-compose';\nimport states from '../utils/states';\n\nimport Icon from './icon';\n\nexport default function ComposeButton() {\n function handleButton(e) {\n if (e.shiftKey) {\n const newWin = openCompose();\n\n if (!newWin) {\n states.showCompose = true;\n }\n } else {\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","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+?',\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 Keyboard shortcuts
\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: 'Open post details',\n keys: (\n <>\n Enter or o\n >\n ),\n },\n {\n action: (\n <>\n Expand content warning or\n
\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 {action} \n {keys} \n
\n \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 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 \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 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 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
Unable to load account.
\n\n \n Go to account page
βββββββ ββββ ββββ
\nββββ ββββββββ ββββββ βββββββββ ββββ ββ
\n\n {displayName} has indicated that their new account is\n now:\n
\n\n {text}\n
\n\n
Unable to load lists.
\n ) : (\nNo lists.
\n )}\n \n\n
No drafts found.
\n )}\nThe end.
\n )\n ) : (\n uiState === 'loading' && (\n\n
\n
Error loading accounts
\n ) : (\nNothing to show
\n )}\n\n {alt}\n
\n {(differentLanguage || forceTranslate) && (\nSpecify a list of shortcuts that'll appear as:
\nNo shortcuts yet. Tap on the Add shortcut button.
\n\n Not sure what to add?\n
\n Try adding{' '}\n {\n e.preventDefault();\n states.shortcuts = [\n {\n type: 'following',\n },\n {\n type: 'notifications',\n },\n ];\n }}\n >\n Home / Following and Notifications\n {' '}\n first.\n
\n {shortcuts.length >= SHORTCUTS_LIMIT &&\n `Max ${SHORTCUTS_LIMIT} shortcuts`}\n
\n\n \n \n
\n\n {\n setImportShortcutStr(e.target.value);\n }}\n />\n
\n {!!parsedImportShortcutStr &&\n Array.isArray(parsedImportShortcutStr) && (\n <>\n\n {parsedImportShortcutStr.length} shortcut\n {parsedImportShortcutStr.length > 1 ? 's' : ''}{' '}\n \n ({importShortcutStr.length} characters)\n \n
\n\n * Exists in current shortcuts\n
\n \n β οΈ List may not work if it's from a different account.\n \n
\n β οΈ Invalid settings format\n
\n )}\n\n {hasCurrentSettings && (\n <>\n
\n {\n if (!e.target.value) return;\n e.target.select();\n // Copy url to clipboard\n try {\n navigator.clipboard.writeText(e.target.value);\n showToast('Shortcuts copied');\n } catch (e) {\n console.error(e);\n showToast('Unable to copy shortcuts');\n }\n }}\n />\n
\n\n {' '}\n {navigator?.share &&\n navigator?.canShare?.({\n text: shortcutsStr,\n }) && (\n \n )}{' '}\n {shortcutsStr.length > 0 && (\n \n {shortcutsStr.length} characters\n \n )}\n
\n {!!shortcutsStr && (\n\n {!/poll|update/i.test(type) && (\n <>\n {_accounts?.length > 1 ? (\n <>\n \n \n {shortenNumber(_accounts.length)}\n {' '}\n people\n {' '}\n >\n ) : (\n <>\n
\n {_accounts.slice(0, AVATARS_LIMIT).map((account) => (\n
This notification is from your other account.
\n )}\nThe end.
\n ))}\n >\n ) : uiState === 'loading' ? (\n{emptyText}
\n )}\n {uiState === 'error' && (\n\n {errorText}\n
\n
\n \n
\n {uiState === 'default' ? \"You're all caught up.\" : <>…>}\n
\n )}\n {snapStates.notifications.length ? (\n <>\n {snapStates.notifications\n // This is leaked from Notifications popover\n .filter((n) => n.type !== 'follow_request')\n .map((notification) => {\n if (onlyMentions && notification.type !== 'mention') {\n return null;\n }\n const notificationDay = new Date(notification.createdAt);\n const differentDay =\n notificationDay.toDateString() !== currentDay.toDateString();\n if (differentDay) {\n currentDay = notificationDay;\n }\n // if notificationDay is yesterday, show \"Yesterday\"\n // if notificationDay is before yesterday, show date\n const heading =\n notificationDay.toDateString() ===\n yesterdayDate.toDateString()\n ? 'Yesterday'\n : niceDateTime(currentDay, {\n hideTime: true,\n });\n return (\nβββββββββββ ββββ
\n\n Unable to load notifications\n
\n
\n \n
\n \n {updatedAt && updatedAtText !== publishedDateText && (\n <>\n {' '}\n •{' '}\n \n Updated{' '}\n \n \n >\n )}\n
\n\n
No accounts found.
\n ))\n )}\n >\n )}\n {(!type || type === 'hashtags') && (\n <>\n {type !== 'hashtags' && (\n\n
No hashtags found.
\n ))\n )}\n >\n )}\n {(!type || type === 'statuses') && (\n <>\n {type !== 'statuses' && (\n\n
No posts found.
\n ))\n )}\n >\n )}\n {!!type &&\n (uiState === 'default' ? (\n showMore ? (\nThe end.
\n )\n ) : (\n uiState === 'loading' && (\n\n
\n
\n Enter your search term or paste a URL above to get started.\n
\n )}\nUnable to fetch notifications.
\n\n \n
\n\n \n {url}\n \n
\n >\n ) : (\n <>\n\n \n {url}\n \n
\n >\n )}\n\n Go home\n
\n\n
Unable to load lists.
\n ) : (\nNo lists yet.
\n )}\n\n Unable to load post\n
\n
\n \n
A minimalistic opinionated Mastodon web client.
\n\n \n {DEFAULT_INSTANCE ? 'Log in' : 'Log in with Mastodon'}\n \n
\n {DEFAULT_INSTANCE && DEFAULT_INSTANCE_REGISTRATION_URL && (\n\n \n Sign up\n \n
\n )}\n {!DEFAULT_INSTANCE && (\n\n \n Connect your existing Mastodon/Fediverse account.\n
\n Your credentials are not stored on this server.\n \n
\n \n {appSite} {appVersion}\n \n
\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 Privacy Policy\n \n .\n
\n\n Visually separate original posts and re-shared posts (boosted\n posts).\n
\nEffortlessly follow conversations. Semi-collapsible replies.
\n\n Similar notifications are grouped and collapsed to reduce clutter.\n
\n\n By default, single column for zen-mode seekers. Configurable\n multi-column for power users.\n
\nUp to 5 hashtags combined into a single timeline.
\n