diff --git a/bun.lock b/bun.lock index 085eb54e..8bdb4729 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,6 @@ "@sentry/react": "^10.53.1", "@szhsin/react-menu": "~4.5.1", "chroma-js": "~3.2.0", - "compare-versions": "~6.1.1", "dompurify": "^3.4.5", "exifreader": "~4.38.0", "fast-blurhash": "~1.1.4", @@ -1006,8 +1005,6 @@ "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], - "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], diff --git a/package.json b/package.json index 31ea8ec3..6f00f4bb 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@sentry/react": "^10.53.1", "@szhsin/react-menu": "~4.5.1", "chroma-js": "~3.2.0", - "compare-versions": "~6.1.1", "dompurify": "^3.4.5", "exifreader": "~4.38.0", "fast-blurhash": "~1.1.4", diff --git a/src/components/account-info.tsx b/src/components/account-info.tsx index cd09dc75..6b445a16 100644 --- a/src/components/account-info.tsx +++ b/src/components/account-info.tsx @@ -29,7 +29,6 @@ import { getCurrentAccountID, saveAccounts, } from '../utils/store-utils'; -import supports from '../utils/supports'; import AccountBlock from './account-block'; import AccountHandleInfo from './account-handle-info'; @@ -883,23 +882,21 @@ function AccountInfo({ )} - {currentAuthenticated && - isSelf && - supports('@mastodon/profile-edit') && ( - <> - - { - setShowEditProfile(true); - }} - > - - - Edit profile - - - - )} + {currentAuthenticated && isSelf && ( + <> + + { + setShowEditProfile(true); + }} + > + + + Edit profile + + + + )} ) : ( { - const uploadFile = supports('@atproto') - ? await compressAtprotoImageIfNeeded(file) - : file; + const uploadFile = await compressAtprotoImageIfNeeded(file); return { fileData: await uploadFile.arrayBuffer(), fileName: uploadFile.name, @@ -1583,23 +1580,7 @@ function Compose({ (attachment) => attachment.id, ), }; - if (editStatus) { - if ( - supports('@mastodon') || - supports('@gotosocial/edit-media-attributes') - ) { - params.media_attributes = submitMediaAttachments.map( - (attachment) => { - return { - id: attachment.id, - description: attachment.description, - // focus - // thumbnail - }; - }, - ); - } - } else { + if (!editStatus) { if (currentQuoteStatus?.id) { params.quoted_status_id = currentQuoteStatus.id; } diff --git a/src/components/file-picker-input.tsx b/src/components/file-picker-input.tsx index b26de404..c11318ca 100644 --- a/src/components/file-picker-input.tsx +++ b/src/components/file-picker-input.tsx @@ -3,7 +3,6 @@ import type { SyntheticEvent } from 'react'; import { compressAtprotoImageIfNeeded } from '../utils/atproto-image-compression'; import { revokeAttachmentObjectUrls } from '../utils/compose-media'; -import supports from '../utils/supports'; interface FilePickerMediaAttachment { fileData: ArrayBuffer; @@ -65,9 +64,7 @@ function FilePickerInput({ try { mediaFiles = await Promise.all( Array.from(files).map(async (file) => { - const uploadFile = supports('@atproto') - ? await compressAtprotoImageIfNeeded(file) - : file; + const uploadFile = await compressAtprotoImageIfNeeded(file); return { fileData: await uploadFile.arrayBuffer(), fileName: uploadFile.name, diff --git a/src/components/list-add-edit.tsx b/src/components/list-add-edit.tsx index d7818afd..158743e8 100644 --- a/src/components/list-add-edit.tsx +++ b/src/components/list-add-edit.tsx @@ -3,17 +3,13 @@ import { useEffect, useRef, useState } from 'react'; import { api, getMastoV1Resource } from '../utils/api'; import { addListStore, deleteListStore, updateListStore } from '../utils/lists'; -import supports from '../utils/supports'; import Icon from './icon'; -import ListExclusiveBadge from './list-exclusive-badge'; import MenuConfirm from './menu-confirm'; interface ListLike { id: string; title: string; - repliesPolicy?: string; - exclusive?: boolean; [key: string]: unknown; } @@ -43,17 +39,9 @@ interface ListAddEditProps { } interface MastoListsApi { - create(params: { - title: FormDataEntryValue | null; - replies_policy: FormDataEntryValue | null; - exclusive: boolean; - }): Promise; + create(params: { title: string }): Promise; $select(id: string): { - update(params: { - title: FormDataEntryValue | null; - replies_policy: FormDataEntryValue | null; - exclusive: boolean; - }): Promise; + update(params: { title: string }): Promise; remove(): Promise; }; } @@ -67,24 +55,13 @@ function ListAddEdit({ list, onClose }: ListAddEditProps) { const [uiState, setUIState] = useState('default'); const editMode = !!list; const nameFieldRef = useRef(null); - const repliesPolicyFieldRef = useRef(null); - const exclusiveFieldRef = useRef(null); useEffect(() => { if (editMode && list) { if (nameFieldRef.current) { nameFieldRef.current.value = list.title; } - if (repliesPolicyFieldRef.current) { - repliesPolicyFieldRef.current.value = list.repliesPolicy ?? ''; - } - if (exclusiveFieldRef.current) { - exclusiveFieldRef.current.checked = !!list.exclusive; - } } }, [editMode, list]); - const supportsExclusive = - supports('@mastodon/list-exclusive') || - supports('@gotosocial/list-exclusive'); return (
@@ -109,13 +86,10 @@ function ListAddEdit({ list, onClose }: ListAddEditProps) { e.preventDefault(); // Get form values const formData = new FormData(e.target as HTMLFormElement); - const title = formData.get('title'); - const repliesPolicy = formData.get('replies_policy'); - const exclusive = formData.get('exclusive') === 'on'; + const titleValue = formData.get('title'); + const title = typeof titleValue === 'string' ? titleValue : ''; console.log({ title, - repliesPolicy, - exclusive, }); setUIState('loading'); @@ -126,14 +100,10 @@ function ListAddEdit({ list, onClose }: ListAddEditProps) { if (editMode && list) { listResult = await listsApi.$select(list.id).update({ title, - replies_policy: repliesPolicy, - exclusive, }); } else { listResult = await listsApi.create({ title, - replies_policy: repliesPolicy, - exclusive, }); } @@ -177,38 +147,6 @@ function ListAddEdit({ list, onClose }: ListAddEditProps) { />
-
- -
- {supportsExclusive && ( -
- -
- )}
} > - {list?.exclusive && ( - <> - - {' '} - - Posts on this list are hidden from Home/Following - - - - - )} { setShowListAddEditModal({ diff --git a/src/pages/lists.tsx b/src/pages/lists.tsx index 208eafb6..499781ad 100644 --- a/src/pages/lists.tsx +++ b/src/pages/lists.tsx @@ -6,7 +6,6 @@ import { useEffect, useReducer, useState } from 'react'; import Icon from '../components/icon'; import Link from '../components/link'; import ListAddEdit from '../components/list-add-edit'; -import ListExclusiveBadge from '../components/list-exclusive-badge'; import Loader from '../components/loader'; import Modal from '../components/modal'; import NavMenu from '../components/nav-menu'; @@ -16,7 +15,6 @@ import useTitle from '../utils/useTitle'; interface ListItem { id: string; title: string; - exclusive?: boolean; [key: string]: unknown; } @@ -49,9 +47,6 @@ function Lists() { useState(false); const { lists: userLists, feeds } = splitListsAndFeeds(lists); - const hasExclusiveLists = userLists.some( - (list) => (list as ListItem).exclusive, - ); return (
@@ -93,15 +88,7 @@ function Lists() {
  • {' '} - - {list.title} - {list.exclusive && ( - <> - {' '} - - - )} - + {list.title} {/* - )} -
  • - ); - })} -
    - )} - {supportsTrendingLinkPosts && !!links.length && ( -
    - {hasCurrentLink ? ( - <> -
    - {currentLinkMentionsLoading ? ( - - ) : ( - - )} -
    -

    - - Showing posts mentioning{' '} - - {(currentLink ?? '') - .replace(/^https?:\/\/(www\.)?/i, '') - .replace(/\/$/, '')} - - -

    - - ) : ( -

    - Trending posts -

    - )} -
    - )} - - ); - }, [ - hashtags, - links, - currentLink, - currentLinkMentionsLoading, - supportsTrendingLinkPosts, - instance, - t, - hasCurrentLink, - ]); - return ( } id="trending" - timelineKey={`trending-${instance}-${currentLink || 'posts'}`} + timelineKey={`trending-${instance}-posts`} instance={instance} emptyText={t`No trending posts.`} errorText={t`Unable to load posts`} - fetchItems={hasCurrentLink ? fetchLinkMentions : fetchTrends} - checkForUpdates={hasCurrentLink ? undefined : checkForUpdates} + fetchItems={fetchTrends} + checkForUpdates={checkForUpdates} checkForUpdatesInterval={5 * 60 * 1000} // 5 minutes useItemID headerStart={<>} boostsCarousel={snapStates.settings.boostsCarousel} // allowFilters filterContext="public" - timelineStart={TimelineStart} - refresh={currentLink} - clearWhenRefresh - view={hasCurrentLink ? 'link-mentions' : undefined} /> ); } diff --git a/src/utils/atproto-adapter.ts b/src/utils/atproto-adapter.ts index 59586029..5d353c6d 100644 --- a/src/utils/atproto-adapter.ts +++ b/src/utils/atproto-adapter.ts @@ -567,7 +567,6 @@ interface AdaptedList { id: string; title: string; repliesPolicy: 'list'; - exclusive: false; _atproto: { uri?: string; cid?: string; @@ -1322,7 +1321,6 @@ function listToPhanpyList(list: AtprotoList = {}): AdaptedList { id: encodeURIComponent(String(uri)), title: list.name || list.displayName || uri || '', repliesPolicy: 'list', - exclusive: false, _atproto: { uri, cid: list.cid, @@ -1341,7 +1339,6 @@ function feedGeneratorToPhanpyList( id: encodeURIComponent(String(uri)), title: feed.displayName || feed.name || uri || '', repliesPolicy: 'list', - exclusive: false, _atproto: { uri, cid: feed.cid, @@ -3751,7 +3748,6 @@ export function atprotoInstanceInfo() { maxOptions: 0, }, }, - apiVersions: { mastodon: 7 }, }; } diff --git a/src/utils/lists.ts b/src/utils/lists.ts index decfab6d..d5d2924d 100644 --- a/src/utils/lists.ts +++ b/src/utils/lists.ts @@ -8,7 +8,6 @@ const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day interface ListLike { id: string; title: string; - exclusive?: boolean; _atproto?: { type?: string } | null; [key: string]: unknown; } diff --git a/src/utils/store-utils.ts b/src/utils/store-utils.ts index a86dc13e..d2021f5a 100644 --- a/src/utils/store-utils.ts +++ b/src/utils/store-utils.ts @@ -34,16 +34,7 @@ type InstanceConfiguration = JsonRecord & { }; type InstanceInfo = JsonRecord & { - apiVersions?: JsonRecord; configuration?: InstanceConfiguration; - maxMediaAttachments?: unknown; - maxTootChars?: unknown; - pollLimits?: { - maxExpiration?: unknown; - maxOptionChars?: unknown; - maxOptions?: unknown; - minExpiration?: unknown; - }; version?: string; }; @@ -254,55 +245,14 @@ export function getCurrentInstance(): InstanceInfo { } } -let currentNodeInfo: JsonRecord | null = null; -export function getCurrentNodeInfo(): JsonRecord { - if (currentNodeInfo) { - return currentNodeInfo; - } - try { - const account = getCurrentAccount(); - if (!account) { - return {}; - } - const nodeInfos = - store.local.getJSON>('nodeInfos') ?? {}; - const instanceURL = account.instanceURL.toLowerCase(); - return (currentNodeInfo = nodeInfos[instanceURL] ?? {}); - } catch (error) { - console.error(error); - return {}; - } -} - -// Massage these instance configurations to match the Mastodon API -// - Pleroma function getInstanceConfiguration( instance: InstanceInfo, ): InstanceConfiguration { - const { configuration, maxMediaAttachments, maxTootChars, pollLimits } = - instance; - - const statuses = configuration?.statuses ?? {}; - if (maxMediaAttachments) { - statuses.maxMediaAttachments ??= maxMediaAttachments; - } - - if (maxTootChars) { - statuses.maxCharacters ??= maxTootChars; - } - - const polls = configuration?.polls ?? {}; - if (pollLimits) { - polls.maxCharactersPerOption ??= pollLimits.maxOptionChars; - polls.maxExpiration ??= pollLimits.maxExpiration; - polls.maxOptions ??= pollLimits.maxOptions; - polls.minExpiration ??= pollLimits.minExpiration; - } - + const { configuration } = instance; return { ...configuration, - polls, - statuses, + polls: configuration?.polls ?? {}, + statuses: configuration?.statuses ?? {}, }; } @@ -311,11 +261,6 @@ export function getCurrentInstanceConfiguration(): InstanceConfiguration { return getInstanceConfiguration(instance); } -export function getAPIVersions(): JsonRecord { - const instance = getCurrentInstance(); - return instance.apiVersions ?? {}; -} - export function getVapidKey(instance?: InstanceInfo): unknown { // Vapid key has moved from account to instance config const config = instance @@ -324,8 +269,3 @@ export function getVapidKey(instance?: InstanceInfo): unknown { const vapidKey = config.vapid?.publicKey ?? config.vapid?.public_key; return vapidKey ?? getCurrentAccount()?.vapidKey; } - -export function isMediaFirstInstance(): boolean { - const instance = getCurrentInstance(); - return /pixelfed/i.test(instance.version ?? ''); -} diff --git a/src/utils/supports.ts b/src/utils/supports.ts deleted file mode 100644 index c25eddfe..00000000 --- a/src/utils/supports.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { satisfies } from 'compare-versions'; - -import features from '../data/features.json'; - -import { getCurrentInstance, getCurrentNodeInfo } from './store-utils'; - -type SatisfiesWithOptions = ( - version: string | undefined, - range: string, - options?: { includePrerelease?: boolean; loose?: boolean }, -) => boolean; - -type SatisfiesCompat = ( - version: string, - range: string, - options?: { includePrerelease?: boolean; loose?: boolean }, -) => boolean; - -const satisfiesCompat: SatisfiesCompat = satisfies; -const satisfiesVersion: SatisfiesWithOptions = (version, range, options) => { - if (version === undefined) { - throw new TypeError('Expected version for semver comparison'); - } - return satisfiesCompat(version, range, options); -}; -const featuresMap = features as Record; - -// Non-semver(?) UA string detection -const containPixelfed = /pixelfed/i; -const notContainPixelfed = /^(?!.*pixelfed).*$/i; -const platformFeatures: Record = { - '@mastodon/lists': notContainPixelfed, - '@mastodon/mentions': notContainPixelfed, - '@mastodon/trending-hashtags': notContainPixelfed, - '@mastodon/trending-links': notContainPixelfed, - '@mastodon/post-bookmark': notContainPixelfed, - '@mastodon/post-edit': notContainPixelfed, - '@mastodon/profile-edit': notContainPixelfed, - '@mastodon/pinned-posts': notContainPixelfed, - '@pixelfed/trending': containPixelfed, - '@pixelfed/home-include-reblogs': containPixelfed, - '@pixelfed/global-feed': containPixelfed, -}; - -const supportsCache: Record = {}; -const bskyUnsupportedFeatures = new Set([ - '@mastodon/pinned-posts', - '@mastodon/post-edit', - '@mastodon/trending-hashtags', - '@mastodon/trending-links', -]); - -const semverExtract = /^\d+\.\d+(\.\d+)?/; -const atSoftwareSlashMatch = /^@([a-z]+)\//i; - -function supports(feature: string): boolean { - try { - const instance = getCurrentInstance() as { - version?: string; - domain?: string; - }; - const { version, domain } = instance; - const nodeInfo = getCurrentNodeInfo() as { - software?: { name?: string }; - }; - let softwareName = nodeInfo?.software?.name || 'mastodon'; - - if (domain === 'bsky.social' && bskyUnsupportedFeatures.has(feature)) { - return false; - } - - if (softwareName === 'hometown') { - // Hometown is a Mastodon fork and inherits its features - softwareName = 'mastodon'; - } - - const key = `${domain}-${feature}`; - if (key in supportsCache) return supportsCache[key]; - - if (platformFeatures[feature]) { - return (supportsCache[key] = platformFeatures[feature].test( - version as string, - )); - } - - const featureMatch = feature.match(atSoftwareSlashMatch); - if (!featureMatch) { - // Only software match, e.g. supports('@mastodon') - const software = feature.replace(/^@/, ''); - return (supportsCache[key] = softwareName === software); - } - - const range = featuresMap[feature]; - if (!range) return false; - - // '@mastodon/blah' => 'mastodon' - const featureSoftware = featureMatch[1]; - - const doesSoftwareMatch = featureSoftware === softwareName.toLowerCase(); - let satisfiesRange = satisfiesVersion(version, range, { - includePrerelease: true, - loose: true, - }); - if (!satisfiesRange) { - try { - // E.g. "4.2.1 (compatible; Iceshrimp 2023.12.14-dev-046d237af)" is invalid semver 😅 - // This regex extracts numbers with dots out and tries again - // Hopefully this doesn't break anything - satisfiesRange = satisfiesVersion( - (version as string).match(semverExtract)?.[0], - range, - { - includePrerelease: true, - loose: false, - }, - ); - } catch { - // Ignore - } - } - return (supportsCache[key] = doesSoftwareMatch && satisfiesRange); - } catch { - return false; - } -} - -export default supports; diff --git a/src/utils/timeline-utils.ts b/src/utils/timeline-utils.ts index 79dcfa96..f5191b53 100644 --- a/src/utils/timeline-utils.ts +++ b/src/utils/timeline-utils.ts @@ -11,7 +11,6 @@ import { shouldFetchReplyContextForInstance } from './reply-context'; import states, { saveStatus, statusKey } from './states'; import store from './store'; import { getCurrentAccountID } from './store-utils'; -import supports from './supports'; import { canonicalTimelineContextId, groupContextItems, @@ -282,40 +281,38 @@ export function groupContext( } } - if (supports('@mastodon/fetch-multiple-statuses')) { - // This is batch fetching yooo, woot - // Limit 20, returns 422 if exceeded https://github.com/mastodon/mastodon/pull/27871 - const ids = inReplyToIds.map(({ inReplyToId }) => inReplyToId); - void (async () => { - try { - const replyToStatuses = await statusesResource.list({ id: ids }); - if (replyToStatuses?.length) { - for (const replyToStatus of replyToStatuses) { - saveStatus(replyToStatus, instance, { - skipThreading: true, - }); - const sKey = inReplyToIds.find( - ({ inReplyToId }) => inReplyToId === replyToStatus.id, - )?.sKey; - if (sKey) { - states.statusReply[sKey] = { - id: replyToStatus.id, - instance, - }; - } + // This is batch fetching yooo, woot + // Limit 20, returns 422 if exceeded https://github.com/mastodon/mastodon/pull/27871 + const ids = inReplyToIds.map(({ inReplyToId }) => inReplyToId); + void (async () => { + try { + const replyToStatuses = await statusesResource.list({ id: ids }); + if (replyToStatuses?.length) { + for (const replyToStatus of replyToStatuses) { + saveStatus(replyToStatus, instance, { + skipThreading: true, + }); + // Several visible posts can reply to the same parent, so set the + // reply hint for every matching sKey, not just the first. + const matchingHints = inReplyToIds.filter( + ({ inReplyToId }) => inReplyToId === replyToStatus.id, + ); + for (const { sKey } of matchingHints) { + states.statusReply[sKey] = { + id: replyToStatus.id, + instance, + }; } - } else { - void fallbackFetch(); } - } catch (e) { - // Silently fail - console.error(e); + } else { void fallbackFetch(); } - })(); - } else { - void fallbackFetch(); - } + } catch (e) { + // Silently fail + console.error(e); + void fallbackFetch(); + } + })(); }, 10); } diff --git a/tests/atproto-smoke-loggedin.spec.js b/tests/atproto-smoke-loggedin.spec.js index 31074df1..ed307929 100644 --- a/tests/atproto-smoke-loggedin.spec.js +++ b/tests/atproto-smoke-loggedin.spec.js @@ -618,6 +618,21 @@ test.describe('read flows', () => { await expect(page.locator('#lists-page')).toBeVisible({ timeout: 15_000 }); }); + // Phase 2D: the Mastodon-only list knobs (replies_policy, exclusive) were + // removed; ATProto list create/update only accepts a title. + test('new-list form is title-only (no Mastodon replies-policy / exclusive)', async ({ + page, + }) => { + await goto(page, '/l'); + await expect(page.locator('#lists-page')).toBeVisible({ timeout: 15_000 }); + await page.getByRole('button', { name: /new list/i }).first().click(); + const form = page.locator('form.list-form'); + await expect(form).toBeVisible({ timeout: 10_000 }); + await expect(form.locator('input[name="title"]')).toBeVisible(); + await expect(form.locator('select[name="replies_policy"]')).toHaveCount(0); + await expect(form.locator('input[name="exclusive"]')).toHaveCount(0); + }); + test('bookmarks page renders', async ({ page }) => { await goto(page, '/b'); await expect(page.locator('.deck-container').first()).toBeVisible({ @@ -639,6 +654,22 @@ test.describe('read flows', () => { }); }); + // Phase 2D: Trending was reduced to the Bluesky Discover feed. The Mastodon + // trending-hashtags / trending-links / link-mentions chrome was removed with + // the feature-detection system (those endpoints returned empty collections on + // ATProto). Assert the route still renders posts from the Discover feed. + test('trending page renders Discover-feed posts', async ({ page }) => { + await goto(page, '/trending'); + await expect(page.locator('.deck-container').first()).toBeVisible({ + timeout: 15_000, + }); + await expect( + page + .locator('[data-state-post-id], article.status, .status-link') + .first(), + ).toBeVisible({ timeout: 30_000 }); + }); + test('year-in-posts page renders', async ({ page }) => { await goto(page, '/yip'); await expect(page.locator('#year-in-posts-page')).toBeVisible({