implement email editor w/ live(?) preview

not accessible in the ui currently, but the object is available as
window.ee for testing.
This commit is contained in:
Harvey Tindall 2021-02-20 22:49:59 +00:00
parent 6ffdd4dad7
commit 058cac2e7b
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
11 changed files with 285 additions and 40 deletions

92
api.go
View File

@ -1375,6 +1375,84 @@ func (app *appContext) SetEmailState(gc *gin.Context) {
respondBool(200, true, gc)
}
// @Summary Render and return an email for testing purposes.
// @Produce json
// @Param customEmail body customEmail true "Content = email (in markdown)."
// @Success 200 {object} Email
// @Failure 400 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /config/emails/{id}/test [post]
// @tags Configuration
func (app *appContext) GetTestEmail(gc *gin.Context) {
var req customEmail
gc.BindJSON(&req)
if req.Content == "" {
app.debug.Println("Test failed: Content was empty")
respondBool(400, false, gc)
return
}
id := gc.Param("id")
var msg *Email
var err error
var cache customEmail
var restore func(cache customEmail)
username := app.storage.lang.Email[app.storage.lang.chosenEmailLang].Strings.get("username")
emailAddress := app.storage.lang.Email[app.storage.lang.chosenEmailLang].Strings.get("emailAddress")
if id == "UserCreated" {
cache = app.storage.customEmails.UserCreated
restore = func(cache customEmail) { app.storage.customEmails.UserCreated = cache }
app.storage.customEmails.UserCreated.Content = req.Content
app.storage.customEmails.UserCreated.Enabled = true
msg, err = app.email.constructCreated("xxxxxx", username, emailAddress, Invite{}, app, false)
} else if id == "InviteExpiry" {
cache = app.storage.customEmails.InviteExpiry
restore = func(cache customEmail) { app.storage.customEmails.InviteExpiry = cache }
app.storage.customEmails.InviteExpiry.Content = req.Content
app.storage.customEmails.InviteExpiry.Enabled = true
msg, err = app.email.constructExpiry("xxxxxx", Invite{}, app, false)
} else if id == "PasswordReset" {
cache = app.storage.customEmails.PasswordReset
restore = func(cache customEmail) { app.storage.customEmails.PasswordReset = cache }
app.storage.customEmails.PasswordReset.Content = req.Content
app.storage.customEmails.PasswordReset.Enabled = true
msg, err = app.email.constructReset(PasswordReset{Pin: "12-34-56", Username: username}, app, false)
} else if id == "UserDeleted" {
cache = app.storage.customEmails.UserDeleted
restore = func(cache customEmail) { app.storage.customEmails.UserDeleted = cache }
app.storage.customEmails.UserDeleted.Content = req.Content
app.storage.customEmails.UserDeleted.Enabled = true
msg, err = app.email.constructDeleted(app.storage.lang.Email[app.storage.lang.chosenEmailLang].UserDeleted.get("reason"), app, false)
} else if id == "InviteEmail" {
cache = app.storage.customEmails.InviteEmail
restore = func(cache customEmail) { app.storage.customEmails.InviteEmail = cache }
app.storage.customEmails.InviteEmail.Content = req.Content
app.storage.customEmails.InviteEmail.Enabled = true
msg, err = app.email.constructInvite("xxxxxx", Invite{}, app, false)
} else if id == "WelcomeEmail" {
cache = app.storage.customEmails.WelcomeEmail
restore = func(cache customEmail) { app.storage.customEmails.WelcomeEmail = cache }
app.storage.customEmails.WelcomeEmail.Content = req.Content
app.storage.customEmails.WelcomeEmail.Enabled = true
msg, err = app.email.constructWelcome(username, app, false)
} else if id == "EmailConfirmation" {
cache = app.storage.customEmails.EmailConfirmation
restore = func(cache customEmail) { app.storage.customEmails.EmailConfirmation = cache }
app.storage.customEmails.EmailConfirmation.Content = req.Content
app.storage.customEmails.EmailConfirmation.Enabled = true
msg, err = app.email.constructConfirmation("xxxxxx", username, "xxxxxx", app, false)
} else {
respondBool(400, false, gc)
return
}
restore(cache)
if err != nil {
respondBool(500, false, gc)
app.err.Printf("Failed to construct test email: %s", err)
return
}
gc.JSON(200, msg)
}
// @Summary Returns the boilerplate email and list of used variables in it.
// @Produce json
// @Success 200 {object} customEmail
@ -1395,7 +1473,7 @@ func (app *appContext) GetEmail(gc *gin.Context) {
if content == "" {
newEmail = true
msg, err = app.email.constructCreated("", "", "", Invite{}, app, true)
content = msg.text
content = msg.Text
} else {
variables = app.storage.customEmails.UserCreated.Variables
}
@ -1406,7 +1484,7 @@ func (app *appContext) GetEmail(gc *gin.Context) {
if content == "" {
newEmail = true
msg, err = app.email.constructExpiry("", Invite{}, app, true)
content = msg.text
content = msg.Text
} else {
variables = app.storage.customEmails.InviteExpiry.Variables
}
@ -1417,7 +1495,7 @@ func (app *appContext) GetEmail(gc *gin.Context) {
if content == "" {
newEmail = true
msg, err = app.email.constructReset(PasswordReset{}, app, true)
content = msg.text
content = msg.Text
} else {
variables = app.storage.customEmails.PasswordReset.Variables
}
@ -1428,7 +1506,7 @@ func (app *appContext) GetEmail(gc *gin.Context) {
if content == "" {
newEmail = true
msg, err = app.email.constructDeleted("", app, true)
content = msg.text
content = msg.Text
} else {
variables = app.storage.customEmails.UserDeleted.Variables
}
@ -1439,7 +1517,7 @@ func (app *appContext) GetEmail(gc *gin.Context) {
if content == "" {
newEmail = true
msg, err = app.email.constructInvite("", Invite{}, app, true)
content = msg.text
content = msg.Text
} else {
variables = app.storage.customEmails.InviteEmail.Variables
}
@ -1450,7 +1528,7 @@ func (app *appContext) GetEmail(gc *gin.Context) {
if content == "" {
newEmail = true
msg, err = app.email.constructWelcome("", app, true)
content = msg.text
content = msg.Text
} else {
variables = app.storage.customEmails.WelcomeEmail.Variables
}
@ -1461,7 +1539,7 @@ func (app *appContext) GetEmail(gc *gin.Context) {
if content == "" {
newEmail = true
msg, err = app.email.constructConfirmation("", "", "", app, true)
content = msg.text
content = msg.Text
} else {
variables = app.storage.customEmails.EmailConfirmation.Variables
}

View File

@ -88,6 +88,10 @@ div.card:contains(section.banner.footer) {
margin-left: 0.5rem;
}
.mr-half {
margin-right: 0.5rem;
}
.mr-1 {
margin-right: 1rem;
}

View File

@ -33,14 +33,14 @@ type Mailgun struct {
func (mg *Mailgun) send(fromName, fromAddr string, email *Email, address ...string) error {
message := mg.client.NewMessage(
fmt.Sprintf("%s <%s>", fromName, fromAddr),
email.subject,
email.text,
email.Subject,
email.Text,
)
for _, a := range address {
// Adding variable tells mailgun to do a batch send, so users don't see other recipients.
message.AddRecipientAndVariables(a, map[string]interface{}{"unique_id": a})
}
message.SetHtml(email.html)
message.SetHtml(email.HTML)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
_, _, err := mg.client.Send(ctx, message)
@ -69,10 +69,10 @@ func (sm *SMTP) send(fromName, fromAddr string, email *Email, address ...string)
go func(addr string) {
defer wg.Done()
e := jEmail.NewEmail()
e.Subject = email.subject
e.Subject = email.Subject
e.From = from
e.Text = []byte(email.text)
e.HTML = []byte(email.html)
e.Text = []byte(email.Text)
e.HTML = []byte(email.HTML)
e.To = []string{addr}
if sm.sslTLS {
err = e.SendWithTLS(server, sm.auth, tlsConfig)
@ -94,8 +94,9 @@ type Emailer struct {
// Email stores content.
type Email struct {
subject string
html, text string
Subject string `json:"subject"`
HTML string `json:"html"`
Text string `json:"text"`
}
func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) {
@ -213,7 +214,7 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string,
func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Email, error) {
email := &Email{
subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")),
Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")),
}
var err error
template := map[string]interface{}{
@ -245,9 +246,9 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
email, err = emailer.constructTemplate(email.subject, content, app)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.html, email.text, err = emailer.construct(app, "email_confirmation", "email_", template)
email.HTML, email.Text, err = emailer.construct(app, "email_confirmation", "email_", template)
}
if err != nil {
return nil, err
@ -256,13 +257,13 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a
}
func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (*Email, error) {
email := &Email{subject: subject}
email := &Email{Subject: subject}
renderer := html.NewRenderer(html.RendererOptions{Flags: html.Smartypants})
html := markdown.ToHTML([]byte(md), nil, renderer)
text := stripMarkdown(md)
message := app.config.Section("email").Key("message").String()
var err error
email.html, email.text, err = emailer.construct(app, "template_email", "email_", map[string]interface{}{
email.HTML, email.Text, err = emailer.construct(app, "template_email", "email_", map[string]interface{}{
"text": template.HTML(html),
"plaintext": text,
"message": message,
@ -275,7 +276,7 @@ func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (
func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Email, error) {
email := &Email{
subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.InviteEmail.get("title")),
}
expiry := invite.ValidTill
d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern)
@ -312,9 +313,9 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
email, err = emailer.constructTemplate(email.subject, content, app)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.html, email.text, err = emailer.construct(app, "invite_emails", "email_", template)
email.HTML, email.Text, err = emailer.construct(app, "invite_emails", "email_", template)
}
if err != nil {
return nil, err
@ -324,7 +325,7 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont
func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Email, error) {
email := &Email{
subject: emailer.lang.InviteExpiry.get("title"),
Subject: emailer.lang.InviteExpiry.get("title"),
}
expiry := app.formatDatetime(invite.ValidTill)
template := map[string]interface{}{
@ -347,9 +348,9 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
email, err = emailer.constructTemplate(email.subject, content, app)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.html, email.text, err = emailer.construct(app, "notifications", "expiry_", template)
email.HTML, email.Text, err = emailer.construct(app, "notifications", "expiry_", template)
}
if err != nil {
return nil, err
@ -359,7 +360,7 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont
func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Email, error) {
email := &Email{
subject: emailer.lang.UserCreated.get("title"),
Subject: emailer.lang.UserCreated.get("title"),
}
template := map[string]interface{}{
"nameString": emailer.lang.Strings.get("name"),
@ -397,9 +398,9 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
email, err = emailer.constructTemplate(email.subject, content, app)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.html, email.text, err = emailer.construct(app, "notifications", "created_", template)
email.HTML, email.Text, err = emailer.construct(app, "notifications", "created_", template)
}
if err != nil {
return nil, err
@ -409,7 +410,7 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Email, error) {
email := &Email{
subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")),
Subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")),
}
d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String()
@ -446,9 +447,9 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
email, err = emailer.constructTemplate(email.subject, content, app)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.html, email.text, err = emailer.construct(app, "password_resets", "email_", template)
email.HTML, email.Text, err = emailer.construct(app, "password_resets", "email_", template)
}
if err != nil {
return nil, err
@ -458,7 +459,7 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub
func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Email, error) {
email := &Email{
subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")),
Subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")),
}
template := map[string]interface{}{
"yourAccountWasDeleted": emailer.lang.UserDeleted.get("yourAccountWasDeleted"),
@ -483,9 +484,9 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub b
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
email, err = emailer.constructTemplate(email.subject, content, app)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.html, email.text, err = emailer.construct(app, "deletion", "email_", template)
email.HTML, email.Text, err = emailer.construct(app, "deletion", "email_", template)
}
if err != nil {
return nil, err
@ -495,7 +496,7 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub b
func (emailer *Emailer) constructWelcome(username string, app *appContext, noSub bool) (*Email, error) {
email := &Email{
subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")),
}
template := map[string]interface{}{
"welcome": emailer.lang.WelcomeEmail.get("welcome"),
@ -523,9 +524,9 @@ func (emailer *Emailer) constructWelcome(username string, app *appContext, noSub
content = strings.ReplaceAll(content, v, replaceWith.(string))
}
}
email, err = emailer.constructTemplate(email.subject, content, app)
email, err = emailer.constructTemplate(email.Subject, content, app)
} else {
email.html, email.text, err = emailer.construct(app, "welcome_email", "email_", template)
email.HTML, email.Text, err = emailer.construct(app, "welcome_email", "email_", template)
}
if err != nil {
return nil, err

View File

@ -109,6 +109,30 @@
</div>
</form>
</div>
<div id="modal-editor" class="modal">
<form class="modal-content wide 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 content mt-half">
<span class="label supra" for="editor-variables">{{ .strings.variables }}</span>
<div id="editor-variables"></div>
<label class="label supra" for="textarea-editor">{{ .strings.message }}</label>
<textarea id="textarea-editor" class="textarea full-width ~neutral !normal mt-half monospace"></textarea>
<p class="support mt-half mb-1">{{ .strings.markdownSupported }}</p>
<div class="flex-row">
<label class="full-width ml-half">
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.submit }}</span>
</label>
</div>
</div>
<div class="col card ~neutral !low">
<span class="subheading supra">{{ .strings.preview }}</span>
<div class="mt-half" id="editor-preview"></div>
</div>
</div>
</form>
</div>
<div id="modal-restart" class="modal">
<div class="modal-content card ~critical !low">
<span class="heading">{{ .strings.settingsRestartRequired }} <span class="modal-close">&times;</span></span>

View File

@ -33,6 +33,8 @@
"announce": "Announce",
"subject": "Email Subject",
"message": "Message",
"variables": "Variables",
"preview": "Preview",
"markdownSupported": "Markdown is supported.",
"modifySettings": "Modify Settings",
"modifySettingsDescription": "Apply settings from an existing profile, or source them directly from a user.",
@ -76,6 +78,7 @@
"userCreated": "User {n} created.",
"createProfile": "Created profile {n}.",
"saveSettings": "Settings were saved",
"saveEmail": "Email saved.",
"sentAnnouncement": "Announcement sent.",
"setOmbiDefaults": "Stored ombi defaults.",
"errorConnection": "Couldn't connect to jfa-go.",
@ -85,6 +88,7 @@
"errorSettingsFailed": "Application failed.",
"errorLoginBlank": "The username and/or password were left blank.",
"errorUnknown": "Unknown error.",
"errorSaveEmail": "Failed to save email.",
"errorBlankFields": "Fields were left blank",
"errorDeleteProfile": "Failed to delete profile {n}",
"errorLoadProfiles": "Failed to load profiles.",

View File

@ -180,3 +180,7 @@ type emailListDTO map[string]string
type emailSetDTO struct {
Content string `json:"content"`
}
type emailTestDTO struct {
Address string `json:"address"`
}

View File

@ -142,7 +142,8 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
api.GET(p+"/config/emails", app.GetEmails)
api.GET(p+"/config/emails/:id", app.GetEmail)
api.POST(p+"/config/emails/:id", app.SetEmail)
api.POST(p+"/config/emails/:id/:state", app.SetEmailState)
api.POST(p+"/config/emails/:id/test", app.GetTestEmail)
api.POST(p+"/config/emails/:id/state/:state", app.SetEmailState)
api.GET(p+"/config", app.GetConfig)
api.POST(p+"/config", app.ModifyConfig)
api.POST(p+"/restart", app.restart)

View File

@ -44,7 +44,9 @@ func stripMarkdown(md string) string {
}
fullLinks := make([]string, len(openSquares))
for i := range openSquares {
fullLinks[i] = md[openSquares[i] : closeBrackets[i]+1]
if openSquares[i] != -1 && closeBrackets[i] != -1 {
fullLinks[i] = md[openSquares[i] : closeBrackets[i]+1]
}
}
for i, _ := range openSquares {
md = strings.Replace(md, fullLinks[i], links[i], 1)

View File

@ -53,6 +53,8 @@ window.availableProfiles = window.availableProfiles || [];
window.modals.addProfile = new Modal(document.getElementById("modal-add-profile"));
window.modals.announce = new Modal(document.getElementById("modal-announce"));
window.modals.editor = new Modal(document.getElementById("modal-editor"));
})();
var inviteCreator = new createInvite();

View File

@ -653,3 +653,127 @@ class ombiDefaults {
});
}
}
interface Email {
subject: string;
html: string;
text: string;
}
interface templateEmail {
content: string;
variables: string[];
}
class EmailEditor {
private _currentID: string;
private _names: { [id: string]: string };
private _content: string;
private _form = document.getElementById("form-editor") as HTMLFormElement;
private _header = document.getElementById("header-editor") as HTMLSpanElement;
private _variables = document.getElementById("editor-variables") as HTMLDivElement;
private _textArea = document.getElementById("textarea-editor") as HTMLTextAreaElement;
private _preview = document.getElementById("editor-preview") as HTMLDivElement;
insert = (textarea: HTMLTextAreaElement, text: string) => { // https://kubyshkin.name/posts/insert-text-into-textarea-at-cursor-position <3
const isSuccess = document.execCommand("insertText", false, text);
// Firefox (non-standard method)
if (!isSuccess && typeof textarea.setRangeText === "function") {
const start = textarea.selectionStart;
textarea.setRangeText(text);
// update cursor to be at the end of insertion
textarea.selectionStart = textarea.selectionEnd = start + text.length;
// Notify any possible listeners of the change
const e = document.createEvent("UIEvent");
e.initEvent("input", true, false);
textarea.dispatchEvent(e);
textarea.focus();
}
}
load = (id: string) => {
this._currentID = id;
this.loadPreview();
_get("/config/emails/" + id, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
window.notifications.customError("loadTemplateError", window.lang.notif("errorFailureCheckLogs"));
return;
}
if (this._names[id] !== undefined) {
this._header.textContent = this._names[id];
}
const templ = req.response as templateEmail;
this._textArea.value = templ.content;
this._content = templ.content;
const colors = ["info", "urge", "positive", "neutral"];
let innerHTML = '';
for (let i = 0; i < templ.variables.length; i++) {
let ci = i % colors.length;
innerHTML += '<span class="button ~' + colors[ci] +' !normal mb-1" style="margin-left: 0.25rem; margin-right: 0.25rem;"></span>'
}
this._variables.innerHTML = innerHTML
const buttons = this._variables.querySelectorAll("span.button") as NodeListOf<HTMLSpanElement>;
for (let i = 0; i < templ.variables.length; i++) {
buttons[i].innerHTML = `<span class="monospace">` + templ.variables[i] + `</span>`;
buttons[i].onclick = () => this.insert(this._textArea, templ.variables[i]);
}
window.modals.editor.show();
}
})
}
loadPreview = () => {
_post("/config/emails/" + this._currentID + "/test", { "content": this._textArea.value }, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
window.notifications.customError("loadTemplateError", window.lang.notif("errorFailureCheckLogs"));
return;
}
this._preview.innerHTML = req.response.html;
}
}, true);
}
constructor() {
_get("/config/emails", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
window.notifications.customError("loadTemplateError", window.lang.notif("errorFailureCheckLogs"));
return;
}
this._names = req.response;
}
});
let timeout: number;
const finishInterval = 1000;
this._textArea.onkeyup = () => {
clearTimeout(timeout);
timeout = setTimeout(this.loadPreview, finishInterval);
};
this._textArea.onkeydown = () => {
clearTimeout(timeout);
};
this._form.onsubmit = (event: Event) => {
if (this._textArea.value == this._content) {
window.modals.editor.close();
return;
}
event.preventDefault()
_post("/config/emails/" + this._currentID, { "content": this._textArea.value }, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
window.modals.editor.close();
if (req.status != 200) {
window.notifications.customError("saveEmailError", window.lang.notif("errorSaveEmail"));
return;
}
window.notifications.customSuccess("saveEmail", window.lang.notif("saveEmail"));
}
});
};
}
}
(window as any).ee = () => { (window as any).ee = new EmailEditor(); };

View File

@ -75,6 +75,7 @@ declare interface Modals {
profiles: Modal;
addProfile: Modal;
announce: Modal;
editor: Modal;
}
interface Invite {