feat: support nip-36

This commit is contained in:
reya 2024-04-16 07:49:44 +07:00
parent 09b143cb08
commit 94d400cab2
16 changed files with 253 additions and 150 deletions

View File

@ -19,6 +19,7 @@
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/query-sync-storage-persister": "^5.29.0",
"@tanstack/react-query": "^5.29.0",
"@tanstack/react-query-persist-client": "^5.29.0",

View File

@ -10,7 +10,7 @@ import { getCurrent } from "@tauri-apps/api/webviewWindow";
import { readTextFile } from "@tauri-apps/plugin-fs";
import { nanoid } from "nanoid";
import { useEffect, useRef, useState } from "react";
import { useDebounce, useDebouncedCallback } from "use-debounce";
import { useDebouncedCallback } from "use-debounce";
import { VList, VListHandle } from "virtua";
export const Route = createFileRoute("/$account/home")({

View File

@ -2,8 +2,15 @@ import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import { type Ark } from "@lume/ark";
import { type QueryClient } from "@tanstack/react-query";
import { type Platform } from "@tauri-apps/plugin-os";
import { Account, Interests, Settings } from "@lume/types";
import type { Account, Interests, Settings } from "@lume/types";
import { Spinner } from "@lume/ui";
import { type Descendant } from "slate";
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
};
interface RouterContext {
ark: Ark;
@ -13,6 +20,7 @@ interface RouterContext {
settings?: Settings;
interests?: Interests;
accounts?: Account[];
initialValue?: EditorElement[];
}
export const Route = createRootRouteWithContext<RouterContext>()({

View File

@ -69,6 +69,13 @@ function Screen() {
}));
};
const toggleNsfw = () => {
setNewSettings((prev) => ({
...prev,
nsfw: !newSettings.nsfw,
}));
};
const submit = async () => {
try {
// start loading
@ -167,18 +174,28 @@ function Screen() {
</p>
</div>
</div>
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-50 px-5 py-4 dark:bg-neutral-950">
<div className="flex w-full items-start justify-between gap-4 rounded-lg bg-neutral-100 px-5 py-4 dark:bg-neutral-900">
<Switch.Root
checked={newSettings.nsfw}
onClick={() => toggleNsfw()}
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block size-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
<div className="flex-1">
<h3 className="font-semibold">Filter sensitive content</h3>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
There are many more settings you can configure from the 'Settings'
Screen. Be sure to visit it later.
By default, Lume will display all content which have Content
Warning tag, it's may include NSFW content.
</p>
</div>
</div>
</div>
<button
type="button"
onClick={submit}
disabled={loading}
className="inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
className="mb-1 inline-flex h-11 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
>
{t("global.continue")}
</button>

View File

@ -7,6 +7,7 @@ import { getCurrent } from "@tauri-apps/api/window";
import { UnlistenFn } from "@tauri-apps/api/event";
import { useRouteContext } from "@tanstack/react-router";
import { Spinner } from "@lume/ui";
import * as Tooltip from "@radix-ui/react-tooltip";
export function MediaButton({ className }: { className?: string }) {
const { ark } = useRouteContext({ strict: false });
@ -16,14 +17,13 @@ export function MediaButton({ className }: { className?: string }) {
const uploadToNostrBuild = async () => {
try {
// start loading
setLoading(true);
const image = await ark.upload();
if (image) {
insertImage(editor, image);
}
// reset loading
setLoading(false);
} catch (e) {
setLoading(false);
@ -63,6 +63,9 @@ export function MediaButton({ className }: { className?: string }) {
}, []);
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => uploadToNostrBuild()}
@ -70,10 +73,19 @@ export function MediaButton({ className }: { className?: string }) {
className={cn("inline-flex items-center justify-center", className)}
>
{loading ? (
<Spinner className="size-5" />
<Spinner className="size-4" />
) : (
<AddMediaIcon className="size-5" />
<AddMediaIcon className="size-4" />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Upload media
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@ -0,0 +1,40 @@
import { NsfwIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import { Dispatch, SetStateAction } from "react";
export function NsfwToggle({
nsfw,
setNsfw,
className,
}: {
nsfw: boolean;
setNsfw: Dispatch<SetStateAction<boolean>>;
className?: string;
}) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => setNsfw((prev) => !prev)}
className={cn(
"inline-flex items-center justify-center",
className,
nsfw ? "bg-blue-500 text-white" : "",
)}
>
<NsfwIcon className="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mark as sensitive content
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@ -1,4 +1,4 @@
import { LoaderIcon, TrashIcon } from "@lume/icons";
import { ComposeFilledIcon, NsfwIcon, TrashIcon } from "@lume/icons";
import {
Portal,
cn,
@ -35,11 +35,11 @@ import { Spinner, User } from "@lume/ui";
import { nip19 } from "nostr-tools";
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { NsfwToggle } from "./-components/nsfw";
type EditorElement = {
type: string;
children: Descendant[];
eventId?: string;
type EditorSearch = {
reply_to: string;
quote: boolean;
};
const contactQueryOptions = queryOptions({
@ -51,46 +51,48 @@ const contactQueryOptions = queryOptions({
});
export const Route = createFileRoute("/editor/")({
loader: ({ context }) =>
context.queryClient.ensureQueryData(contactQueryOptions),
component: Screen,
pendingComponent: Pending,
});
function Screen() {
// @ts-ignore, useless
const { reply_to, quote } = Route.useSearch();
const { ark } = Route.useRouteContext();
let initialValue: EditorElement[];
if (quote) {
initialValue = [
validateSearch: (search: Record<string, string>): EditorSearch => {
return {
reply_to: search.reply_to,
quote: search.quote === "true" ?? false,
};
},
beforeLoad: async ({ search }) => {
return {
initialValue: search.quote
? [
{
type: "paragraph",
children: [{ text: "" }],
},
{
type: "event",
eventId: `nostr:${nip19.noteEncode(reply_to)}`,
eventId: `nostr:${nip19.noteEncode(search.reply_to)}`,
children: [{ text: "" }],
},
{
type: "paragraph",
children: [{ text: "" }],
},
];
} else {
initialValue = [
]
: [
{
type: "paragraph",
children: [{ text: "" }],
},
];
}
],
};
},
loader: ({ context }) => {
context.queryClient.ensureQueryData(contactQueryOptions);
},
component: Screen,
pendingComponent: Pending,
});
const ref = useRef<HTMLDivElement | null>();
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
function Screen() {
const { reply_to, quote } = Route.useSearch();
const { ark, initialValue } = Route.useRouteContext();
const [t] = useTranslation();
const [editorValue, setEditorValue] = useState(initialValue);
@ -98,10 +100,14 @@ function Screen() {
const [index, setIndex] = useState(0);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [nsfw, setNsfw] = useState(false);
const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
);
const ref = useRef<HTMLDivElement | null>();
const contacts = useSuspenseQuery(contactQueryOptions).data as Contact[];
const filters = contacts
?.filter((c) =>
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),
@ -204,15 +210,25 @@ function Screen() {
>
<div
data-tauri-drag-region
className="flex h-16 w-full shrink-0 items-center justify-end gap-3 px-2"
className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2"
>
<MediaButton className="size-9 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" />
<NsfwToggle
nsfw={nsfw}
setNsfw={setNsfw}
className="size-8 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
/>
<MediaButton className="size-8 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" />
<button
type="button"
onClick={publish}
className="inline-flex h-9 w-24 items-center justify-center rounded-full bg-blue-500 px-3 font-medium text-white hover:bg-blue-600"
className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
{loading ? <Spinner className="size-5" /> : t("global.post")}
{loading ? (
<Spinner className="size-4" />
) : (
<ComposeFilledIcon className="size-4" />
)}
{t("global.post")}
</button>
</div>
<div className="flex h-full min-h-0 w-full">

View File

@ -23,9 +23,11 @@ enum NSTORE_KEYS {
export class Ark {
public windows: WebviewWindow[];
public settings: Settings;
constructor() {
this.windows = [];
this.settings = undefined;
}
public async get_all_accounts() {
@ -144,7 +146,6 @@ export class Ark {
if (asOf && asOf > 0) until = asOf.toString();
const dedup = true;
const seenIds = new Set<string>();
const dedupQueue = new Set<string>();
@ -155,7 +156,6 @@ export class Ark {
global: isGlobal,
});
if (dedup) {
for (const event of nostrEvents) {
const tags = event.tags
.filter((el) => el[0] === "e")
@ -172,14 +172,21 @@ export class Ark {
}
}
return nostrEvents
const events = nostrEvents
.filter((event) => !dedupQueue.has(event.id))
.sort((a, b) => b.created_at - a.created_at);
if (this.settings?.nsfw) {
return events.filter(
(event) =>
event.tags.filter((event) => event[0] === "content-warning")
.length > 0,
);
}
return nostrEvents;
return events;
} catch (e) {
console.error(String(e));
console.info(String(e));
return [];
}
}
@ -229,7 +236,12 @@ export class Ark {
return nostrEvents.sort((a, b) => b.created_at - a.created_at);
}
public async publish(content: string, reply_to?: string, quote?: boolean) {
public async publish(
content: string,
reply_to?: string,
quote?: boolean,
nsfw?: boolean,
) {
try {
const g = await generateContentTags(content);
@ -238,26 +250,34 @@ export class Ark {
if (reply_to) {
const replyEvent = await this.get_event(reply_to);
const relayHint =
replyEvent.tags.find((ev) => ev[0] === "e")?.[0][2] ?? "";
if (quote) {
eventTags.push([
"e",
replyEvent.id,
replyEvent.relay || "",
"mention",
]);
eventTags.push(["e", replyEvent.id, relayHint, "mention"]);
} else {
const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root");
if (rootEvent) {
eventTags.push(["e", rootEvent[1], rootEvent[2] || "", "root"]);
eventTags.push([
"e",
rootEvent[1],
rootEvent[2] || relayHint,
"root",
]);
}
eventTags.push(["e", replyEvent.id, replyEvent.relay || "", "reply"]);
eventTags.push(["e", replyEvent.id, relayHint, "reply"]);
eventTags.push(["p", replyEvent.pubkey]);
}
}
if (nsfw) {
eventTags.push(["L", "content-warning"]);
eventTags.push(["l", "reason", "content-warning"]);
eventTags.push(["content-warning", "nsfw"]);
}
const cmd: string = await invoke("publish", {
content: eventContent,
tags: eventTags,
@ -605,6 +625,7 @@ export class Ark {
key: NSTORE_KEYS.settings,
});
const settings: Settings = cmd ? JSON.parse(cmd) : null;
this.settings = settings;
return settings;
} catch {
const defaultSettings: Settings = {
@ -612,7 +633,9 @@ export class Ark {
enhancedPrivacy: false,
notification: false,
zap: false,
nsfw: false,
};
this.settings = defaultSettings;
return defaultSettings;
}
}

View File

@ -123,3 +123,4 @@ export * from "./src/laurel";
export * from "./src/quote";
export * from "./src/key";
export * from "./src/remote";
export * from "./src/nsfw";

View File

@ -1,19 +1,12 @@
export function AddMediaIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 22v-3m0 0v-3m0 3h-3m3 0h3m0-5.648V11l-.001-1m-9.464 11H10c-.756 0-1.41 0-1.983-.01M22 10H21c-1.393 0-2.09 0-2.676.06A11.5 11.5 0 008.06 20.324c-.02.2-.034.415-.043.665M22 10c-.008-2.15-.068-3.336-.544-4.27a5 5 0 00-2.185-2.185C18.2 3 16.8 3 14 3h-4c-2.8 0-4.2 0-5.27.545A5 5 0 002.545 5.73C2 6.8 2 8.2 2 11v2c0 2.8 0 4.2.545 5.27a5 5 0 002.185 2.185c.78.398 1.738.505 3.287.534M7.5 9.5a1 1 0 110-2 1 1 0 010 2z"
strokeWidth="1.5"
d="M15.25 8.75v-4a2 2 0 0 0-2-2h-8.5a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h4M3.1 11.9l1.794-1.176a2 2 0 0 1 2.206.01l1.279.852M6 6.25h.5m8 8.75h.5M6.75 6.25a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm7 6.95v3.6l2.8-1.8-2.8-1.8Zm5.5 8.05h-8.5a2 2 0 0 1-2-2v-8.5a2 2 0 0 1 2-2h8.5a2 2 0 0 1 2 2v8.5a2 2 0 0 1-2 2Z"
/>
</svg>
);

View File

@ -0,0 +1,13 @@
export function NsfwIcon(props: JSX.IntrinsicElements["svg"]) {
return (
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M4.75 18.75v1.5a1 1 0 0 0 1 1h12.5a1 1 0 0 0 1-1v-1.5a2 2 0 0 0-2-2H6.75a2 2 0 0 0-2 2Zm2-2V12a5.25 5.25 0 0 1 10.5 0v4.75M12 1.75v1.025M21.225 12h1.025M2.775 12H1.75m16.773-6.523.725-.725m-13.771.725-.725-.725M12 16.75v-3"
/>
</svg>
);
}

View File

@ -3,6 +3,8 @@ export interface Settings {
enhancedPrivacy: boolean;
autoUpdate: boolean;
zap: boolean;
nsfw: boolean;
[key: string]: string | number | boolean;
}
export interface Keys {

View File

@ -20,7 +20,6 @@ export const Note = {
Pin: NotePin,
Content: NoteContent,
Zap: NoteZap,
Pin: NotePin,
Child: NoteChild,
Thread: NoteThread,
};

15
pnpm-lock.yaml generated
View File

@ -87,6 +87,9 @@ importers:
'@radix-ui/react-switch':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-tooltip':
specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/query-sync-storage-persister':
specifier: ^5.29.0
version: 5.29.0
@ -259,7 +262,7 @@ importers:
version: 1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-tooltip':
specifier: ^1.0.7
version: 1.0.7(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
version: 1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-query':
specifier: ^5.29.0
version: 5.29.0(react@18.2.0)
@ -410,7 +413,7 @@ importers:
version: 1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-tooltip':
specifier: ^1.0.7
version: 1.0.7(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
version: 1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-query':
specifier: ^5.29.0
version: 5.29.0(react@18.2.0)
@ -2283,7 +2286,7 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-tooltip@1.0.7(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0):
/@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==}
peerDependencies:
'@types/react': '*'
@ -2308,8 +2311,9 @@ packages:
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.2(@types/react@18.2.75)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.75)(react@18.2.0)
'@radix-ui/react-visually-hidden': 1.0.3(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
'@types/react': 18.2.75
'@types/react-dom': 18.2.24
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
@ -2416,7 +2420,7 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-visually-hidden@1.0.3(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0):
/@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==}
peerDependencies:
'@types/react': '*'
@ -2432,6 +2436,7 @@ packages:
'@babel/runtime': 7.24.4
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.24)(@types/react@18.2.75)(react-dom@18.2.0)(react@18.2.0)
'@types/react': 18.2.75
'@types/react-dom': 18.2.24
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false

View File

@ -126,8 +126,6 @@ fn main() {
nostr::event::get_event_thread,
nostr::event::publish,
nostr::event::repost,
nostr::event::upvote,
nostr::event::downvote,
commands::folder::show_in_folder,
commands::folder::get_accounts,
commands::opg::fetch_opg,

View File

@ -222,16 +222,15 @@ pub async fn get_event_thread(id: &str, state: State<'_, Nostr>) -> Result<Vec<E
#[tauri::command]
pub async fn publish(
content: &str,
tags: Vec<Vec<String>>,
tags: Vec<Vec<&str>>,
state: State<'_, Nostr>,
) -> Result<String, String> {
let client = &state.client;
let final_tags = tags.into_iter().map(|val| Tag::parse(&val).unwrap());
if let Ok(event_id) = client.publish_text_note(content, final_tags).await {
Ok(event_id.to_bech32().unwrap())
} else {
Err("Publish text note failed".into())
match client.publish_text_note(content, final_tags).await {
Ok(event_id) => Ok(event_id.to_bech32().unwrap()),
Err(err) => Err(err.to_string()),
}
}
@ -246,27 +245,3 @@ pub async fn repost(raw: &str, state: State<'_, Nostr>) -> Result<EventId, Strin
Err("Repost failed".into())
}
}
#[tauri::command]
pub async fn upvote(raw: &str, state: State<'_, Nostr>) -> Result<EventId, String> {
let client = &state.client;
let event = Event::from_json(raw).unwrap();
if let Ok(event_id) = client.like(&event).await {
Ok(event_id)
} else {
Err("Upvote failed".into())
}
}
#[tauri::command]
pub async fn downvote(raw: &str, state: State<'_, Nostr>) -> Result<EventId, String> {
let client = &state.client;
let event = Event::from_json(raw).unwrap();
if let Ok(event_id) = client.dislike(&event).await {
Ok(event_id)
} else {
Err("Downvote failed".into())
}
}