diff --git a/deno.lock b/deno.lock index 289857ee..128f58a5 100644 --- a/deno.lock +++ b/deno.lock @@ -212,6 +212,7 @@ "npm:unist-util-visit@5": "5.0.0", "npm:uri-template-router@^0.0.17": "0.0.17", "npm:url-template@^3.1.1": "3.1.1", + "npm:vinxi@0.5.8": "0.5.8_@babel+core@7.28.3_ioredis@5.7.0_drizzle-orm@1.0.0-beta.1-5e64efc__@opentelemetry+api@1.9.0__postgres@3.4.7_@opentelemetry+api@1.9.0_postgres@3.4.7_@types+node@24.2.0", "npm:vinxi@~0.5.8": "0.5.8_@babel+core@7.28.3_ioredis@5.7.0_drizzle-orm@1.0.0-beta.1-5e64efc__@opentelemetry+api@1.9.0__postgres@3.4.7_@opentelemetry+api@1.9.0_postgres@3.4.7_@types+node@24.2.0", "npm:vite-plugin-cjs-interop@^2.2.0": "2.2.0_vite@6.3.5__picomatch@4.0.3_@types+node@24.2.0", "npm:vite-plugin-relay-lite@0.11": "0.11.0_graphql@16.11.0-canary.pr.4364.2b4ffe237616247a733274dfdcb404c3d55d9f02_vite@6.3.5__picomatch@4.0.3_@types+node@24.2.0", diff --git a/graphql/invite.ts b/graphql/invite.ts new file mode 100644 index 00000000..7b6b2323 --- /dev/null +++ b/graphql/invite.ts @@ -0,0 +1,368 @@ +import { normalizeEmail } from "@hackerspub/models/account"; +import { negotiateLocale } from "@hackerspub/models/i18n"; +import { + type Account as AccountTable, + accountTable, + type Actor, +} from "@hackerspub/models/schema"; +import { createSignupToken, type SignupToken } from "@hackerspub/models/signup"; +import type { Uuid } from "@hackerspub/models/uuid"; +import { getLogger } from "@logtape/logtape"; +import { expandGlob } from "@std/fs"; +import { join } from "@std/path"; +import { createMessage, type Message } from "@upyo/core"; +import { and, eq, gt, sql } from "drizzle-orm"; +import { parseTemplate } from "url-template"; +import { Account } from "./account.ts"; +import { builder } from "./builder.ts"; +import { EMAIL_FROM } from "./email.ts"; + +const logger = getLogger(["hackerspub", "graphql", "invite"]); + +interface Invitation { + inviterId: Uuid; + email: string; + locale: Intl.Locale; + message?: string; +} + +const InvitationRef = builder.objectRef("Invitation"); + +InvitationRef.implement({ + description: "An invitation that has been created.", + fields: (t) => ({ + inviter: t.field({ + type: Account, + async resolve(invitation, _, ctx) { + const account = await ctx.db.query.accountTable.findFirst({ + where: { id: invitation.inviterId }, + with: { actor: true }, + }); + if (account == null) { + throw new Error( + `Account with ID ${invitation.inviterId} not found.`, + ); + } + return account; + }, + }), + email: t.field({ + type: "Email", + resolve(invitation) { + return invitation.email; + }, + }), + locale: t.field({ + type: "Locale", + resolve(invitation) { + return invitation.locale; + }, + }), + message: t.field({ + type: "Markdown", + nullable: true, + resolve(invitation) { + return invitation.message ?? null; + }, + }), + }), +}); + +const InviteInviterError = builder.enumType("InviteInviterError", { + values: [ + "INVITER_NOT_AUTHENTICATED", + "INVITER_NO_INVITATIONS_LEFT", + "INVITER_EMAIL_SEND_FAILED", + ] as const, +}); + +const InviteEmailError = builder.enumType("InviteEmailError", { + values: ["EMAIL_INVALID", "EMAIL_ALREADY_TAKEN"] as const, +}); + +const InviteVerifyUrlError = builder.enumType("InviteVerifyUrlError", { + values: ["VERIFY_URL_NO_TOKEN", "VERIFY_URL_NO_CODE"] as const, +}); + +interface InviteValidationErrors { + inviter?: typeof InviteInviterError.$inferType; + email?: typeof InviteEmailError.$inferType; + verifyUrl?: typeof InviteVerifyUrlError.$inferType; + emailOwnerId?: Uuid; +} + +const InviteValidationErrorsRef = builder.objectRef( + "InviteValidationErrors", +); + +InviteValidationErrorsRef.implement({ + description: "Validation errors that occurred during the invitation process.", + fields: (t) => ({ + inviter: t.field({ + type: InviteInviterError, + nullable: true, + resolve: (errors) => errors.inviter ?? null, + }), + email: t.field({ + type: InviteEmailError, + nullable: true, + resolve: (errors) => errors.email ?? null, + }), + verifyUrl: t.field({ + type: InviteVerifyUrlError, + nullable: true, + resolve: (errors) => errors.verifyUrl ?? null, + }), + emailOwner: t.field({ + type: Account, + nullable: true, + resolve(errors, _, ctx) { + if (errors.emailOwnerId == null) return null; + return ctx.db.query.accountTable.findFirst({ + where: { id: errors.emailOwnerId }, + }); + }, + }), + }), +}); + +const InviteResultRef = builder.unionType("InviteResult", { + types: [InvitationRef, InviteValidationErrorsRef], + resolveType(obj) { + if ("inviterId" in obj) return InvitationRef; + return InviteValidationErrorsRef; + }, +}); + +export const EXPIRATION = Temporal.Duration.from({ hours: 48 }); + +builder.mutationField("invite", (t) => + t.field({ + type: InviteResultRef, + args: { + email: t.arg({ type: "Email", required: true }), + locale: t.arg({ type: "Locale", required: true }), + message: t.arg({ type: "Markdown" }), + verifyUrl: t.arg({ + type: "URITemplate", + required: true, + description: + "The RFC 6570-compliant URI Template for the verification link. Available variables: `{token}` and `{code}`.", + }), + }, + async resolve(_root, args, ctx) { + const errors = {} as InviteValidationErrors; + if (ctx.account == null) errors.inviter = "INVITER_NOT_AUTHENTICATED"; + else if (ctx.account.leftInvitations < 1) { + errors.inviter = "INVITER_NO_INVITATIONS_LEFT"; + } + let email: string | undefined; + try { + email = normalizeEmail(args.email); + } catch { + errors.email = "EMAIL_INVALID"; + } + if (email != null) { + const existingEmail = await ctx.db.query.accountEmailTable.findFirst({ + where: { email }, + }); + if (existingEmail != null) { + errors.email = "EMAIL_ALREADY_TAKEN"; + errors.emailOwnerId = existingEmail.accountId; + } + } + const verifyUrlTemplate = parseTemplate(args.verifyUrl); + const a = verifyUrlTemplate.expand({ + token: "00000000-0000-0000-0000-000000000000", + code: "AAAAAA", + }); + const b = verifyUrlTemplate.expand({ + token: "ffffffff-ffff-ffff-ffff-ffffffffffff", + code: "AAAAAA", + }); + if (a === b) { + errors.verifyUrl = "VERIFY_URL_NO_TOKEN"; + } + const c = verifyUrlTemplate.expand({ + token: "00000000-0000-0000-0000-000000000000", + code: "BBBBBB", + }); + if (a === c) { + errors.verifyUrl = "VERIFY_URL_NO_CODE"; + } + if ( + errors.inviter != null || errors.email != null || + errors.email != null || ctx.account == null || email == null + ) { + return errors; + } + const updated = await ctx.db.update(accountTable).set({ + leftInvitations: sql`${accountTable.leftInvitations} - 1`, + }).where( + and( + eq(accountTable.id, ctx.account.id), + gt(accountTable.leftInvitations, 0), + ), + ).returning(); + if (updated.length < 1) { + return { + inviter: "INVITER_NO_INVITATIONS_LEFT", + } satisfies InviteValidationErrors; + } + const token = await createSignupToken(ctx.kv, email, { + inviterId: ctx.account.id, + expiration: EXPIRATION, + }); + const message = await getEmailMessage({ + locale: args.locale, + inviter: ctx.account, + verifyUrlTemplate: args.verifyUrl, + to: email, + token, + message: args.message ?? undefined, + }); + const receipt = await ctx.email.send(message); + if (!receipt.successful) { + logger.error( + "Failed to send invitation email: {errors}", + { errors: receipt.errorMessages }, + ); + // Credit back the invitation on email send failure + await ctx.db.update(accountTable).set({ + leftInvitations: sql`${accountTable.leftInvitations} + 1`, + }).where(eq(accountTable.id, ctx.account.id)); + + // Return validation error to inform the user + return { + inviter: "INVITER_EMAIL_SEND_FAILED", + } satisfies InviteValidationErrors; + } + return { + inviterId: ctx.account.id, + email, + locale: args.locale, + message: args.message ?? undefined, + }; + }, + })); + +const LOCALES_DIR = join(import.meta.dirname!, "locales"); + +// Cache for email templates +let cachedTemplates: + | Map< + string, + { subject: string; emailContent: string; emailContentWithMessage: string } + > + | null = null; +let cachedAvailableLocales: Record | null = null; + +async function loadEmailTemplates(): Promise { + if (cachedTemplates && cachedAvailableLocales) return; + + const availableLocales: Record = {}; + const templates = new Map< + string, + { subject: string; emailContent: string; emailContentWithMessage: string } + >(); + + const files = expandGlob(join(LOCALES_DIR, "*.json"), { + includeDirs: false, + }); + + for await (const file of files) { + if (!file.isFile) continue; + const match = file.name.match(/^(.+)\.json$/); + if (match == null) continue; + const localeName = match[1]; + availableLocales[localeName] = file.path; + + try { + const json = await Deno.readTextFile(file.path); + const data = JSON.parse(json); + templates.set(localeName, { + subject: data.invite.emailSubject, + emailContent: data.invite.emailContent, + emailContentWithMessage: data.invite.emailContentWithMessage, + }); + } catch (error) { + console.warn( + `Failed to load email template for locale ${localeName}:`, + error, + ); + } + } + + cachedTemplates = templates; + cachedAvailableLocales = availableLocales; +} + +async function getEmailTemplate( + locale: Intl.Locale, + message: boolean, +): Promise<{ subject: string; content: string }> { + await loadEmailTemplates(); + + const selectedLocale = + negotiateLocale(locale, Object.keys(cachedAvailableLocales!)) ?? + new Intl.Locale("en"); + + const template = cachedTemplates!.get(selectedLocale.baseName); + if (!template) { + throw new Error( + `No email template found for locale ${selectedLocale.baseName}`, + ); + } + + return { + subject: template.subject, + content: message ? template.emailContentWithMessage : template.emailContent, + }; +} + +async function getEmailMessage( + { locale, inviter, to, verifyUrlTemplate, token, message }: { + locale: Intl.Locale; + inviter: AccountTable & { actor: Actor }; + to: string; + verifyUrlTemplate: string; + token: SignupToken; + message?: string; + }, +): Promise { + const verifyUrl = parseTemplate(verifyUrlTemplate).expand({ + token: token.token, + code: token.code, + }); + const expiration = EXPIRATION.toLocaleString(locale.baseName, { + // @ts-ignore: DurationFormatOptions, not DateTimeFormatOptions + style: "long", + }); + const template = await getEmailTemplate(locale, message != null); + function substitute(template: string): string { + return template.replaceAll( + /\{\{(verifyUrl|code|expiration|inviter|inviterName|message)\}\}/g, + (m) => { + return m === "{{verifyUrl}}" + ? verifyUrl + : m === "{{code}}" + ? token.code + : m === "{{expiration}}" + ? expiration + : m === "{{inviter}}" + ? `${inviter.name} (${inviter.actor.handle})` + : m === "{{inviterName}}" + ? inviter.name + : (message ?? ""); + }, + ); + } + return createMessage({ + from: EMAIL_FROM, + to, + subject: substitute(template.subject), + content: { + text: substitute(template.content), + }, + }); +} diff --git a/graphql/locales/en.json b/graphql/locales/en.json index bb7ee11d..24de581a 100644 --- a/graphql/locales/en.json +++ b/graphql/locales/en.json @@ -2,5 +2,10 @@ "login": { "emailSubject": "Sign in to Hackers' Pub", "emailContent": "Welcome back to Hackers' Pub! To sign in, click the following link:\n\n{{verifyUrl}}\n\nor submit this code through the sign-in page:\n\n{{code}}\n\nThis link and code will expire in {{expiration}}.\n\nIf you didn't request this email, you can safely ignore it.\n" + }, + "invite": { + "emailSubject": "{{inviterName}} invites you to Hackers' Pub!", + "emailContent": "{{inviter}} invites you to Hackers' Pub!\n\nHackers' Pub is a place for software engineers to share their knowledge and experience with each other. It's also an ActivityPub-enabled social network, so you can follow your favorite hackers in the fediverse and get their latest posts in your feed.\n\nTo accept the invitation, click the following link:\n\n{{verifyUrl}}\n\nThis link will expire in {{expiration}}.\n", + "emailContentWithMessage": "{{inviter}} invites you to Hackers' Pub! Here's a message from {{inviterName}}:\n\n{{message}}\n\nTo accept the invitation, click the following link:\n\n{{verifyUrl}}\n\nThis link will expire in {{expiration}}.\n" } } diff --git a/graphql/locales/ja.json b/graphql/locales/ja.json index 12e858a3..2c233f41 100644 --- a/graphql/locales/ja.json +++ b/graphql/locales/ja.json @@ -2,5 +2,10 @@ "login": { "emailSubject": "Hackers' Pubへのログイン", "emailContent": "Hackers' Pubへお帰りなさい!ログインするには以下のリンクをクリックしてください:\n\n{{verifyUrl}}\n\n他には下のコードをページに入力して下さい:\n\n{{code}}\n\nこのリンクとコードは{{expiration}}後に有効期限が切れます。\n\nこのメールに心当たりがない場合は、無視していただいて構いません。\n" + }, + "invite": { + "emailSubject": "{{inviterName}}さんがHackers' Pubに招待しています!", + "emailContent": "{{inviter}}さんがHackers' Pubに招待しています!\n\nHackers' Pubは、ソフトウェアエンジニアが知識と経験を共有する場所です。また、ActivityPubに対応したソーシャルネットワークでもあり、フェディバース(fediverse)で気に入ったハッカーをフォローして、最新のコンテンツをフィードで受け取ることができます。\n\n招待を受け入れるには、以下のリンクをクリックしてください:\n\n{{verifyUrl}}\n\nこのリンクは{{expiration}}後に有効期限が切れます。\n", + "emailContentWithMessage": "{{inviter}}さんがHackers' Pubに招待しています!{{inviterName}}さんからのメッセージです:\n\n{{message}}\n\n招待を受け入れるには、以下のリンクをクリックしてください:\n\n{{verifyUrl}}\n\nこのリンクは{{expiration}}後に有効期限が切れます。\n" } } diff --git a/graphql/locales/ko.json b/graphql/locales/ko.json index 4b6f84fb..163e8ac7 100644 --- a/graphql/locales/ko.json +++ b/graphql/locales/ko.json @@ -2,5 +2,10 @@ "login": { "emailSubject": "Hackers' Pub 로그인", "emailContent": "Hackers' Pub에 다시 오신 것을 환영합니다! 로그인하려면 다음 링크를 클릭하세요:\n\n{{verifyUrl}}\n\n혹은 이 코드를 입력해주세요:\n\n{{code}}\n\n이 링크와 코드는 {{expiration}} 후에 만료됩니다.\n\n이 이메일을 요청하지 않았다면 무시해도 됩니다.\n" + }, + "invite": { + "emailSubject": "{{inviterName}} 님이 Hackers' Pub에 초대합니다!", + "emailContent": "{{inviter}} 님이 Hackers' Pub에 초대합니다!\n\nHackers' Pub은 소프트웨어 프로그래머들이 지식과 경험을 서로 나누는 곳입니다. 또한 ActivityPub을 지원하는 소셜 네트워크이기도 하며, 연합우주(fediverse)에서 즐겨찾는 소프트웨어 프로그래머들을 팔로하고 최신 콘텐츠를 받아 볼 수 있습니다.\n\n초대장을 받으려면 아래 링크를 클릭하세요:\n\n{{verifyUrl}}\n\n이 링크는 {{expiration}} 후에 만료됩니다.\n", + "emailContentWithMessage": "{{inviter}} 님이 Hackers' Pub에 초대합니다! 다음은 {{inviterName}} 님의 메시지입니다:\n\n{{message}}\n\n초대장을 받으려면 아래 링크를 클릭하세요:\n\n{{verifyUrl}}\n\n이 링크는 {{expiration}} 후에 만료됩니다.\n" } } diff --git a/graphql/locales/zh-CN.json b/graphql/locales/zh-CN.json index 5e2ea483..46c406fd 100644 --- a/graphql/locales/zh-CN.json +++ b/graphql/locales/zh-CN.json @@ -2,5 +2,10 @@ "login": { "emailSubject": "登录 Hackers' Pub", "emailContent": "欢迎回到 Hackers' Pub!要登录,点击以下链接:\n\n{{verifyUrl}}\n\n或通过登录页面提交此密码:\n\n{{code}}\n\n该链接和密码将于 {{expiration}} 失效。\n\n如果你并没申请这个电子邮件,可以安全地忽略它。\n" + }, + "invite": { + "emailSubject": "{{inviterName}} 邀请你加入 Hackers' Pub!", + "emailContent": "{{inviter}} 邀请你加入 Hackers' Pub!\n\nHackers' Pub 是个为软件工程师共同分享知识和经验的地方。它也是启用了 ActivityPub 的社交网络,这样你可以在联邦宇宙里关注你喜爱的黑客,并获取最新的帖子。\n\n要接受邀请,请点击以下链接:\n\n{{verifyUrl}}\n\n该链接将于 {{expiration}} 失效。\n", + "emailContentWithMessage": "{{inviter}} 邀请你加入 Hackers' Pub!以下是来自 {{inviterName}} 的信息:\n\n{{message}}\n\n要接受邀请,请点击以下链接:\n\n{{verifyUrl}}\n\n该链接将于 {{expiration}} 失效。\n" } } diff --git a/graphql/locales/zh-TW.json b/graphql/locales/zh-TW.json index 14576f4c..8073679f 100644 --- a/graphql/locales/zh-TW.json +++ b/graphql/locales/zh-TW.json @@ -2,5 +2,10 @@ "login": { "emailSubject": "登入 Hackers' Pub", "emailContent": "歡迎回到 Hackers' Pub!要登入,點擊以下連結:\n\n{{verifyUrl}}\n\n或透過登入頁面提交此密碼:\n\n{{code}}\n\n該連結和密碼將於 {{expiration}} 失效。\n\n如果你並沒申請這個電子郵件,可以安全地忽略它。\n" + }, + "invite": { + "emailSubject": "{{inviterName}} 邀請你加入 Hackers' Pub!", + "emailContent": "{{inviter}} 邀請你加入 Hackers' Pub!\n\nHackers' Pub 是個為軟體工程師共同分享知識和經驗的地方。它也是啟用了 ActivityPub 的社交網路,這樣你可以在聯邦宇宙裡關注你喜愛的駭客,並獲取最新的貼文。\n\n要接受邀請,請點擊以下連結:\n\n{{verifyUrl}}\n\n該連結將於 {{expiration}} 失效。\n", + "emailContentWithMessage": "{{inviter}} 邀請你加入 Hackers' Pub!以下是來自 {{inviterName}} 的訊息:\n\n{{message}}\n\n要接受邀請,請點擊以下連結:\n\n{{verifyUrl}}\n\n該連結將於 {{expiration}} 失效。\n" } } diff --git a/graphql/misc.ts b/graphql/misc.ts new file mode 100644 index 00000000..070d7148 --- /dev/null +++ b/graphql/misc.ts @@ -0,0 +1,34 @@ +import { expandGlob } from "@std/fs"; +import { join } from "@std/path"; +import { builder } from "./builder.ts"; + +const LOCALES_DIR = join(import.meta.dirname!, "locales"); + +let cachedLocales: Intl.Locale[] | null = null; + +builder.queryField("availableLocales", (t) => + t.field({ + type: ["Locale"], + async resolve(_root, _args, _ctx) { + if (cachedLocales) return cachedLocales; + + const availableLocales: Intl.Locale[] = []; + const files = expandGlob(join(LOCALES_DIR, "*.json"), { + includeDirs: false, + }); + for await (const file of files) { + if (!file.isFile) continue; + const match = file.name.match(/^(.+)\.json$/); + if (match == null) continue; + const localeName = match[1]; + try { + availableLocales.push(new Intl.Locale(localeName)); + } catch { + // ignore invalid locale tags + } + } + + cachedLocales = availableLocales; + return availableLocales; + }, + })); diff --git a/graphql/mod.ts b/graphql/mod.ts index 9d815348..809597d6 100644 --- a/graphql/mod.ts +++ b/graphql/mod.ts @@ -4,7 +4,9 @@ import "./account.ts"; import "./actor.ts"; import { builder } from "./builder.ts"; import "./doc.ts"; +import "./invite.ts"; import "./login.ts"; +import "./misc.ts"; import "./notification.ts"; import "./passkey.ts"; import "./poll.ts"; diff --git a/graphql/schema.graphql b/graphql/schema.graphql index c8c72105..c6cbeb53 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -384,6 +384,40 @@ type InvalidInputError { inputPath: String! } +"""An invitation that has been created.""" +type Invitation { + email: Email! + inviter: Account! + locale: Locale! + message: Markdown +} + +enum InviteEmailError { + EMAIL_ALREADY_TAKEN + EMAIL_INVALID +} + +enum InviteInviterError { + INVITER_EMAIL_SEND_FAILED + INVITER_NOT_AUTHENTICATED + INVITER_NO_INVITATIONS_LEFT +} + +union InviteResult = Invitation | InviteValidationErrors + +"""Validation errors that occurred during the invitation process.""" +type InviteValidationErrors { + email: InviteEmailError + emailOwner: Account + inviter: InviteInviterError + verifyUrl: InviteVerifyUrlError +} + +enum InviteVerifyUrlError { + VERIFY_URL_NO_CODE + VERIFY_URL_NO_TOKEN +} + """ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). """ @@ -441,6 +475,16 @@ type Mutation { sessionId: UUID! ): JSON! getPasskeyRegistrationOptions(accountId: ID!): JSON! + invite( + email: Email! + locale: Locale! + message: Markdown + + """ + The RFC 6570-compliant URI Template for the verification link. Available variables: `{token}` and `{code}`. + """ + verifyUrl: URITemplate! + ): InviteResult! loginByEmail( """The email of the account to sign in.""" email: String! @@ -742,6 +786,7 @@ type Query { handle: String! ): Actor actorByUuid(uuid: UUID!): Actor + availableLocales: [Locale!]! codeOfConduct( """The locale for the Code of Conduct.""" locale: Locale! diff --git a/models/i18n.ts b/models/i18n.ts index 28f9395c..d368e7d8 100644 --- a/models/i18n.ts +++ b/models/i18n.ts @@ -155,7 +155,7 @@ export function findNearestLocale( */ export function negotiateLocale( wantedLocale: Intl.Locale | string, - availableLocales: (Intl.Locale | string)[], + availableLocales: readonly (Intl.Locale | string)[], ): Intl.Locale | undefined; /** @@ -166,13 +166,13 @@ export function negotiateLocale( * @returns The best matching locale, or undefined if no match is found. */ export function negotiateLocale( - wantedLocales: (Intl.Locale | string)[], - availableLocales: (Intl.Locale | string)[], + wantedLocales: readonly (Intl.Locale | string)[], + availableLocales: readonly (Intl.Locale | string)[], ): Intl.Locale | undefined; export function negotiateLocale( - wantedLocales: Intl.Locale | string | (Intl.Locale | string)[], - availableLocales: (Intl.Locale | string)[], + wantedLocales: Intl.Locale | string | readonly (Intl.Locale | string)[], + availableLocales: readonly (Intl.Locale | string)[], ): Intl.Locale | undefined { if (availableLocales.length === 0) { return undefined; @@ -186,37 +186,38 @@ export function negotiateLocale( : wantedLocales, ]; - const availables = availableLocales.map((l) => + const availableLocalesNormalized = availableLocales.map((l) => typeof l === "string" ? new Intl.Locale(l) : l ); + const availablesWithMaximized = availableLocalesNormalized.map((raw) => ({ + raw, + max: raw.maximize(), + })); for (const wanted of wantedArray) { const wantedMaximized = wanted.maximize(); // First try exact match - for (const available of availables) { - const availableMaximized = available.maximize(); - if (wantedMaximized.baseName === availableMaximized.baseName) { - return available; + for (const a of availablesWithMaximized) { + if (wantedMaximized.baseName === a.max.baseName) { + return a.raw; } } // Then try language + script match (e.g., zh-Hant) - for (const available of availables) { - const availableMaximized = available.maximize(); + for (const a of availablesWithMaximized) { if ( - wantedMaximized.language === availableMaximized.language && - wantedMaximized.script === availableMaximized.script + wantedMaximized.language === a.max.language && + wantedMaximized.script === a.max.script ) { - return available; + return a.raw; } } // Finally try language-only match - for (const available of availables) { - const availableMaximized = available.maximize(); - if (wantedMaximized.language === availableMaximized.language) { - return available; + for (const a of availablesWithMaximized) { + if (wantedMaximized.language === a.max.language) { + return a.raw; } } } diff --git a/web-next/src/components/LocaleSelect.tsx b/web-next/src/components/LocaleSelect.tsx new file mode 100644 index 00000000..681237e3 --- /dev/null +++ b/web-next/src/components/LocaleSelect.tsx @@ -0,0 +1,118 @@ +import { negotiateLocale } from "@hackerspub/models/i18n"; +import { graphql } from "relay-runtime"; +import { Show } from "solid-js"; +import { createFragment } from "solid-relay"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select.tsx"; +import { useLingui } from "~/lib/i18n/macro.d.ts"; +import { LocaleSelect_availableLocales$key } from "./__generated__/LocaleSelect_availableLocales.graphql.ts"; + +export interface LocaleSelectProps { + readonly $availableLocales: LocaleSelect_availableLocales$key; + readonly value: string; + onChange(value: string): void; + readonly class?: string; +} + +export function LocaleSelect(props: LocaleSelectProps) { + const { i18n } = useLingui(); + const availableLocales = createFragment( + graphql` + fragment LocaleSelect_availableLocales on Query { + availableLocales + } + `, + () => props.$availableLocales, + ); + return ( + + ); +} + +interface LocaleInfo { + readonly code: string; + readonly name: string; + readonly nativeName: string; +} + +function toLocaleInfo(locale: string, currentLocale: string): LocaleInfo { + return mapLocaleInfo([locale], currentLocale)[0]; +} + +function mapLocaleInfo( + locales: readonly string[], + currentLocale: string, +): LocaleInfo[] { + const displayNames = new Intl.DisplayNames(currentLocale, { + type: "language", + }); + const list = locales.map((l) => { + const nativeNames = new Intl.DisplayNames(l, { type: "language" }); + return ({ + code: l, + name: displayNames.of(l) ?? l, + nativeName: nativeNames.of(l) ?? l, + }); + }); + list.sort((a, b) => a.name.localeCompare(b.name)); + return list; +} diff --git a/web-next/src/components/SettingsTabs.tsx b/web-next/src/components/SettingsTabs.tsx index cdde5537..c7d92c18 100644 --- a/web-next/src/components/SettingsTabs.tsx +++ b/web-next/src/components/SettingsTabs.tsx @@ -6,7 +6,7 @@ import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; import { useLingui } from "~/lib/i18n/macro.d.ts"; import type { SettingsTabs_account$key } from "./__generated__/SettingsTabs_account.graphql.ts"; -export type SettingsTab = "profile" | "preferences" | "passkeys"; +export type SettingsTab = "profile" | "preferences" | "invite" | "passkeys"; export interface SettingsTabsProps { selected: SettingsTab; @@ -28,7 +28,7 @@ export function SettingsTabs(props: SettingsTabsProps) { {(account) => ( - + {t`Preferences`} + + {t`Invite`} + + loadQuery( + useRelayEnvironment()(), + invitePageQuery, + { username: handle.replace(/^@/, "") }, + ), + "loadInvitePageQuery", +); + +const inviteMutation = graphql` + mutation inviteMutation( + $email: Email!, + $locale: Locale!, + $message: Markdown, + $verifyUrl: URITemplate! + ) { + invite( + email: $email, + locale: $locale, + message: $message, + verifyUrl: $verifyUrl, + ) { + __typename + ... on Invitation { + inviter { + id + invitationsLeft + } + } + ... on InviteValidationErrors { + account: inviter + email + verifyUrl + emailOwner { + name + handle + username + } + } + } + } +`; + +export default function InvitePage() { + const params = useParams(); + const location = useLocation(); + const { t, i18n } = useLingui(); + const data = createPreloadedQuery( + invitePageQuery, + () => loadInvitePageQuery(params.handle), + ); + const [inviterError, setInviterError] = createSignal< + InviteInviterError | undefined + >(); + const [email, setEmail] = createSignal(""); + const [emailError, setEmailError] = createSignal< + InviteEmailError | undefined + >(); + const [emailOwner, setEmailOwner] = createSignal< + { name: string; handle: string; username: string } | undefined + >(); + const [invitationLanguage, setInvitationLanguage] = createSignal(i18n.locale); + const [message, setMessage] = createSignal(""); + const [send] = createMutation(inviteMutation); + const [sending, setSending] = createSignal(false); + function onSubmit(event: SubmitEvent) { + event.preventDefault(); + setSending(true); + send({ + variables: { + email: email(), + locale: invitationLanguage(), + message: message().trim() === "" ? null : message().trim(), + verifyUrl: `${ + globalThis.location?.origin ?? "https://hackers.pub" + }/sign/up/{token}?code={code}`, + }, + onCompleted({ invite }) { + setSending(false); + if (invite.__typename === "InviteValidationErrors") { + setInviterError(invite.account ?? undefined); + setEmailError(invite.email ?? undefined); + setEmailOwner(invite.emailOwner ?? undefined); + showToast({ + variant: "error", + title: t`Failed to send invitation`, + description: t`Please correct the errors and try again.`, + }); + } else { + setInviterError(undefined); + setEmailError(undefined); + setEmailOwner(undefined); + setEmail(""); + setMessage(""); + showToast({ + title: t`Invitation sent`, + description: t`The invitation has been sent successfully.`, + }); + } + }, + onError(error) { + console.error(error); + setSending(false); + showToast({ + variant: "error", + title: t`Failed to send invitation`, + description: + t`An unexpected error occurred. Please try again later.` + + (import.meta.env.DEV ? `\n\n${error.message}` : ""), + }); + }, + }); + } + + return ( + + {(data) => ( + <> + + } + > + {(viewer) => ( + + {(account) => ( + + + + )} + + )} + + + {(account) => ( + <> + {t`Invite`} + + + + + {t`Settings`} + + + + + + {t`Invite`} + + + +
+
+ +
+

+ 0} + fallback={t`You have no invitations left. Please wait until you receive more.`} + > + {i18n._(msg`${ + plural(account().invitationsLeft, { + one: + "Invite your friends to Hackers' Pub. You can invite up to # person.", + other: + "Invite your friends to Hackers' Pub. You can invite up to # people.", + }) + }`)} + +

+ + + {t`Email address`} + + setEmail(e.currentTarget.value)} + /> + + + + {t`The email address is not only used for receiving the invitation, but also for signing in to the account.`} + + + + + {t`The email address is invalid.`} + + + + + ( + + {emailOwner()?.name}{" "} + + ({emailOwner()?.handle}) + + + ), + }} + /> + + + + +
+ + +

+ {t`Choose the language your friend prefers. This language will only be used for the invitation.`} +

+
+ + + {t`Extra message`} + + setMessage(e.currentTarget.value)} + placeholder={t`You can leave this field empty.`} + /> + + {t`Your friend will see this message in the invitation email.`} + + + + +

+ {t`You have no invitations left. Please wait until you receive more.`} +

+
+
+
+
+ + )} +
+ + )} +
+ ); +} diff --git a/web-next/src/routes/(root)/[handle]/settings/preferences.tsx b/web-next/src/routes/(root)/[handle]/settings/preferences.tsx index e3aa236f..cc499874 100644 --- a/web-next/src/routes/(root)/[handle]/settings/preferences.tsx +++ b/web-next/src/routes/(root)/[handle]/settings/preferences.tsx @@ -202,7 +202,7 @@ export default function PreferencesPage() { defaultChecked={account().preferAiSummary} />
-
-
+
-
+