diff --git a/locales/en-US.yml b/locales/en-US.yml index 3c183e4ca..336bb6f03 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -5,7 +5,7 @@ introIceshrimp: "Welcome! Iceshrimp is an open source, decentralized social medi platform that's free forever! 🚀" monthAndDay: "{month}/{day}" search: "Search" -searchPlaceholder: "Search Iceshrimp" +searchPlaceholder: "Search the Fediverse" notifications: "Notifications" username: "Username" password: "Password" @@ -1488,13 +1488,32 @@ _time: hour: "Hour(s)" day: "Day(s)" _filters: + _dialog: + title: "Advanced search filters" + learnMore: "View advanced filters" + wordFilters: "Filter by post text" + miscFilters: "Filter by following relationship and/or note type" + userDomain: "Filter by author, mentioned users, reply user or instance domain" + postDate: "Filter by post date" + exclusivity: "Note that the before: filter is exclusive, while the after: filter is inclusive." + word: "word" + phrase: "literal phrase that contains (arbitrary) characters" + attachmentType: "Filter by attachment type(s)" + info: "Nomenclature" + info1: "Text in brackets signifies available optional filter parameters. Filter aliases or parameter options are signified by a pipe character." + info2: "A dash enclosed in brackets denotes the ability to invert/negate a filter with the dash character." fromUser: "From user" + replyTo: "Replying to" + mentioning: "Mentioning" withFile: "With file" fromDomain: "From domain" notesBefore: "Posts before" notesAfter: "Posts after" followingOnly: "Following only" followersOnly: "Followers only" + repliesOnly: "Replies only" + excludeReplies: "Exclude replies" + excludeRenotes: "Exclude boosts" _tutorial: title: "How to use Iceshrimp" step1_1: "Welcome!" diff --git a/packages/backend/src/server/api/common/generate-fts-query.ts b/packages/backend/src/server/api/common/generate-fts-query.ts index eaa3e5f8c..17cc275a1 100644 --- a/packages/backend/src/server/api/common/generate-fts-query.ts +++ b/packages/backend/src/server/api/common/generate-fts-query.ts @@ -10,8 +10,6 @@ const filters = { "-mention": mentionFilterInverse, "reply": replyFilter, "-reply": replyFilterInverse, - "replyto": replyFilter, - "-replyto": replyFilterInverse, "to": replyFilter, "-to": replyFilterInverse, "before": beforeFilter, @@ -19,14 +17,15 @@ const filters = { "after": afterFilter, "since": afterFilter, "domain": domainFilter, + "-domain": domainFilterInverse, "host": domainFilter, + "-host": domainFilterInverse, "filter": miscFilter, "-filter": miscFilterInverse, "has": attachmentFilter, } as Record, search: string, id: number) => any> //TODO: editing the query should be possible, clicking search again resets it (it should be a twitter-like top of the page kind of deal) -//TODO: new filters are missing from the filter dropdown, and said dropdown should always show (remove the searchFilters meta prop), also we should fix the null bug export function generateFtsQuery(query: SelectQueryBuilder, q: string): void { const components = q.trim().split(" "); @@ -132,6 +131,10 @@ function domainFilter(query: SelectQueryBuilder, filter: string) { query.andWhere('note.userHost = :domain', { domain: filter }); } +function domainFilterInverse(query: SelectQueryBuilder, filter: string) { + query.andWhere('note.userHost <> :domain', { domain: filter }); +} + function miscFilter(query: SelectQueryBuilder, filter: string) { let subQuery: SelectQueryBuilder | null = null; if (filter === 'followers') { @@ -144,7 +147,7 @@ function miscFilter(query: SelectQueryBuilder, filter: string) { .where('following.followerId = :meId') } else if (filter === 'replies') { query.andWhere('note.replyId IS NOT NULL'); - } else if (filter === 'boosts') { + } else if (filter === 'boosts' || filter === 'renotes') { query.andWhere('note.renoteId IS NOT NULL'); } diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 780f79e36..bab32b73c 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -305,11 +305,6 @@ export const meta = { optional: false, nullable: false, }, - searchFilters: { - type: "boolean", - optional: false, - nullable: false, - }, hcaptcha: { type: "boolean", optional: false, @@ -489,7 +484,6 @@ export default define(meta, paramDef, async (ps, me) => { recommendedTimeline: !instance.disableRecommendedTimeline, globalTimeLine: !instance.disableGlobalTimeline, emailRequiredForSignup: instance.emailRequiredForSignup, - searchFilters: true, hcaptcha: instance.enableHcaptcha, recaptcha: instance.enableRecaptcha, objectStorage: instance.useObjectStorage, diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts index f9fbd718e..d51504ff8 100644 --- a/packages/backend/src/server/nodeinfo.ts +++ b/packages/backend/src/server/nodeinfo.ts @@ -78,7 +78,6 @@ const nodeinfo2 = async () => { disableRecommendedTimeline: meta.disableRecommendedTimeline, disableGlobalTimeline: meta.disableGlobalTimeline, emailRequiredForSignup: meta.emailRequiredForSignup, - searchFilters: true, postEditing: true, postImports: meta.experimentalFeatures?.postImports || false, enableHcaptcha: meta.enableHcaptcha, diff --git a/packages/client/src/components/MkDialog.vue b/packages/client/src/components/MkDialog.vue index 6d1f12833..a1175001d 100644 --- a/packages/client/src/components/MkDialog.vue +++ b/packages/client/src/components/MkDialog.vue @@ -62,7 +62,7 @@ v-model="inputValue" autofocus :autocomplete="input.autocomplete" - :type="input.type == 'search' ? 'search' : input.type || 'text'" + :type="(input.type || 'text') as 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | undefined" :placeholder="input.placeholder || undefined" :style="{ width: input.type === 'search' ? '300px' : null, @@ -208,6 +208,7 @@ import MkTextarea from "@/components/form/textarea.vue"; import MkSelect from "@/components/form/select.vue"; import * as os from "@/os"; import { i18n } from "@/i18n"; +import XSearchFilterDialog from "@/components/MkSearchFilterDialog.vue"; interface Input { type: HTMLInputElement["type"]; @@ -278,7 +279,7 @@ const emit = defineEmits<{ const modal = shallowRef>(); -const inputValue = ref(props.input?.default ?? null); +const inputValue = ref(props.input?.default ?? ""); const selectedValue = ref(props.select?.default ?? null); let disabledReason = $ref( @@ -353,6 +354,13 @@ function formatDateToYYYYMMDD(date) { return `${year}-${month}-${day}`; } +function appendSearchFilter(filter: string, trailingSpace: boolean = true) { + if (typeof inputValue.value !== "string") inputValue.value = ""; + if (inputValue.value.length > 0 && inputValue.value.at(inputValue.value.length - 1) !== " ") inputValue.value += " "; + inputValue.value += filter; + if (trailingSpace) inputValue.value += " "; +} + async function openSearchFilters(ev) { await os.popupMenu( [ @@ -361,10 +369,35 @@ async function openSearchFilters(ev) { text: i18n.ts._filters.fromUser, action: () => { os.selectUser().then((user) => { - inputValue.value += " from:@" + Acct.toString(user); + appendSearchFilter(`from:${Acct.toString(user)}`); }); }, }, + { + icon: "ph-at ph-bold ph-lg", + text: i18n.ts._filters.mentioning, + action: () => { + os.selectUser().then((user) => { + appendSearchFilter(`mention:${Acct.toString(user)}`); + }); + }, + }, + { + icon: "ph-arrow-u-up-left ph-bold ph-lg", + text: i18n.ts._filters.replyTo, + action: () => { + os.selectUser().then((user) => { + appendSearchFilter(`reply:${Acct.toString(user)}`); + }); + }, + }, + { + icon: "ph-link ph-bold ph-lg", + text: i18n.ts._filters.fromDomain, + action: () => { + appendSearchFilter("domain:", false); + }, + }, { type: "parent", text: i18n.ts._filters.withFile, @@ -374,39 +407,33 @@ async function openSearchFilters(ev) { text: i18n.ts.image, icon: "ph-image-square ph-bold ph-lg", action: () => { - inputValue.value += " has:image"; + appendSearchFilter("has:image"); }, }, { text: i18n.ts.video, icon: "ph-video-camera ph-bold ph-lg", action: () => { - inputValue.value += " has:video"; + appendSearchFilter("has:video"); }, }, { text: i18n.ts.audio, icon: "ph-music-note ph-bold ph-lg", action: () => { - inputValue.value += " has:audio"; + appendSearchFilter("has:audio"); }, }, { text: i18n.ts.file, icon: "ph-file ph-bold ph-lg", action: () => { - inputValue.value += " has:file"; + appendSearchFilter("has:file"); }, }, ], }, - { - icon: "ph-link ph-bold ph-lg", - text: i18n.ts._filters.fromDomain, - action: () => { - inputValue.value += " domain:"; - }, - }, + null, { icon: "ph-calendar-blank ph-bold ph-lg", text: i18n.ts._filters.notesBefore, @@ -415,8 +442,7 @@ async function openSearchFilters(ev) { title: i18n.ts._filters.notesBefore, }).then((res) => { if (res.canceled) return; - inputValue.value += - " before:" + formatDateToYYYYMMDD(res.result); + appendSearchFilter("before:" + formatDateToYYYYMMDD(res.result)); }); }, }, @@ -428,31 +454,61 @@ async function openSearchFilters(ev) { title: i18n.ts._filters.notesAfter, }).then((res) => { if (res.canceled) return; - inputValue.value += - " after:" + formatDateToYYYYMMDD(res.result); + appendSearchFilter("after:" + formatDateToYYYYMMDD(res.result)); }); }, }, + null, { icon: "ph-eye ph-bold ph-lg", text: i18n.ts._filters.followingOnly, action: () => { - inputValue.value += " filter:following "; + appendSearchFilter("filter:following"); }, }, { icon: "ph-users-three ph-bold ph-lg", text: i18n.ts._filters.followersOnly, action: () => { - inputValue.value += " filter:followers "; + appendSearchFilter("filter:followers"); + }, + }, + { + icon: "ph-arrow-u-up-left ph-bold ph-lg", + text: i18n.ts._filters.repliesOnly, + action: () => { + appendSearchFilter("filter:replies"); + }, + }, + null, + { + icon: "ph-arrow-u-up-left ph-bold ph-lg", + text: i18n.ts._filters.excludeReplies, + action: () => { + appendSearchFilter("-filter:replies"); + }, + }, + { + icon: "ph-repeat ph-bold ph-lg", + text: i18n.ts._filters.excludeRenotes, + action: () => { + appendSearchFilter("-filter:renotes"); + }, + }, + null, + { + icon: "ph-question ph-bold ph-lg", + text: i18n.ts._filters._dialog.learnMore, + action: () => { + os.popup(XSearchFilterDialog, {}, {}, "closed"); }, }, ], ev.target, { noReturnFocus: true }, ); - inputEl.value.focus(); - inputEl.value.selectRange(inputValue.value.length, inputValue.value.length); // cursor at end + inputEl.value!.focus(); + inputEl.value!.selectRange((inputValue.value as string).length, (inputValue.value as string).length); // cursor at end } onMounted(() => { diff --git a/packages/client/src/components/MkSearchFilterDialog.vue b/packages/client/src/components/MkSearchFilterDialog.vue new file mode 100644 index 000000000..d02bd54a2 --- /dev/null +++ b/packages/client/src/components/MkSearchFilterDialog.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/packages/client/src/pages/search-filters.vue b/packages/client/src/pages/search-filters.vue new file mode 100644 index 000000000..53d49bb96 --- /dev/null +++ b/packages/client/src/pages/search-filters.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/packages/client/src/scripts/search.ts b/packages/client/src/scripts/search.ts index ecea1ff2d..0e23a412d 100644 --- a/packages/client/src/scripts/search.ts +++ b/packages/client/src/scripts/search.ts @@ -1,11 +1,10 @@ import * as os from "@/os"; import { i18n } from "@/i18n"; import { mainRouter } from "@/router"; -import { instance } from "@/instance"; export async function search() { const { canceled, result: query } = await os.inputText({ - type: instance.features.searchFilters ? "search" : "text", + type: "search", title: i18n.ts.search, placeholder: i18n.ts.searchPlaceholder, }); diff --git a/packages/client/src/widgets/server-metric/index.vue b/packages/client/src/widgets/server-metric/index.vue index 6504aa11c..8f7c4836b 100644 --- a/packages/client/src/widgets/server-metric/index.vue +++ b/packages/client/src/widgets/server-metric/index.vue @@ -103,8 +103,7 @@ os.apiGet("server-info", {}).then((res) => { const toggleView = () => { if ( - (widgetProps.view === 5 && instance.features.searchFilters) || - (widgetProps.view === 4 && !instance.features.searchFilters) + (widgetProps.view === 5) ) { widgetProps.view = 0; } else {