2023-10-07 23:23:53 +00:00
|
|
|
import { z } from "https://esm.sh/zod@3.22.4";
|
2023-10-08 22:08:18 +00:00
|
|
|
import { Channel, semaphore } from "https://raw.githubusercontent.com/BlowaterNostr/csp/master/csp.ts";
|
2023-10-09 18:39:52 +00:00
|
|
|
import { GroupMessageGetter } from "../UI/app_update.tsx";
|
2023-10-15 22:39:21 +00:00
|
|
|
import { ConversationSummary } from "../UI/conversation-list.ts";
|
2023-10-19 03:44:43 +00:00
|
|
|
import { ConversationListRetriever, GroupMessageListGetter } from "../UI/conversation-list.tsx";
|
2023-10-09 18:39:52 +00:00
|
|
|
import { ChatMessage } from "../UI/message.ts";
|
|
|
|
import { Database_Contextual_View } from "../database.ts";
|
|
|
|
import { prepareEncryptedNostrEvent } from "../lib/nostr-ts/event.ts";
|
|
|
|
import { PrivateKey, PublicKey } from "../lib/nostr-ts/key.ts";
|
|
|
|
import { InMemoryAccountContext, NostrAccountContext, NostrEvent, NostrKind } from "../lib/nostr-ts/nostr.ts";
|
|
|
|
import { ConnectionPool } from "../lib/nostr-ts/relay.ts";
|
2023-10-09 21:02:13 +00:00
|
|
|
import { getTags, Parsed_Event } from "../nostr.ts";
|
2023-10-15 22:39:21 +00:00
|
|
|
import { parseJSON } from "./profile.ts";
|
2023-10-08 21:03:15 +00:00
|
|
|
|
|
|
|
export type GM_Types = "gm_creation" | "gm_message" | "gm_invitation";
|
2023-10-07 23:23:53 +00:00
|
|
|
|
|
|
|
export type GroupMessage = {
|
|
|
|
event: NostrEvent<NostrKind.Group_Message>;
|
|
|
|
};
|
2023-09-24 21:56:29 +00:00
|
|
|
|
2023-10-09 21:02:13 +00:00
|
|
|
export type gm_Creation = {
|
2023-10-07 10:59:06 +00:00
|
|
|
cipherKey: InMemoryAccountContext;
|
|
|
|
groupKey: InMemoryAccountContext;
|
|
|
|
};
|
|
|
|
|
2023-10-09 21:02:13 +00:00
|
|
|
export type gm_Invitation = {
|
2023-10-08 21:03:15 +00:00
|
|
|
cipherKey: InMemoryAccountContext;
|
|
|
|
groupAddr: PublicKey;
|
|
|
|
};
|
|
|
|
|
2023-10-15 22:39:21 +00:00
|
|
|
export interface GroupChatAdder {
|
|
|
|
add(key: string): void;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface ProfileAdder {
|
|
|
|
add(key: string): void;
|
|
|
|
}
|
|
|
|
|
2023-10-09 18:39:52 +00:00
|
|
|
export class GroupMessageController implements GroupMessageGetter, GroupMessageListGetter {
|
2023-10-19 03:44:43 +00:00
|
|
|
private created_groups = new Map<string, gm_Creation>();
|
|
|
|
private invitations = new Map<string, gm_Invitation>();
|
|
|
|
private messages = new Map<string, ChatMessage[]>();
|
2023-09-24 21:56:29 +00:00
|
|
|
|
|
|
|
constructor(
|
|
|
|
private readonly ctx: NostrAccountContext,
|
2023-10-15 22:39:21 +00:00
|
|
|
private readonly groupSyncer: GroupChatAdder,
|
|
|
|
private readonly profileSyncer: ProfileAdder,
|
2023-09-24 21:56:29 +00:00
|
|
|
) {}
|
|
|
|
|
2023-10-09 18:39:52 +00:00
|
|
|
getConversationList() {
|
2023-10-19 03:44:43 +00:00
|
|
|
const conversations = new Map<string, ConversationSummary>();
|
2023-10-08 21:03:15 +00:00
|
|
|
for (const v of this.created_groups.values()) {
|
2023-10-19 03:44:43 +00:00
|
|
|
conversations.set(v.groupKey.publicKey.bech32(), {
|
2023-10-08 21:03:15 +00:00
|
|
|
pubkey: v.groupKey.publicKey,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
for (const v of this.invitations.values()) {
|
2023-10-19 03:44:43 +00:00
|
|
|
conversations.set(v.groupAddr.bech32(), {
|
2023-10-08 21:03:15 +00:00
|
|
|
pubkey: v.groupAddr,
|
|
|
|
});
|
|
|
|
}
|
2023-10-19 03:44:43 +00:00
|
|
|
return Array.from(conversations.values());
|
2023-10-08 21:03:15 +00:00
|
|
|
}
|
|
|
|
|
2023-10-07 23:23:53 +00:00
|
|
|
getGroupMessages(publicKey: string): ChatMessage[] {
|
|
|
|
const msgs = this.messages.get(publicKey);
|
|
|
|
return msgs ? msgs : [];
|
|
|
|
}
|
|
|
|
|
2023-10-09 21:02:13 +00:00
|
|
|
async encodeCreationToNostrEvent(groupCreation: gm_Creation) {
|
2023-10-03 15:15:45 +00:00
|
|
|
const event = prepareEncryptedNostrEvent(this.ctx, {
|
|
|
|
encryptKey: this.ctx.publicKey,
|
2023-10-07 10:59:06 +00:00
|
|
|
kind: NostrKind.Group_Message,
|
2023-10-03 15:15:45 +00:00
|
|
|
tags: [],
|
2023-10-07 10:59:06 +00:00
|
|
|
content: JSON.stringify({
|
2023-10-07 23:23:53 +00:00
|
|
|
type: "gm_creation",
|
2023-10-07 10:59:06 +00:00
|
|
|
cipherKey: groupCreation.cipherKey.privateKey.bech32,
|
|
|
|
groupKey: groupCreation.groupKey.privateKey.bech32,
|
|
|
|
}),
|
2023-09-24 21:56:29 +00:00
|
|
|
});
|
|
|
|
return event;
|
|
|
|
}
|
|
|
|
|
2023-10-15 22:39:21 +00:00
|
|
|
async prepareGroupMessageEvent(groupAddr: PublicKey, text: string) {
|
|
|
|
const groupCtx = this.getGroupChatCtx(groupAddr);
|
|
|
|
if (groupCtx == undefined) {
|
|
|
|
return new Error(`group ctx for ${groupAddr.bech32()} is empty`);
|
|
|
|
}
|
|
|
|
const nostrEvent = await prepareEncryptedNostrEvent(this.ctx, {
|
|
|
|
content: JSON.stringify({
|
|
|
|
type: "gm_message",
|
|
|
|
text,
|
|
|
|
}),
|
|
|
|
kind: NostrKind.Group_Message,
|
|
|
|
tags: [
|
|
|
|
["p", groupAddr.hex],
|
|
|
|
],
|
|
|
|
encryptKey: groupCtx.publicKey,
|
|
|
|
});
|
|
|
|
return nostrEvent;
|
|
|
|
}
|
|
|
|
|
2023-10-03 15:15:45 +00:00
|
|
|
createGroupChat() {
|
2023-10-09 21:02:13 +00:00
|
|
|
const groupChatCreation: gm_Creation = {
|
2023-10-07 10:59:06 +00:00
|
|
|
cipherKey: InMemoryAccountContext.New(PrivateKey.Generate()),
|
|
|
|
groupKey: InMemoryAccountContext.New(PrivateKey.Generate()),
|
2023-10-03 15:15:45 +00:00
|
|
|
};
|
2023-10-07 10:59:06 +00:00
|
|
|
this.created_groups.set(groupChatCreation.groupKey.publicKey.bech32(), groupChatCreation);
|
2023-10-08 22:08:18 +00:00
|
|
|
this.groupSyncer.add(groupChatCreation.groupKey.publicKey.hex);
|
|
|
|
this.profileSyncer.add(groupChatCreation.groupKey.publicKey.hex);
|
2023-10-07 10:59:06 +00:00
|
|
|
return groupChatCreation;
|
2023-09-24 21:56:29 +00:00
|
|
|
}
|
|
|
|
|
2023-10-09 21:02:13 +00:00
|
|
|
async addEvent(event: Parsed_Event<NostrKind.Group_Message>) {
|
2023-10-19 03:44:43 +00:00
|
|
|
const type = await gmEventType(this.ctx, event);
|
2023-10-20 04:32:41 +00:00
|
|
|
if (type == "gm_creation") {
|
2023-10-07 23:23:53 +00:00
|
|
|
return await this.handleCreation(event);
|
2023-10-08 21:03:15 +00:00
|
|
|
} else if (type == "gm_message") {
|
2023-10-07 23:23:53 +00:00
|
|
|
return await this.handleMessage(event);
|
2023-10-19 03:44:43 +00:00
|
|
|
} else if (type == "gm_invitation") {
|
2023-10-08 21:03:15 +00:00
|
|
|
return await this.handleInvitation(event);
|
2023-10-07 23:23:53 +00:00
|
|
|
} else {
|
2023-10-09 18:39:52 +00:00
|
|
|
console.log(GroupMessageController.name, "ignore", event, "type", type);
|
2023-10-07 23:23:53 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-10-15 22:39:21 +00:00
|
|
|
private async handleInvitation(event: NostrEvent<NostrKind.Group_Message>) {
|
2023-10-19 03:44:43 +00:00
|
|
|
if (event.pubkey == this.ctx.publicKey.hex) {
|
|
|
|
return new Error("the invitation created by me.");
|
|
|
|
} // send by me
|
2023-10-09 21:02:13 +00:00
|
|
|
const invitation = await decodeInvitation(this.ctx, event);
|
|
|
|
if (invitation instanceof Error) {
|
|
|
|
return invitation;
|
2023-10-08 21:03:15 +00:00
|
|
|
}
|
2023-10-09 21:02:13 +00:00
|
|
|
this.invitations.set(invitation.groupAddr.bech32(), invitation);
|
|
|
|
this.groupSyncer.add(invitation.groupAddr.hex);
|
|
|
|
this.profileSyncer.add(invitation.groupAddr.hex);
|
2023-10-08 21:03:15 +00:00
|
|
|
}
|
|
|
|
|
2023-10-15 22:39:21 +00:00
|
|
|
private async handleMessage(event: Parsed_Event<NostrKind.Group_Message>) {
|
2023-10-07 23:23:53 +00:00
|
|
|
const groupAddr = getTags(event).p[0];
|
2023-10-08 21:03:15 +00:00
|
|
|
const groupAddrPubkey = PublicKey.FromHex(groupAddr);
|
|
|
|
if (groupAddrPubkey instanceof Error) {
|
|
|
|
return groupAddrPubkey;
|
|
|
|
}
|
|
|
|
const groupChatCtx = this.getGroupChatCtx(groupAddrPubkey);
|
|
|
|
if (groupChatCtx == undefined) {
|
|
|
|
return new Error(`group ${groupAddr} does not have me in it`);
|
|
|
|
}
|
|
|
|
const decryptedContent = await groupChatCtx.decrypt(event.pubkey, event.content);
|
2023-10-07 10:59:06 +00:00
|
|
|
if (decryptedContent instanceof Error) {
|
2023-10-07 23:23:53 +00:00
|
|
|
return decryptedContent;
|
|
|
|
}
|
|
|
|
|
|
|
|
const json = parseJSON<unknown>(decryptedContent);
|
|
|
|
if (json instanceof Error) {
|
|
|
|
return json;
|
|
|
|
}
|
|
|
|
|
|
|
|
const author = PublicKey.FromHex(event.pubkey);
|
|
|
|
if (author instanceof Error) {
|
|
|
|
return author;
|
2023-10-07 10:59:06 +00:00
|
|
|
}
|
2023-10-08 18:51:53 +00:00
|
|
|
|
|
|
|
let message;
|
|
|
|
try {
|
|
|
|
message = z.object({
|
|
|
|
type: z.string(),
|
|
|
|
text: z.string(),
|
|
|
|
}).parse(json);
|
|
|
|
} catch (e) {
|
|
|
|
message = e as Error;
|
|
|
|
}
|
|
|
|
if (message instanceof Error) {
|
|
|
|
return message;
|
|
|
|
}
|
|
|
|
|
2023-10-07 23:23:53 +00:00
|
|
|
const chatMessage: ChatMessage = {
|
2023-10-09 21:02:13 +00:00
|
|
|
type: "text",
|
2023-10-07 23:23:53 +00:00
|
|
|
event: event,
|
|
|
|
author: author,
|
|
|
|
content: message.text,
|
|
|
|
created_at: new Date(event.created_at * 1000),
|
2023-10-09 21:02:13 +00:00
|
|
|
lamport: event.parsedTags.lamport_timestamp,
|
2023-10-07 23:23:53 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
const messages = this.messages.get(groupAddr);
|
|
|
|
if (messages) {
|
|
|
|
messages.push(chatMessage);
|
|
|
|
} else {
|
|
|
|
this.messages.set(groupAddr, [chatMessage]);
|
2023-10-07 10:59:06 +00:00
|
|
|
}
|
2023-10-07 23:23:53 +00:00
|
|
|
}
|
|
|
|
|
2023-10-15 22:39:21 +00:00
|
|
|
private async handleCreation(event: NostrEvent<NostrKind.Group_Message>) {
|
2023-10-07 23:23:53 +00:00
|
|
|
const decryptedContent = await this.ctx.decrypt(event.pubkey, event.content);
|
|
|
|
if (decryptedContent instanceof Error) {
|
|
|
|
return decryptedContent;
|
2023-10-07 10:59:06 +00:00
|
|
|
}
|
2023-10-07 23:23:53 +00:00
|
|
|
|
|
|
|
const json = parseJSON<unknown>(decryptedContent);
|
|
|
|
if (json instanceof Error) {
|
|
|
|
return json;
|
2023-10-07 10:59:06 +00:00
|
|
|
}
|
2023-10-04 15:07:40 +00:00
|
|
|
|
2023-10-07 23:23:53 +00:00
|
|
|
try {
|
|
|
|
const schema = z.object({
|
|
|
|
type: z.string(),
|
|
|
|
});
|
|
|
|
const content = schema.parse(json);
|
|
|
|
if (content.type == "gm_creation") {
|
|
|
|
const schema = z.object({
|
|
|
|
type: z.string(),
|
|
|
|
cipherKey: z.string(),
|
|
|
|
groupKey: z.string(),
|
|
|
|
});
|
|
|
|
const content = schema.parse(json);
|
|
|
|
const groupKey = PrivateKey.FromString(content.groupKey);
|
|
|
|
if (groupKey instanceof Error) {
|
|
|
|
return groupKey;
|
|
|
|
}
|
|
|
|
const cipherKey = PrivateKey.FromString(content.cipherKey);
|
|
|
|
if (cipherKey instanceof Error) {
|
|
|
|
return cipherKey;
|
|
|
|
}
|
|
|
|
|
|
|
|
const groupChatCreation = {
|
|
|
|
groupKey: InMemoryAccountContext.New(groupKey),
|
|
|
|
cipherKey: InMemoryAccountContext.New(cipherKey),
|
|
|
|
};
|
|
|
|
this.created_groups.set(groupKey.toPublicKey().bech32(), groupChatCreation);
|
2023-10-08 22:08:18 +00:00
|
|
|
this.groupSyncer.add(groupKey.toPublicKey().hex);
|
|
|
|
this.profileSyncer.add(groupKey.toPublicKey().hex);
|
2023-10-04 15:07:40 +00:00
|
|
|
|
2023-10-15 22:39:21 +00:00
|
|
|
// this.conversationLists.addGroupCreation(groupChatCreation);
|
2023-10-07 23:23:53 +00:00
|
|
|
} else if (content.type == "gm_message") {
|
|
|
|
console.log(content);
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
return e as Error;
|
|
|
|
}
|
2023-09-24 21:56:29 +00:00
|
|
|
}
|
|
|
|
|
2023-10-07 23:23:53 +00:00
|
|
|
getGroupChatCtx(group_addr: PublicKey): InMemoryAccountContext | undefined {
|
2023-10-08 21:03:15 +00:00
|
|
|
const creation = this.created_groups.get(group_addr.bech32());
|
|
|
|
if (creation == undefined) {
|
|
|
|
const invitation = this.invitations.get(group_addr.bech32());
|
|
|
|
if (invitation == undefined) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
return invitation.cipherKey;
|
2023-10-07 23:23:53 +00:00
|
|
|
}
|
2023-10-08 21:03:15 +00:00
|
|
|
return creation.cipherKey;
|
2023-10-07 23:23:53 +00:00
|
|
|
}
|
2023-09-24 21:56:29 +00:00
|
|
|
|
2023-10-04 15:07:40 +00:00
|
|
|
getGroupAdminCtx(group_addr: PublicKey): InMemoryAccountContext | undefined {
|
|
|
|
const creation = this.created_groups.get(group_addr.bech32());
|
|
|
|
if (!creation) {
|
|
|
|
return;
|
|
|
|
}
|
2023-10-07 10:59:06 +00:00
|
|
|
return creation.groupKey;
|
2023-10-04 15:07:40 +00:00
|
|
|
}
|
2023-10-08 21:03:15 +00:00
|
|
|
|
|
|
|
async createInvitation(groupAddr: PublicKey, invitee: PublicKey) {
|
|
|
|
// It has to be a group that I created
|
|
|
|
const group = this.created_groups.get(groupAddr.bech32());
|
|
|
|
if (group == undefined) {
|
|
|
|
return new Error(`You are not the admin of ${groupAddr.bech32()}`);
|
|
|
|
}
|
|
|
|
// create the invitation event
|
|
|
|
const invitation = {
|
|
|
|
type: "gm_invitation",
|
|
|
|
cipherKey: group.cipherKey.privateKey.bech32,
|
|
|
|
groupAddr: group.groupKey.publicKey.bech32(),
|
|
|
|
};
|
|
|
|
const event = await prepareEncryptedNostrEvent(this.ctx, {
|
|
|
|
encryptKey: invitee,
|
|
|
|
kind: NostrKind.Group_Message,
|
|
|
|
content: JSON.stringify(invitation),
|
|
|
|
tags: [
|
|
|
|
["p", invitee.hex],
|
|
|
|
],
|
|
|
|
});
|
|
|
|
return event;
|
|
|
|
}
|
2023-09-24 21:56:29 +00:00
|
|
|
}
|
2023-10-07 23:23:53 +00:00
|
|
|
|
|
|
|
function isCreation(event: NostrEvent<NostrKind.Group_Message>) {
|
|
|
|
return event.tags.length == 0;
|
|
|
|
}
|
|
|
|
|
2023-10-19 03:44:43 +00:00
|
|
|
export async function gmEventType(
|
2023-10-08 21:03:15 +00:00
|
|
|
ctx: NostrAccountContext,
|
|
|
|
event: NostrEvent<NostrKind.Group_Message>,
|
2023-10-20 04:32:41 +00:00
|
|
|
): Promise<GM_Types> {
|
2023-10-08 21:03:15 +00:00
|
|
|
if (isCreation(event)) {
|
|
|
|
return "gm_creation";
|
|
|
|
}
|
|
|
|
|
|
|
|
const receiver = getTags(event).p[0];
|
|
|
|
if (receiver == ctx.publicKey.hex) {
|
|
|
|
return "gm_invitation"; // received by me
|
|
|
|
}
|
2023-10-19 03:44:43 +00:00
|
|
|
|
|
|
|
if (ctx.publicKey.hex == event.pubkey) { // I sent
|
2023-10-20 04:32:41 +00:00
|
|
|
if (await ctx.decrypt(receiver, event.content) instanceof Error) {
|
|
|
|
return "gm_message";
|
2023-10-19 03:44:43 +00:00
|
|
|
}
|
2023-10-20 04:32:41 +00:00
|
|
|
return "gm_invitation";
|
2023-10-19 03:44:43 +00:00
|
|
|
}
|
|
|
|
|
2023-10-08 21:03:15 +00:00
|
|
|
return "gm_message";
|
2023-10-07 23:23:53 +00:00
|
|
|
}
|
2023-10-08 22:08:18 +00:00
|
|
|
|
2023-10-15 22:39:21 +00:00
|
|
|
export class GroupChatSyncer implements GroupChatAdder {
|
2023-10-08 22:08:18 +00:00
|
|
|
readonly groupAddrSet = new Set<string>();
|
|
|
|
private readonly lock = semaphore(1);
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
private readonly database: Database_Contextual_View,
|
|
|
|
private readonly pool: ConnectionPool,
|
|
|
|
) {
|
|
|
|
}
|
|
|
|
|
|
|
|
async add(...groupAddresses: string[]) {
|
|
|
|
const size = this.groupAddrSet.size;
|
|
|
|
for (const groupAddr of groupAddresses) {
|
|
|
|
this.groupAddrSet.add(groupAddr);
|
|
|
|
}
|
|
|
|
if (this.groupAddrSet.size == size) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const resp = await this.lock(async () => {
|
|
|
|
await this.pool.closeSub(GroupChatSyncer.name);
|
|
|
|
const resp = await this.pool.newSub(GroupChatSyncer.name, {
|
|
|
|
"#p": Array.from(this.groupAddrSet),
|
|
|
|
kinds: [NostrKind.Group_Message],
|
|
|
|
});
|
|
|
|
return resp;
|
|
|
|
});
|
|
|
|
if (resp instanceof Error) {
|
|
|
|
console.log(resp);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
for await (let { res: nostrMessage, url: relayUrl } of resp.chan) {
|
|
|
|
if (nostrMessage.type === "EVENT" && nostrMessage.event.content) {
|
|
|
|
this.database.addEvent(nostrMessage.event);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-10-09 21:02:13 +00:00
|
|
|
|
|
|
|
export async function decodeInvitation(ctx: NostrAccountContext, event: NostrEvent<NostrKind.Group_Message>) {
|
2023-10-19 03:44:43 +00:00
|
|
|
let decryptedContent;
|
|
|
|
const pTags = getTags(event).p;
|
|
|
|
if (pTags.length > 0 && pTags[0] != ctx.publicKey.hex) { // I sent
|
|
|
|
decryptedContent = await ctx.decrypt(pTags[0], event.content);
|
|
|
|
} else {
|
|
|
|
decryptedContent = await ctx.decrypt(event.pubkey, event.content);
|
|
|
|
}
|
2023-10-09 21:02:13 +00:00
|
|
|
if (decryptedContent instanceof Error) {
|
|
|
|
return decryptedContent;
|
|
|
|
}
|
|
|
|
|
|
|
|
const json = parseJSON<unknown>(decryptedContent);
|
|
|
|
if (json instanceof Error) {
|
|
|
|
return json;
|
|
|
|
}
|
|
|
|
|
|
|
|
const author = PublicKey.FromHex(event.pubkey);
|
|
|
|
if (author instanceof Error) {
|
|
|
|
return author;
|
|
|
|
}
|
|
|
|
|
|
|
|
let message: {
|
|
|
|
type: string;
|
|
|
|
cipherKey: string;
|
|
|
|
groupAddr: string;
|
|
|
|
};
|
|
|
|
try {
|
|
|
|
message = z.object({
|
|
|
|
type: z.string(),
|
|
|
|
cipherKey: z.string(),
|
|
|
|
groupAddr: z.string(),
|
|
|
|
}).parse(json);
|
|
|
|
} catch (e) {
|
|
|
|
return e as Error;
|
|
|
|
}
|
|
|
|
|
|
|
|
// add invitations
|
|
|
|
const cipherKey = PrivateKey.FromBech32(message.cipherKey);
|
|
|
|
if (cipherKey instanceof Error) {
|
|
|
|
return cipherKey;
|
|
|
|
}
|
|
|
|
const groupAddr = PublicKey.FromBech32(message.groupAddr);
|
|
|
|
if (groupAddr instanceof Error) {
|
|
|
|
return groupAddr;
|
|
|
|
}
|
|
|
|
const invitation: gm_Invitation = {
|
|
|
|
cipherKey: InMemoryAccountContext.New(cipherKey),
|
|
|
|
groupAddr,
|
|
|
|
};
|
|
|
|
return invitation;
|
|
|
|
}
|