diff --git a/app/javascript/mastodon/features/compose/components/search.tsx b/app/javascript/mastodon/features/compose/components/search.tsx
index 84e11e44b5..3ff1691d82 100644
--- a/app/javascript/mastodon/features/compose/components/search.tsx
+++ b/app/javascript/mastodon/features/compose/components/search.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useState, useRef } from 'react';
+import { useCallback, useState, useRef, useMemo } from 'react';
import {
defineMessages,
@@ -252,6 +252,103 @@ export const Search: React.FC<{
[dispatch, history],
);
+ const QuickActionGenerators: {
+ couldBeURL: (url: string) => SearchOption;
+ couldBeHashtag: (tag: string) => SearchOption;
+ couldBeUsername: (username: string) => SearchOption;
+ couldBeStatusSearch: (status: string) => SearchOption;
+ accountSearch: (account: string) => SearchOption;
+ } = useMemo(
+ () => ({
+ couldBeURL: (url: string): SearchOption => ({
+ key: 'open-url',
+ label: (
+
+ ),
+ action: () => {
+ dispatch(openURL({ url: url }))
+ .then((result) => {
+ if (isFulfilled(result)) {
+ if (result.payload.accounts[0]) {
+ history.push(`/@${result.payload.accounts[0].acct}`);
+ } else if (result.payload.statuses[0]) {
+ history.push(
+ `/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
+ );
+ }
+ }
+ return void 0;
+ })
+ .catch((e: unknown) => {
+ console.error(e);
+ })
+ .finally(() => {
+ unfocus();
+ });
+ },
+ }),
+ couldBeHashtag: (tag: string): SearchOption => ({
+ key: 'go-to-hashtag',
+ label: (
+ #{tag} }}
+ />
+ ),
+ action: () => {
+ history.push(`/tags/${tag}`);
+ void dispatch(clickSearchResult({ q: tag, type: 'hashtag' }));
+ unfocus();
+ },
+ }),
+ couldBeUsername: (username: string): SearchOption => ({
+ key: 'go-to-account',
+ label: (
+ @{username} }}
+ />
+ ),
+ action: () => {
+ history.push(`/@${username}`);
+ void dispatch(clickSearchResult({ q: username, type: 'account' }));
+ unfocus();
+ },
+ }),
+ couldBeStatusSearch: (status: string): SearchOption => ({
+ key: 'status-search',
+ label: (
+ {status} }}
+ />
+ ),
+ action: () => {
+ submit(status, 'statuses');
+ },
+ }),
+ accountSearch: (account: string): SearchOption => ({
+ key: 'account-search',
+ label: (
+ {account} }}
+ />
+ ),
+ action: () => {
+ submit(account, 'accounts');
+ },
+ }),
+ }),
+ [dispatch, history, submit],
+ );
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent) => {
setValue(value);
@@ -263,113 +360,63 @@ export const Search: React.FC<{
const couldBeURL =
trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
+ let mastoPath;
if (couldBeURL) {
- newQuickActions.push({
- key: 'open-url',
- label: (
-
- ),
- action: async () => {
- const result = await dispatch(openURL({ url: trimmedValue }));
+ newQuickActions.push(QuickActionGenerators.couldBeURL(trimmedValue));
- if (isFulfilled(result)) {
- if (result.payload.accounts[0]) {
- history.push(`/@${result.payload.accounts[0].acct}`);
- } else if (result.payload.statuses[0]) {
- history.push(
- `/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
- );
- }
- }
-
- unfocus();
- },
- });
+ // presume URL is from a mastodon server; not just some random web URL
+ mastoPath = new URL(trimmedValue).pathname.replace(/^\//, '');
+ } else {
+ mastoPath = '';
}
const couldBeHashtag =
(trimmedValue.startsWith('#') && trimmedValue.length > 1) ||
- trimmedValue.match(HASHTAG_REGEX);
+ trimmedValue.match(HASHTAG_REGEX) ||
+ (couldBeURL && mastoPath.startsWith('tags/'));
if (couldBeHashtag) {
- newQuickActions.push({
- key: 'go-to-hashtag',
- label: (
- #{trimmedValue.replace(/^#/, '')} }}
- />
- ),
- action: () => {
- const query = trimmedValue.replace(/^#/, '');
- history.push(`/tags/${query}`);
- void dispatch(clickSearchResult({ q: query, type: 'hashtag' }));
- unfocus();
- },
- });
+ const hashtag = couldBeURL
+ ? mastoPath.replace(/^tags\//, '')
+ : trimmedValue.replace(/^#/, '');
+
+ newQuickActions.push(QuickActionGenerators.couldBeHashtag(hashtag));
}
- const couldBeUsername = /^@?[a-z0-9_-]+(@[^\s]+)?$/i.exec(trimmedValue);
-
+ const userRegexp = /^@?[a-z0-9_-]+(@[^\s]+)?$/i;
+ const couldBeUsername =
+ userRegexp.test(trimmedValue) ||
+ (couldBeURL && userRegexp.test(mastoPath));
if (couldBeUsername) {
- newQuickActions.push({
- key: 'go-to-account',
- label: (
- @{trimmedValue.replace(/^@/, '')} }}
- />
- ),
- action: () => {
- const query = trimmedValue.replace(/^@/, '');
- history.push(`/@${query}`);
- void dispatch(clickSearchResult({ q: query, type: 'account' }));
- unfocus();
- },
- });
+ const mastoUser = mastoPath.replace(/^@/, '');
+
+ const username = !couldBeURL
+ ? trimmedValue.replace(/^@/, '')
+ : // @ts-expect-error : mastoPath was tested against userRegexp above, meaning there must be at least one `@`:
+ // so match can't return null here
+ mastoPath.match(/@/g).length === 1
+ ? // if there's only 1 `@` in mastoPath, obtain domain from URL's FQDN & append to mastoUser
+ `${mastoUser}@${new URL(trimmedValue).hostname}`
+ : // otherwise there were at least (hopefully, only) 2, and it's a full masto identifier
+ mastoUser;
+
+ newQuickActions.push(QuickActionGenerators.couldBeUsername(username));
}
const couldBeStatusSearch = searchEnabled;
if (couldBeStatusSearch && signedIn) {
- newQuickActions.push({
- key: 'status-search',
- label: (
- {trimmedValue} }}
- />
- ),
- action: () => {
- submit(trimmedValue, 'statuses');
- },
- });
+ newQuickActions.push(
+ QuickActionGenerators.couldBeStatusSearch(trimmedValue),
+ );
}
- newQuickActions.push({
- key: 'account-search',
- label: (
- {trimmedValue} }}
- />
- ),
- action: () => {
- submit(trimmedValue, 'accounts');
- },
- });
+ newQuickActions.push(QuickActionGenerators.accountSearch(trimmedValue));
}
setQuickActions(newQuickActions);
},
- [dispatch, history, signedIn, setValue, setQuickActions, submit],
+ [signedIn, setValue, setQuickActions, QuickActionGenerators],
);
const handleClear = useCallback(() => {
@@ -451,6 +498,33 @@ export const Search: React.FC<{
setSelectedOption(-1);
}, [setExpanded, setSelectedOption]);
+ const handleDragOver = useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ },
+ [],
+ );
+ const handleDrop = useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+
+ handleClear();
+
+ const query =
+ event.dataTransfer.getData('URL') ||
+ event.dataTransfer.getData('text/plain');
+
+ Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ 'value',
+ )?.set?.call(event.target, query);
+
+ event.currentTarget.focus();
+ event.target.dispatchEvent(new Event('change', { bubbles: true }));
+ },
+ [handleClear],
+ );
+
return (