[client] Improve search filter menu

This commit is contained in:
Laura Hausmann 2023-11-18 04:06:20 +01:00
parent b4616d3f36
commit 1b4fedc59f
Signed by: zotan
GPG key ID: D044E84C5BE01605
9 changed files with 246 additions and 38 deletions

View file

@ -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!"

View file

@ -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<string, (query: SelectQueryBuilder<any>, 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<any>, q: string): void {
const components = q.trim().split(" ");
@ -132,6 +131,10 @@ function domainFilter(query: SelectQueryBuilder<any>, filter: string) {
query.andWhere('note.userHost = :domain', { domain: filter });
}
function domainFilterInverse(query: SelectQueryBuilder<any>, filter: string) {
query.andWhere('note.userHost <> :domain', { domain: filter });
}
function miscFilter(query: SelectQueryBuilder<any>, filter: string) {
let subQuery: SelectQueryBuilder<any> | null = null;
if (filter === 'followers') {
@ -144,7 +147,7 @@ function miscFilter(query: SelectQueryBuilder<any>, 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');
}

View file

@ -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,

View file

@ -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,

View file

@ -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<InstanceType<typeof MkModal>>();
const inputValue = ref<string | number | null>(props.input?.default ?? null);
const inputValue = ref<string | number | null>(props.input?.default ?? "");
const selectedValue = ref(props.select?.default ?? null);
let disabledReason = $ref<null | "charactersExceeded" | "charactersBelow">(
@ -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(() => {

View file

@ -0,0 +1,39 @@
<template>
<XModalWindow
ref="dialog"
:width="600"
@close="dialog?.close()"
@closed="$emit('closed')"
>
<template #header>{{ i18n.ts._filters._dialog.title }}</template>
<XSearchFilters :popup="true" style="background: var(--bg)" />
</XModalWindow>
</template>
<script lang="ts" setup>
import XModalWindow from "@/components/MkModalWindow.vue";
import XSearchFilters from "@/pages/search-filters.vue";
import { i18n } from "@/i18n";
const emit = defineEmits<{
(ev: "done"): void;
(ev: "closed"): void;
}>();
const dialog = $ref<InstanceType<typeof XModalWindow>>();
function close(res) {
dialog.close();
}
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -0,0 +1,100 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-if="!popup" /></template>
<MkSpacer :content-max="800">
<div class="search-filters">
<div class="section _block">
<div class="title">{{ i18n.ts._filters._dialog.info }}</div>
<div class="content">
<p>{{ i18n.ts._filters._dialog.info1 }}</p>
<p>{{ i18n.ts._filters._dialog.info2 }}</p>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._filters._dialog.wordFilters }}</div>
<div class="content">
<p><code>[-]{{ i18n.ts._filters._dialog.word }}</code></p>
<p><code>"{{ i18n.ts._filters._dialog.phrase }}"</code></p>
<p><code>(one OR of OR multiple OR words)</code></p>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._filters._dialog.userDomain }}</div>
<div class="content">
<p><code>[-]from:[@]user[@host.tld]</code></p>
<p><code>[-]mention:[@]user[@host.tld]</code></p>
<p><code>[-]reply|to:[@]user[@host.tld]</code></p>
<p><code>[-]domain|host:host.tld</code></p>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._filters._dialog.miscFilters }}</div>
<div class="content">
<p><code>[-]filter:followers|following|replies|renotes|boosts</code></p>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._filters._dialog.attachmentType }}</div>
<div class="content">
<p><code>has:image|video|audio|file</code></p>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._filters._dialog.postDate }}</div>
<div class="content">
<p>{{ i18n.ts._filters._dialog.exclusivity }}</p>
<p><code>before|until:yyyy-mm-dd</code></p>
<p><code>after|since:yyyy-mm-dd</code></p>
</div>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { defineComponent } from "vue";
import MkTextarea from "@/components/form/textarea.vue";
import { definePageMetadata } from "@/scripts/page-metadata";
import { i18n } from "@/i18n";
import { instance } from "@/instance";
defineProps<{
popup?: boolean;
}>();
definePageMetadata({
title: i18n.ts._filters._dialog.title,
icon: "ph-funnel ph-bold ph-lg",
});
</script>
<style lang="scss" scoped>
.search-filters {
> .section {
> .title {
position: sticky;
z-index: 1;
top: var(--stickyTop, 0px);
padding: 16px;
font-weight: bold;
-webkit-backdrop-filter: var(--blur, blur(10px));
backdrop-filter: var(--blur, blur(10px));
background-color: var(--panelTransparent);
}
> .content {
> p {
margin: 0;
padding: 16px;
padding-top: 0;
}
> .preview {
border-top: solid 0.5px var(--divider);
padding: 16px;
}
}
}
}
</style>

View file

@ -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,
});

View file

@ -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 {