diff --git a/src/app.tsx b/src/app.tsx
index 45d5571c..a05ced07 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -82,10 +82,12 @@ import { navigatePath } from './utils/router';
import states, { hideAllModals, initStates, statusKey } from './utils/states';
import store from './utils/store';
import {
+ getAccounts,
getAccount,
getCredentialApplication,
getCurrentAccount,
getVapidKey,
+ removeAccount,
setCurrentAccountID,
} from './utils/store-utils';
@@ -684,11 +686,20 @@ function App() {
if (!account) {
account = getCurrentAccount();
}
- if (account) {
+ while (account) {
setCurrentAccountID(account.info.id);
- account.accessToken = await hydrateAtprotoOAuthAccessToken(
- account.accessToken,
- );
+ try {
+ account.accessToken = await hydrateAtprotoOAuthAccessToken(
+ account.accessToken,
+ );
+ break;
+ } catch (error) {
+ console.error(error);
+ removeAccount(account.info.id);
+ account = getAccounts()[0] ?? null;
+ }
+ }
+ if (account) {
const { client } = api({ account });
const { instance } = client;
// console.log('masto', masto);
@@ -717,6 +728,7 @@ function App() {
}
} else {
if (cancelled) return;
+ setIsLoggedIn(false);
setUIState('default');
__BENCHMARK.end('app-init');
}
diff --git a/src/components/camera-capture-input.tsx b/src/components/camera-capture-input.tsx
index 83564b56..b9de747d 100644
--- a/src/components/camera-capture-input.tsx
+++ b/src/components/camera-capture-input.tsx
@@ -44,13 +44,13 @@ function CameraCaptureInput({
// It also can't switch between photo and video mode like iOS/Safari
const filteredSupportedMimeTypes = isMobileSafari
? supportedMimeTypes
- : supportedMimeTypes?.filter((mimeType) => !/^image\//i.test(mimeType));
+ : supportedMimeTypes?.filter((mimeType) => /^image\//i.test(mimeType));
return (
(null);
useEffect(() => {
+ const composeContainer = composeContainerRef.current;
+ if (!composeContainer) return undefined;
+
const handleFocus = (e: FocusEvent): void => {
// Toggle focused if in or out if any fields are focused
- // The container is non-null at handler time (the listener is only
- // attached when composeContainer was defined). Mirror the original JS
- // direct access.
- (composeContainerRef.current as HTMLDivElement).classList.toggle(
- 'focused',
- e.type === 'focusin',
- );
+ composeContainer.classList.toggle('focused', e.type === 'focusin');
- const target = e.target as HTMLElement;
+ const target = e.target;
+ if (!(target instanceof HTMLElement)) return;
if (target.hasAttribute('data-allow-custom-emoji')) {
lastFocusedEmojiFieldRef.current = target;
}
@@ -838,17 +841,12 @@ function Compose({
}
};
- const composeContainer = composeContainerRef.current;
- if (composeContainer) {
- composeContainer.addEventListener('focusin', handleFocus);
- composeContainer.addEventListener('focusout', handleFocus);
- }
+ composeContainer.addEventListener('focusin', handleFocus);
+ composeContainer.addEventListener('focusout', handleFocus);
return () => {
- if (composeContainer) {
- composeContainer.removeEventListener('focusin', handleFocus);
- composeContainer.removeEventListener('focusout', handleFocus);
- }
+ composeContainer.removeEventListener('focusin', handleFocus);
+ composeContainer.removeEventListener('focusout', handleFocus);
};
}, []);
@@ -2359,123 +2357,119 @@ function Compose({
{showAddButton && (
- (
-
- )}
- >
+ <>
{supportsCameraCapture && (
-
+ setMediaAttachments={setMediaAttachments}
+ />
)}
-
+ (
+
+ )}
>
- {/* TODO(oxlint:jsx-a11y/label-has-associated-control):
- the wrapped FilePickerInput renders the actual
- — the rule cannot see through
- the component boundary. */}
-
- {_(ADD_LABELS.media)}
-
-
- {showPollButton && (
+ className="compose-menu-add-media"
+ onClick={(event) => {
+ event.keepOpen = true;
+ clickFileInput(menuCameraInputId);
+ }}
+ >
+ {' '}
+ {_(ADD_LABELS.camera)}
+
+ )}
- )}
-
-
- {states.settings.composerGIFPicker && (
+ {showPollButton && (
+
+ )}
+
+
- )}
- {showScheduledAt && (
- <>
-
+ {states.settings.composerGIFPicker && (
- >
- )}
-
+ )}
+ {showScheduledAt && (
+ <>
+
+
+ >
+ )}
+
+ >
)}
* {
vertical-align: middle;
pointer-events: none;
diff --git a/src/utils/store-utils.ts b/src/utils/store-utils.ts
index 7866b979..b7aa44c7 100644
--- a/src/utils/store-utils.ts
+++ b/src/utils/store-utils.ts
@@ -53,8 +53,13 @@ export function getAccounts(): StoredAccount[] {
return store.local.getJSON('accounts') ?? [];
}
+const removedAccountIDs = new Set();
+
export function saveAccounts(accounts: readonly StoredAccount[]): void {
- store.local.setJSON('accounts', accounts);
+ store.local.setJSON(
+ 'accounts',
+ accounts.filter((account) => !removedAccountIDs.has(account.info.id)),
+ );
}
const MINS_5 = 5 * 60 * 1000;
@@ -141,6 +146,27 @@ export function setCurrentAccountID(id: string): void {
}
}
+export function removeAccount(id: string): void {
+ removedAccountIDs.add(id);
+ getCurrentAccID.cache.clear();
+ try {
+ getCurrentAcc.cache.clear();
+ } catch {}
+ saveAccounts(getAccounts());
+ try {
+ if (store.session.get('currentAccount') === id) {
+ store.session.del('currentAccount');
+ }
+ } catch {}
+ if (standaloneMQ?.matches) {
+ try {
+ if (store.local.get('currentAccount') === id) {
+ store.local.del('currentAccount');
+ }
+ } catch {}
+ }
+}
+
export function getCurrentAccount(): StoredAccount | null {
if (!window.__IGNORE_GET_ACCOUNT_ERROR__) {
// Track down getCurrentAccount() calls before account-based states are initialized
@@ -176,6 +202,7 @@ export function getCurrentAccountNS(): string {
}
export function saveAccount(account: StoredAccount): void {
+ removedAccountIDs.delete(account.info.id);
const accounts = getAccounts();
const acc = accounts.find(
(storedAccount) => storedAccount.info.id === account.info.id,
diff --git a/tests/atproto-oauth.spec.js b/tests/atproto-oauth.spec.js
index e806f70c..0733ecda 100644
--- a/tests/atproto-oauth.spec.js
+++ b/tests/atproto-oauth.spec.js
@@ -136,4 +136,50 @@ test.describe('ATProto OAuth', () => {
sub: 'did:plc:oauthalice',
});
});
+
+ test('clears a stale OAuth account instead of hanging on startup', async ({
+ page,
+ }) => {
+ test.setTimeout(90_000);
+ await page.addInitScript(() => {
+ const did = 'did:plc:staleoauth';
+ window.__BLUEPY_OAUTH_TEST_CLIENT__ = {
+ init: async () => undefined,
+ restore: async () => {
+ throw new Error('The session was deleted by another process');
+ },
+ };
+ localStorage.setItem(
+ 'accounts',
+ JSON.stringify([
+ {
+ accessToken: JSON.stringify({
+ type: 'atproto-oauth',
+ sub: did,
+ }),
+ atproto: true,
+ info: {
+ id: did,
+ username: 'stale.test',
+ acct: 'stale.test',
+ displayName: 'Stale OAuth',
+ },
+ instanceURL: 'bsky.social',
+ },
+ ]),
+ );
+ sessionStorage.setItem('currentAccount', did);
+ });
+
+ await page.goto('/');
+
+ await expect(
+ page.getByRole('link', { name: 'Log in with Bluesky' }),
+ ).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem('accounts')))
+ .toBe('[]');
+ });
});
diff --git a/tests/atproto-smoke-loggedin.spec.js b/tests/atproto-smoke-loggedin.spec.js
index 24d4efd2..a752cf5f 100644
--- a/tests/atproto-smoke-loggedin.spec.js
+++ b/tests/atproto-smoke-loggedin.spec.js
@@ -403,6 +403,26 @@ test.describe('modals', () => {
});
});
+ test('compose add-media menu attaches an image on narrow viewports', async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await openModal(page, 'showCompose');
+ await page.locator('#compose-container .add-button').click();
+
+ const mediaItem = page.locator('.szh-menu__item.compose-menu-add-media');
+ await expect(mediaItem.first()).toBeVisible({ timeout: 5_000 });
+
+ const chooserPromise = page.waitForEvent('filechooser');
+ await mediaItem.first().click();
+ const chooser = await chooserPromise;
+ await chooser.setFiles(path.join(process.cwd(), 'public/logo-192.png'));
+
+ await expect(
+ page.locator('#compose-container img[src^="blob:"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ });
+
test('shortcuts modal opens', async ({ page }) => {
await openModal(page, 'showShortcutsSettings');
await expect(page.locator('text=/Shortcut/i').first()).toBeVisible({