invites: add /invites/send, store more details in new SentTo field

deprecated SendTo string field for SentTo, which holds successful send
addresses, and failures with a reason that isn't plain text. Will soon
add an interface for sending invites after their creation. For #444
(ha).
This commit is contained in:
Harvey Tindall
2025-12-05 12:03:21 +00:00
parent 3635a13682
commit 5fa528fd2d
19 changed files with 327 additions and 144 deletions

View File

@@ -157,6 +157,90 @@ func (app *appContext) sendAdminExpiryNotification(data Invite) *sync.WaitGroup
return &wait
}
// @Summary Send an existing invite to an email address or discord user.
// @Produce json
// @Param SendInviteDTO body SendInviteDTO true "Email address or Discord username"
// @Success 200 {object} boolResponse
// @Failure 500 {object} stringResponse
// @Router /invites/send [post]
// @Security Bearer
// @tags Invites
func (app *appContext) SendInvite(gc *gin.Context) {
var req SendInviteDTO
gc.BindJSON(&req)
inv, ok := app.storage.GetInvitesKey(req.Invite)
if !ok {
app.err.Printf(lm.FailedGetInvite, req.Invite, lm.NotFound)
respond(500, "Invite not found", gc)
return
}
err := app.sendInvite(req.sendInviteDTO, &inv)
// Even if failed, some error info might have been stored in the invite.
app.storage.SetInvitesKey(req.Invite, inv)
if err != nil {
app.err.Printf(lm.FailedSendInviteMessage, req.Invite, req.SendTo, err)
respond(500, err.Error(), gc)
return
}
app.info.Printf(lm.SentInviteMessage, req.Invite, req.SendTo)
respondBool(200, true, gc)
}
// sendInvite attempts to send an invite to the given email address or discord username.
func (app *appContext) sendInvite(req sendInviteDTO, invite *Invite) (err error) {
if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
// app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, errors.New(lm.InviteMessagesDisabled))
err = errors.New(lm.InviteMessagesDisabled)
return err
}
discord := ""
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: NoUser,
})
err = fmt.Errorf(lm.InvalidAddress, req.SendTo)
return err
} else if len(users) > 1 {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: MultiUser,
})
err = fmt.Errorf(lm.InvalidAddress, req.SendTo)
return err
}
discord = users[0].User.ID
}
var msg *Message
msg, err = app.email.constructInvite(invite, false)
if err != nil {
// Slight misuse of the template
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: CheckLogs,
})
// app.err.Printf(lm.FailedConstructInviteMessage, req.SendTo, err)
return err
}
if discord != "" {
err = app.discord.SendDM(msg, discord)
} else {
err = app.email.send(msg, req.SendTo)
}
if err != nil {
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: req.SendTo,
Reason: CheckLogs,
})
return err
// app.err.Println(invite.SendTo)
}
invite.SentTo.Success = append(invite.SentTo.Success, req.SendTo)
return err
}
// @Summary Create a new invite.
// @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object"
@@ -198,50 +282,13 @@ func (app *appContext) GenerateInvite(gc *gin.Context) {
}
invite.ValidTill = validTill
if req.SendTo != "" {
if !(app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, errors.New(lm.InviteMessagesDisabled))
} else {
addressValid := false
discord := ""
if discordEnabled && (!strings.Contains(req.SendTo, "@") || strings.HasPrefix(req.SendTo, "@")) {
users := app.discord.GetUsers(req.SendTo)
if len(users) == 0 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipNoUser, req.SendTo)
} else if len(users) > 1 {
invite.SendTo = fmt.Sprintf(lm.FailedSendToTooltipMultiUser, req.SendTo)
} else {
invite.SendTo = req.SendTo
addressValid = true
discord = users[0].User.ID
}
} else if emailEnabled {
addressValid = true
invite.SendTo = req.SendTo
}
if addressValid {
msg, err := app.email.constructInvite(invite, false)
err := app.sendInvite(req.sendInviteDTO, &invite)
if err != nil {
// Slight misuse of the template
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, req.SendTo, err)
app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
} else {
var err error
if discord != "" {
err = app.discord.SendDM(msg, discord)
} else {
err = app.email.send(msg, req.SendTo)
}
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
app.err.Println(invite.SendTo)
app.err.Printf(lm.FailedSendInviteMessage, invite.Code, req.SendTo, err)
} else {
app.info.Printf(lm.SentInviteMessage, invite.Code, req.SendTo)
}
}
}
}
}
if req.Profile != "" {
if _, ok := app.storage.GetProfileKey(req.Profile); ok {
invite.Profile = req.Profile
@@ -357,6 +404,9 @@ func (app *appContext) GetInvites(gc *gin.Context) {
if inv.RemainingUses != 0 {
invite.RemainingUses = inv.RemainingUses
}
if len(inv.SentTo.Success) != 0 || len(inv.SentTo.Failed) != 0 {
invite.SentTo = inv.SentTo
}
if inv.SendTo != "" {
invite.SendTo = inv.SendTo
}

View File

@@ -158,7 +158,7 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) {
case "ExpiryReminder":
msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), true)
case "InviteEmail":
msg, err = app.email.constructInvite(Invite{Code: ""}, true)
msg, err = app.email.constructInvite(&Invite{Code: ""}, true)
case "WelcomeEmail":
msg, err = app.email.constructWelcome("", time.Time{}, true)
case "EmailConfirmation":

View File

@@ -738,7 +738,6 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
var invname *dg.Member = nil
invname, err = d.bot.GuildMember(d.guildID, recipient.ID)
invite.SendTo = invname.User.Username
if err == nil && !(d.app.config.Section("invite_emails").Key("enabled").MustBool(false)) {
err = errors.New(lm.InviteMessagesDisabled)
@@ -746,11 +745,14 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
var msg *Message
if err == nil {
msg, err = d.app.email.constructInvite(invite, false)
msg, err = d.app.email.constructInvite(&invite, false)
if err != nil {
// Print extra message, ideally we'd just print this, or get rid of it though.
invite.SendTo = fmt.Sprintf(lm.FailedConstructInviteMessage, invite.Code, err)
d.app.err.Println(invite.SendTo)
invite.SentTo.Failed = append(invite.SentTo.Failed, SendFailure{
Address: invname.User.Username,
Reason: CheckLogs,
})
d.app.err.Printf(lm.FailedConstructInviteMessage, invite.Code, err)
}
}
@@ -760,12 +762,12 @@ func (d *DiscordDaemon) cmdInvite(s *dg.Session, i *dg.InteractionCreate, lang s
if err == nil {
d.app.info.Printf(lm.SentInviteMessage, invite.Code, RenderDiscordUsername(recipient))
invite.SentTo.Success = append(invite.SentTo.Success, invname.User.Username)
sendResponse("sentInvite")
}
if err != nil {
invite.SendTo = fmt.Sprintf(lm.FailedSendInviteMessage, invite.Code, RenderDiscordUsername(recipient), err)
d.app.err.Println(invite.SendTo)
sendResponse("sentInviteFailure")
}
}

View File

@@ -379,7 +379,7 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, placeh
return emailer.construct(contentInfo, cc, template)
}
func (emailer *Emailer) constructInvite(invite Invite, placeholders bool) (*Message, error) {
func (emailer *Emailer) constructInvite(invite *Invite, placeholders bool) (*Message, error) {
expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false)
inviteLink := fmt.Sprintf("%s%s/%s", ExternalURI(nil), PAGES.Form, invite.Code)

View File

@@ -266,18 +266,20 @@
<div id="modal-announce" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-announce" href="">
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="flex flex-col md:flex-row">
<div class="col card ~neutral @low">
<div id="announce-details">
<div class="flex flex-col md:flex-row gap-4">
<div class="card ~neutral @low w-1/2 grow">
<div id="announce-details" class="flex flex-col gap-2">
<span class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</span>
<div id="announce-variables">
<span class="button ~urge @low mb-2 mt-4" id="announce-variables-username" style="margin-left: 0.25rem; margin-right: 0.25rem;"><span class="font-mono bg-inherit">{username}</span></span>
<div id="announce-variables" class="flex flex-row flex-wrap gap-2">
<span class="button ~urge @low" id="announce-variables-username"><span class="font-mono bg-inherit">{username}</span></span>
</div>
<label class="label supra" for="announce-subject"> {{ .strings.subject }}</label>
<input type="text" id="announce-subject" class="input ~neutral @low mb-2 mt-4">
<input type="text" id="announce-subject" class="input ~neutral @low">
<label class="label supra" for="textarea-announce">{{ .strings.message }}</label>
<textarea id="textarea-announce" class="textarea full-width ~neutral @low mt-4 font-mono"></textarea>
<p class="support mt-4 mb-2">{{ .strings.markdownSupported }}</p>
<textarea id="textarea-announce" class="textarea full-width ~neutral @low font-mono"></textarea>
<p class="support">{{ .strings.markdownSupported }}</p>
<p class="support editor-syntax-description">{{ .strings.syntaxDescription }}</p>
</div>
<label class="label unfocused" id="announce-name"><p class="supra">{{ .strings.name }}</p>
<input type="text" class="input ~neutral @low mb-2 mt-4">
@@ -291,9 +293,9 @@
<span class="button ~info @low center supra" id="save-announce">{{ .strings.saveAsTemplate }}</span>
</div>
</div>
<div class="col card ~neutral @low">
<div class="card ~neutral @low flex flex-col gap-2 min-w-lg">
<span class="subheading supra">{{ .strings.preview }}</span>
<div class="mt-8" id="announce-preview"></div>
<div id="announce-preview"></div>
</div>
</div>
</form>
@@ -319,8 +321,8 @@
<div id="modal-editor" class="modal">
<form class="relative mx-auto my-[10%] w-4/5 lg:w-2/3 content card" id="form-editor" href="">
<span class="heading"><span id="header-editor"></span> <span class="modal-close">&times;</span></span>
<div class="row">
<div class="col card ~neutral @low flex flex-col gap-2 justify-between">
<div class="flex flex-col md:flex-row gap-4">
<div class="card ~neutral @low w-1/2 grow flex flex-col gap-2 justify-between">
<div class="flex flex-col gap-2">
<aside class="aside sm ~urge dark:~d_info @low" id="aside-editor"></aside>
<label class="label supra" for="editor-variables" id="label-editor-variables">{{ .strings.variables }}</label>
@@ -329,16 +331,17 @@
<div id="editor-conditionals"></div>
<label class="label supra" for="textarea-editor">{{ .strings.message }}</label>
<textarea id="textarea-editor" class="textarea full-width flex-auto ~neutral @low font-mono"></textarea>
<p class="support">{{ .strings.markdownSupported }}</p>
<p class="support editor-syntax-description">{{ .strings.syntaxDescription }}</p>
</div>
<div class="flex flex-col gap-2">
<p class="support">{{ .strings.markdownSupported }}</p>
<label class="w-full">
<input type="submit" class="unfocused">
<span class="button ~urge @low w-full supra submit">{{ .strings.submit }}</span>
</label>
</div>
</div>
<div class="col card ~neutral @low flex flex-col gap-2">
<div class="card ~neutral @low flex flex-col gap-2 min-w-lg">
<span class="subheading supra">{{ .strings.preview }}</span>
<div id="editor-preview"></div>
</div>

View File

@@ -17,6 +17,7 @@
"warning": "Warning",
"inviteInfiniteUsesWarning": "invites with infinite uses can be used abusively",
"inviteSendToEmail": "Send to",
"sentTo": "Sent to",
"create": "Create",
"apply": "Apply",
"select": "Select",
@@ -223,7 +224,7 @@
"restartRequired": "Restart required",
"required": "Required",
"syntax": "Syntax",
"syntaxDescription": "Variables denoted as {varname}. If statements can evaluate truthfulness (e.g. {if messageAddress}Message sent to {messageAddress}{end}) or make basic comparisons (e.g. {if profile == \"Friends\"}Friend{else if profile != \"Admins\"}User{end})"
"syntaxDescription": "Variables denoted as {variable}. If statements can evaluate truthfulness (e.g. {ifTruth}) or make basic comparisons (e.g. {ifCompare})"
},
"notifications": {
"pathCopied": "Full path copied to clipboard.",
@@ -261,6 +262,7 @@
"errorLoadOmbiUsers": "Failed to load ombi users.",
"errorChangedEmailAddress": "Couldn't change email address of {n}.",
"errorFailureCheckLogs": "Failed (check console/logs)",
"errorCheckLogs": "Check console/logs",
"errorPartialFailureCheckLogs": "Partial failure (check console/logs)",
"errorUserCreated": "Failed to create user {n}.",
"errorSendWelcomeEmail": "Failed to send welcome message (check console/logs)",
@@ -272,7 +274,10 @@
"errorInvalidJSON": "Invalid JSON.",
"updateAvailable": "A new update is available, check settings.",
"noUpdatesAvailable": "No new updates available.",
"runTask": "Triggered task."
"runTask": "Triggered task.",
"errorMultiUser": "Multiple matching users found",
"errorNoUser": "No matching user found",
"errorInvalidAddress": "Invalid address/name"
},
"quantityStrings": {
"modifySettingsFor": {

View File

@@ -43,7 +43,8 @@
"referrals": "Referrals",
"inviteRemainingUses": "Remaining uses",
"internal": "Internal",
"external": "External"
"external": "External",
"failed": "Failed"
},
"notifications": {
"errorLoginBlank": "The username and/or password were left blank.",

View File

@@ -109,9 +109,11 @@ const (
GenerateInvite = "Generating new invite"
FailedGenerateInvite = "Failed to generate new invite: %v"
InvalidInviteCode = "Invalid invite code \"%s\""
FailedGetInvite = "Failed to get invite \"%s\": %v"
FailedSendToTooltipNoUser = "Failed: \"%s\" not found"
FailedSendToTooltipMultiUser = "Failed: \"%s\" linked to multiple users"
InvalidAddress = "invalid address \"%s\""
FailedParseTime = "Failed to parse time value: %v"

View File

@@ -61,7 +61,7 @@ type generateInviteDTO struct {
UserDays int `json:"user-days,omitempty" example:"1"` // Number of days till user expiry
UserHours int `json:"user-hours,omitempty" example:"2"` // Number of hours till user expiry
UserMinutes int `json:"user-minutes,omitempty" example:"3"` // Number of minutes till user expiry
SendTo string `json:"send-to" example:"jeff@jellyf.in"` // Send invite to this address or discord name
sendInviteDTO
MultipleUses bool `json:"multiple-uses" example:"true"` // Allow multiple uses
NoLimit bool `json:"no-limit" example:"false"` // No invite use limit
RemainingUses int `json:"remaining-uses" example:"5"` // Remaining invite uses
@@ -70,6 +70,15 @@ type generateInviteDTO struct {
UserLabel string `json:"user_label,omitempty" example:"Friend"` // Label to apply to users created w/ this invite.
}
type SendInviteDTO struct {
Invite string `json:"invite" example:"slakdaslkdl2342"` // Invite to apply to
sendInviteDTO
}
type sendInviteDTO struct {
SendTo string `json:"send-to" example:"jeff@jellyf.in"` // Send invite to this address or discord name
}
type inviteProfileDTO struct {
Invite string `json:"invite" example:"slakdaslkdl2342"` // Invite to apply to
Profile string `json:"profile" example:"DefaultProfile"` // Profile to use
@@ -121,7 +130,9 @@ type inviteDTO struct {
UsedBy map[string]int64 `json:"used-by,omitempty"` // Users who have used this invite mapped to their creation time in Epoch/Unix time
NoLimit bool `json:"no-limit,omitempty"` // If true, invite can be used any number of times
RemainingUses int `json:"remaining-uses,omitempty"` // Remaining number of uses (if applicable)
SendTo string `json:"send_to,omitempty"` // Email/Discord username the invite was sent to (if applicable)
SendTo string `json:"send_to,omitempty"` // DEPRECATED Email/Discord username the invite was sent to (if applicable)
SentTo SentToList `json:"sent_to,omitempty"` // Email/Discord usernames attempts were made to send this invite to, and a failure reason if failed.
NotifyExpiry bool `json:"notify-expiry,omitempty"` // Whether to notify the requesting user of expiry or not
NotifyCreation bool `json:"notify-creation,omitempty"` // Whether to notify the requesting user of account creation or not
Label string `json:"label,omitempty" example:"For Friends"` // Optional label for the invite

7
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14",
"@hrfee/simpletemplate": "^1.1.0",
"@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0",
"@webcoder49/code-input": "^2.7.2",
@@ -482,6 +483,12 @@
"node": ">=18"
}
},
"node_modules/@hrfee/simpletemplate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@hrfee/simpletemplate/-/simpletemplate-1.1.0.tgz",
"integrity": "sha512-5H4/y7CegE4twstRPip/ms+OGUb+BPyVt+hriEpY88lTWW4I6jxzHFHmz6NDZmVyRa4RVIEVBxvtwk3RXKJVwA==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",

View File

@@ -18,6 +18,7 @@
"homepage": "https://github.com/hrfee/jfa-go#readme",
"dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14",
"@hrfee/simpletemplate": "^1.1.0",
"@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0",
"@webcoder49/code-input": "^2.7.2",

View File

@@ -213,6 +213,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/invites/count/used", app.GetInviteUsedCount)
api.DELETE(p+"/invites", app.DeleteInvite)
api.POST(p+"/invites/profile", app.SetProfile)
api.POST(p+"/invites/send", app.SendInvite)
api.GET(p+"/profiles", app.GetProfiles)
api.GET(p+"/profiles/names", app.GetProfileNames)
api.GET(p+"/profiles/raw/:name", app.GetRawProfile)

View File

@@ -769,6 +769,25 @@ type JellyseerrTemplate struct {
Notifications jellyseerr.NotificationsTemplate `json:"notifications,omitempty"`
}
type SendFailureReason = string
const (
CheckLogs SendFailureReason = "CheckLogs"
NoUser = "NoUser"
MultiUser = "MultiUser"
InvalidAddress = "InvalidAddress"
)
type SendFailure struct {
Address string `json:"address"`
Reason SendFailureReason `json:"reason"`
}
type SentToList struct {
Success []string `json:"success"`
Failed []SendFailure `json:"failed"`
}
type Invite struct {
Code string `badgerhold:"key"`
Created time.Time `json:"created"`
@@ -780,9 +799,9 @@ type Invite struct {
UserDays int `json:"user-days,omitempty"`
UserHours int `json:"user-hours,omitempty"`
UserMinutes int `json:"user-minutes,omitempty"`
SendTo string `json:"email"`
// Used to be stored as formatted time, now as Unix.
UsedBy [][]string `json:"used-by"`
SendTo string `json:"email"` // deprecated: use SentTo now.
SentTo SentToList `json:"sent-to,omitempty"`
UsedBy [][]string `json:"used-by"` // Used to be stored as formatted time, now as Unix.
Notify map[string]map[string]bool `json:"notify"`
Profile string `json:"profile"`
Label string `json:"label,omitempty"`

View File

@@ -2,7 +2,7 @@ import { ThemeManager } from "./modules/theme.js";
import { lang, LangFile, loadLangSelector } from "./modules/lang.js";
import { Modal } from "./modules/modal.js";
import { Tabs, Tab } from "./modules/tabs.js";
import { inviteList, createInvite } from "./modules/invites.js";
import { DOMInviteList, createInvite } from "./modules/invites.js";
import { accountsList } from "./modules/accounts.js";
import { settingsList } from "./modules/settings.js";
import { activityList } from "./modules/activity.js";
@@ -105,7 +105,7 @@ var accounts = new accountsList();
var activity = new activityList();
window.invites = new inviteList();
window.invites = new DOMInviteList();
var settings = new settingsList();

View File

@@ -1247,6 +1247,12 @@ export class accountsList extends PaginatedList {
this._search.showHideSearchOptionsHeader();
this.registerURLListener();
// Get rid of nasty CSS
window.modals.announce.onclose = () => {
const preview = document.getElementById("announce-preview") as HTMLDivElement;
preview.textContent = ``;
}
}
reload = (callback?: (resp: paginatedDTO) => void) => {

View File

@@ -123,20 +123,55 @@ class DOMInvite implements Invite {
} else {
chip.classList.add("button");
chip.parentElement.classList.add("h-full");
if (address.includes("Failed")) {
if (address.includes(window.lang.strings("failed"))) {
icon.classList.remove("ri-mail-line");
icon.classList.add("ri-mail-close-line");
chip.classList.remove("~neutral");
chip.classList.add("~critical");
} else {
address = "Sent to " + address;
icon.classList.remove("ri-mail-close-line");
icon.classList.add("ri-mail-line");
chip.classList.remove("~critical");
chip.classList.add("~neutral");
}
}
tooltip.textContent = address;
// innerHTML as the newer sent_to re-uses this with HTML.
tooltip.innerHTML = address;
}
private _sent_to: SentToList;
get sent_to(): SentToList { return this._sent_to; }
set sent_to(v: SentToList) {
this._sent_to = v;
if (!v || !(v.success || v.failed)) return;
let text = "";
if (v.success && v.success.length > 0) {
text += window.lang.strings("sentTo") + ": " + v.success.join(", ") + " <br>"
}
if (v.failed && v.failed.length > 0) {
text += window.lang.strings("failed") + ": " + v.failed.map((el: SendFailure) => {
let err: string;
switch (el.reason) {
case "CheckLogs":
err = window.lang.notif("errorCheckLogs");
break;
case "NoUser":
err = window.lang.notif("errorNoUser");
break;
case "MultiUser":
err = window.lang.notif("errorMultiUser");
break;
case "InvalidAddress":
err = window.lang.notif("errorInvalidAddress");
break;
default:
err = el.reason;
break;
}
return el.address + " (" + err + ")";
}).join(", ");
}
if (text.length != 0) this.send_to = text;
}
private _usedBy: { [name: string]: number };
@@ -455,6 +490,7 @@ class DOMInvite implements Invite {
this.code = invite.code;
this.created = invite.created;
this.send_to = invite.send_to;
this.sent_to = invite.sent_to;
this.expiresIn = invite.expiresIn;
if (window.notificationsEnabled) {
this.notifyCreation = invite.notifyCreation;
@@ -477,7 +513,7 @@ class DOMInvite implements Invite {
remove = () => { this._container.remove(); }
}
export class inviteList implements inviteList {
export class DOMInviteList implements InviteList {
private _list: HTMLDivElement;
private _empty: boolean;
// since invite reload sends profiles, this event it broadcast so the createInvite object can load them.
@@ -493,7 +529,7 @@ export class inviteList implements inviteList {
};
public static readonly _inviteURLEvent = "invite-url";
registerURLListener = () => document.addEventListener(inviteList._inviteURLEvent, (event: CustomEvent) => {
registerURLListener = () => document.addEventListener(DOMInviteList._inviteURLEvent, (event: CustomEvent) => {
this.focusInvite(event.detail);
})
@@ -587,12 +623,14 @@ export class inviteList implements inviteList {
}));
}
export const inviteURLEvent = (id: string) => { return new CustomEvent(inviteList._inviteURLEvent, {"detail": id}) };
export const inviteURLEvent = (id: string) => { return new CustomEvent(DOMInviteList._inviteURLEvent, {"detail": id}) };
function parseInvite(invite: { [f: string]: string | number | { [name: string]: number } | boolean }): Invite {
// FIXME: Please, i beg you, get rid of this horror!
function parseInvite(invite: { [f: string]: string | number | { [name: string]: number } | boolean | SentToList }): Invite {
let parsed: Invite = {};
parsed.code = invite["code"] as string;
parsed.send_to = invite["send_to"] as string || "";
parsed.sent_to = invite["sent_to"] as SentToList || null;
parsed.label = invite["label"] as string || "";
parsed.user_label = invite["user_label"] as string || "";
let time = "";

View File

@@ -1,4 +1,5 @@
import { _get } from "../modules/common.js";
import { Template } from "@hrfee/simpletemplate";
interface Meta {
name: string;
@@ -39,6 +40,15 @@ export class lang implements Lang {
return str;
}
template = (sect: string, key: string, subs: { [key: string]: any }): string => {
if (sect == "quantityStrings" || sect == "meta") { return ""; }
const map = new Map<string, any>();
for (let key of Object.keys(subs)) { map.set(key, subs[key]); }
const [out, err] = Template(this._lang[sect][key], map);
if (err != null) throw err;
return out;
}
quantity = (key: string, number: number): string => {
if (number == 1) {
return this._lang.quantityStrings[key].singular.replace("{n}", ""+number)

View File

@@ -1626,7 +1626,8 @@ class MessageEditor {
} else {
for (let i = this._templ.conditionals.length-1; i >= 0; i--) {
let ci = i % colors.length;
innerHTML += '<span class="button ~' + colors[ci] +' @low mb-4" style="margin-left: 0.25rem; margin-right: 0.25rem;"></span>'
// FIXME: Store full color strings (with ~) so tailwind sees them.
innerHTML += '<span class="button ~' + colors[ci] +' @low"></span>'
}
this._conditionalsLabel.classList.remove("unfocused");
this._conditionals.innerHTML = innerHTML
@@ -1754,6 +1755,20 @@ class MessageEditor {
}
});
};
const descriptions = document.getElementsByClassName("editor-syntax-description") as HTMLCollectionOf<HTMLParagraphElement>;
for (let el of descriptions) {
el.innerHTML = window.lang.template("strings", "syntaxDescription", {
"variable": `<span class="font-mono font-bold">{varname}</span>`,
"ifTruth": `<span class="font-mono font-bold">{if address}Message sent to {address}{end}</span>`,
"ifCompare": `<span class="font-mono font-bold">{if profile == "Friends"}Friend{else if profile != "Admins"}User{end}</span>`
});
};
// Get rid of nasty CSS
window.modals.editor.onclose = () => {
this._preview.textContent = ``;
}
}
}

View File

@@ -48,7 +48,7 @@ declare interface GlobalWindow extends Window {
transitionEvent: string;
animationEvent: string;
tabs: Tabs;
invites: inviteList;
invites: InviteList;
notifications: NotificationBox;
language: string;
lang: Lang;
@@ -61,6 +61,42 @@ declare interface GlobalWindow extends Window {
loginAppearance: string;
}
declare interface InviteList {
empty: boolean;
invites: { [code: string]: Invite }
add: (invite: Invite) => void;
reload: (callback?: () => void) => void;
isInviteURL: () => boolean;
loadInviteURL: () => void;
}
declare interface Invite {
code?: string;
expiresIn?: string;
remainingUses?: string;
send_to?: string; // DEPRECATED: use sent_to instead.
sent_to?: SentToList;
usedBy?: { [name: string]: number };
created?: number;
notifyExpiry?: boolean;
notifyCreation?: boolean;
profile?: string;
label?: string;
user_label?: string;
userExpiry?: boolean;
userExpiryTime?: string;
}
declare interface SendFailure {
address: string;
reason: "CheckLogs" | "NoUser" | "MultiUser" | "InvalidAddress";
}
declare interface SentToList {
success: string[];
failed: SendFailure[];
}
declare interface Update {
version: string;
commit: string;
@@ -83,6 +119,7 @@ declare interface Lang {
strings: (key: string) => string;
notif: (key: string) => string;
var: (sect: string, key: string, ...subs: string[]) => string;
template: (sect: string, key: string, subs: { [key: string]: string }) => string;
quantity: (key: string, number: number) => string;
}
@@ -131,31 +168,6 @@ declare interface Modals {
backups?: Modal;
}
interface Invite {
code?: string;
expiresIn?: string;
remainingUses?: string;
send_to?: string;
usedBy?: { [name: string]: number };
created?: number;
notifyExpiry?: boolean;
notifyCreation?: boolean;
profile?: string;
label?: string;
user_label?: string;
userExpiry?: boolean;
userExpiryTime?: string;
}
interface inviteList {
empty: boolean;
invites: { [code: string]: Invite }
add: (invite: Invite) => void;
reload: (callback?: () => void) => void;
isInviteURL: () => boolean;
loadInviteURL: () => void;
}
interface paginatedDTO {
last_page: boolean;
}