accounts: add templates for announcements

you can now save announcements as templates, and then use them later by
hovering over the "Announce" button, as well as delete them.
mautrix
Harvey Tindall 3 years ago
parent 35f0fead53
commit 3e55cd1e31
No known key found for this signature in database
GPG Key ID: BBC65952848FB1A2

@ -820,6 +820,82 @@ func (app *appContext) Announce(gc *gin.Context) {
respondBool(200, true, gc)
}
// @Summary Save an announcement as a template for use or editing later.
// @Produce json
// @Param announcementTemplate body announcementTemplate true "Announcement request object"
// @Success 200 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Router /users/announce/template [post]
// @Security Bearer
// @tags Users
func (app *appContext) SaveAnnounceTemplate(gc *gin.Context) {
var req announcementTemplate
gc.BindJSON(&req)
if !messagesEnabled {
respondBool(400, false, gc)
return
}
app.storage.announcements[req.Name] = req
if err := app.storage.storeAnnouncements(); err != nil {
respondBool(500, false, gc)
app.err.Printf("Failed to store announcement templates: %v", err)
return
}
respondBool(200, true, gc)
}
// @Summary Save an announcement as a template for use or editing later.
// @Produce json
// @Success 200 {object} getAnnouncementsDTO
// @Router /users/announce/template [get]
// @Security Bearer
// @tags Users
func (app *appContext) GetAnnounceTemplates(gc *gin.Context) {
resp := &getAnnouncementsDTO{make([]string, len(app.storage.announcements))}
i := 0
for name := range app.storage.announcements {
resp.Announcements[i] = name
i++
}
gc.JSON(200, resp)
}
// @Summary Get an announcement template.
// @Produce json
// @Success 200 {object} announcementTemplate
// @Failure 400 {object} boolResponse
// @Param name path string true "name of template"
// @Router /users/announce/template/{name} [get]
// @Security Bearer
// @tags Users
func (app *appContext) GetAnnounceTemplate(gc *gin.Context) {
name := gc.Param("name")
if announcement, ok := app.storage.announcements[name]; ok {
gc.JSON(200, announcement)
return
}
respondBool(400, false, gc)
}
// @Summary Delete an announcement template.
// @Produce json
// @Success 200 {object} boolResponse
// @Failure 500 {object} boolResponse
// @Param name path string true "name of template"
// @Router /users/announce/template/{name} [delete]
// @Security Bearer
// @tags Users
func (app *appContext) DeleteAnnounceTemplate(gc *gin.Context) {
name := gc.Param("name")
delete(app.storage.announcements, name)
if err := app.storage.storeAnnouncements(); err != nil {
respondBool(500, false, gc)
app.err.Printf("Failed to store announcement templates: %v", err)
return
}
respondBool(200, false, gc)
}
// @Summary Create a new invite.
// @Produce json
// @Param generateInviteDTO body generateInviteDTO true "New invite request object"

@ -44,7 +44,7 @@ func (app *appContext) loadConfig() error {
key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json"))))
}
}
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users"} {
for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users", "discord_users", "matrix_users", "announcements"} {
app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json"))))
}
app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/")

@ -1331,6 +1331,14 @@
"type": "text",
"value": "",
"description": "Stores discord user IDs and language preferences."
},
"announcements": {
"name": "Announcement templates",
"required": false,
"requires_restart": false,
"type": "text",
"value": "",
"description": "Stores custom announcement templates."
}
}
}

@ -134,6 +134,10 @@ div.card:contains(section.banner.footer) {
width: 100%;
}
.h-100 {
height: 100%;
}
.inline-block {
display: inline-block;
}

@ -163,15 +163,24 @@
<span class="heading"><span id="header-announce"></span> <span class="modal-close">&times;</span></span>
<div class="row">
<div class="col flex-col content mt-half">
<label class="label supra" for="announce-subject"> {{ .strings.subject }}</label>
<input type="text" id="announce-subject" class="input ~neutral !normal mb-1 mt-half">
<label class="label supra" for="textarea-announce">{{ .strings.message }}</label>
<textarea id="textarea-announce" class="textarea full-width ~neutral !normal mt-half monospace"></textarea>
<p class="support mt-half mb-1">{{ .strings.markdownSupported }}</p>
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal full-width center supra submit">{{ .strings.submit }}</span>
<div id="announce-details">
<label class="label supra" for="announce-subject"> {{ .strings.subject }}</label>
<input type="text" id="announce-subject" class="input ~neutral !normal mb-1 mt-half">
<label class="label supra" for="textarea-announce">{{ .strings.message }}</label>
<textarea id="textarea-announce" class="textarea full-width ~neutral !normal mt-half monospace"></textarea>
<p class="support mt-half mb-1">{{ .strings.markdownSupported }}</p>
</div>
<label class="label unfocused" id="announce-name"><p class="supra">{{ .strings.name }}</p>
<input type="text" class="input ~neutral !normal mb-1 mt-half">
<p class="support">{{ .strings.templateEnterName }}</p>
</label>
<div class="row flex-expand">
<label>
<input type="submit" class="unfocused">
<span class="button ~urge !normal center supra submit">{{ .strings.send }}</span>
</label>
<span class="button ~info !normal center supra" id="save-announce">{{ .strings.saveAsTemplate }}</span>
</div>
</div>
<div class="col card ~neutral !low">
<span class="subheading supra">{{ .strings.preview }}</span>
@ -542,7 +551,15 @@
</div>
<div class="row">
<span class="col sm button ~neutral !normal center mb-half" id="accounts-add-user">{{ .quantityStrings.addUser.Singular }}</span>
<span class="col sm button ~info !normal center mb-half" id="accounts-announce">{{ .strings.announce }}</span>
<div id="accounts-announce-dropdown" class="col sm dropdown" tabindex="0">
<span class="h-100 sm button ~info !normal center mb-half" id="accounts-announce">{{ .strings.announce }}</span>
<div class="dropdown-display">
<div class="card ~neutral !low">
<span class="supra sm">{{ .strings.templates }}</span>
<div id="accounts-announce-templates"></div>
</div>
</div>
</div>
<span class="col sm button ~urge !normal center mb-half" id="accounts-modify-user">{{ .strings.modifySettings }}</span>
<span class="col sm button ~warning !normal center mb-half" id="accounts-extend-expiry">{{ .strings.extendExpiry }}</span>
<span class="col sm button ~positive !normal center mb-half" id="accounts-disable-enable">{{ .strings.disable }}</span>

@ -48,6 +48,7 @@
"unknown": "Unknown",
"label": "Label",
"announce": "Announce",
"templates": "Templates",
"subject": "Subject",
"message": "Message",
"variables": "Variables",
@ -100,7 +101,10 @@
"searchDiscordUser": "Start typing the Discord username to find the user.",
"findDiscordUser": "Find Discord user",
"linkMatrixDescription": "Enter the username and password of the user to use as a bot. Once submitted, the app will restart.",
"matrixHomeServer": "Home server address"
"matrixHomeServer": "Home server address",
"saveAsTemplate": "Save as template",
"deleteTemplate": "Delete template",
"templateEnterName": "Enter a name to save this template."
},
"notifications": {
"changedEmailAddress": "Changed email address of {n}.",
@ -109,6 +113,7 @@
"saveSettings": "Settings were saved",
"saveEmail": "Email saved.",
"sentAnnouncement": "Announcement sent.",
"savedAnnouncement": "Announcement saved.",
"setOmbiDefaults": "Stored ombi defaults.",
"updateApplied": "Update applied, please restart.",
"updateAppliedRefresh": "Update applied, please refresh.",

@ -359,6 +359,10 @@ func start(asDaemon, firstCall bool) {
if err := app.storage.loadMatrixUsers(); err != nil {
app.err.Printf("Failed to load Matrix users: %v", err)
}
app.storage.announcements_path = app.config.Section("files").Key("announcements").String()
if err := app.storage.loadAnnouncements(); err != nil {
app.err.Printf("Failed to load announcement templates: %v", err)
}
app.storage.profiles_path = app.config.Section("files").Key("user_profiles").String()
app.storage.loadProfiles()

@ -172,6 +172,16 @@ type announcementDTO struct {
Message string `json:"message"` // Email content (markdown supported)
}
type announcementTemplate struct {
Name string `json:"name"` // Name of template
Subject string `json:"subject"` // Email subject
Message string `json:"message"` // Email content (markdown supported)
}
type getAnnouncementsDTO struct {
Announcements []string `json:"announcements"` // list of announcement names.
}
type errorListDTO map[string]map[string]string
type configDTO map[string]interface{}

@ -163,6 +163,12 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
// api.POST(p + "/setDefaults", app.SetDefaults)
api.POST(p+"/users/settings", app.ApplySettings)
api.POST(p+"/users/announce", app.Announce)
api.GET(p+"/users/announce", app.GetAnnounceTemplates)
api.POST(p+"/users/announce/template", app.SaveAnnounceTemplate)
api.GET(p+"/users/announce/:name", app.GetAnnounceTemplate)
api.DELETE(p+"/users/announce/:name", app.DeleteAnnounceTemplate)
api.GET(p+"/config/update", app.CheckUpdate)
api.POST(p+"/config/update", app.ApplyUpdate)
api.GET(p+"/config/emails", app.GetCustomEmails)

@ -15,22 +15,23 @@ import (
)
type Storage struct {
timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path string
users map[string]time.Time
invites Invites
profiles map[string]Profile
defaultProfile string
displayprefs, ombi_template map[string]interface{}
emails map[string]EmailAddress
telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users.
discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users.
matrix map[string]MatrixUser // Map of Jellyfin user IDs to Matrix users.
customEmails customEmails
policy mediabrowser.Policy
configuration mediabrowser.Configuration
lang Lang
invitesLock, usersLock sync.Mutex
timePattern string
invite_path, emails_path, policy_path, configuration_path, displayprefs_path, ombi_path, profiles_path, customEmails_path, users_path, telegram_path, discord_path, matrix_path, announcements_path string
users map[string]time.Time
invites Invites
profiles map[string]Profile
defaultProfile string
displayprefs, ombi_template map[string]interface{}
emails map[string]EmailAddress
telegram map[string]TelegramUser // Map of Jellyfin User IDs to telegram users.
discord map[string]DiscordUser // Map of Jellyfin user IDs to discord users.
matrix map[string]MatrixUser // Map of Jellyfin user IDs to Matrix users.
customEmails customEmails
policy mediabrowser.Policy
configuration mediabrowser.Configuration
lang Lang
announcements map[string]announcementTemplate
invitesLock, usersLock sync.Mutex
}
type TelegramUser struct {
@ -839,6 +840,14 @@ func (st *Storage) storeOmbiTemplate() error {
return storeJSON(st.ombi_path, st.ombi_template)
}
func (st *Storage) loadAnnouncements() error {
return loadJSON(st.announcements_path, &st.announcements)
}
func (st *Storage) storeAnnouncements() error {
return storeJSON(st.announcements_path, st.announcements)
}
func (st *Storage) loadProfiles() error {
err := loadJSON(st.profiles_path, &st.profiles)
for name, profile := range st.profiles {

@ -26,7 +26,13 @@ interface getPinResponse {
token: string;
username: string;
}
interface announcementTemplate {
name: string;
subject: string;
message: string;
}
var addDiscord: (passData: string) => void;
class user implements User {
@ -547,6 +553,8 @@ export class accountsList {
private _addUserButton = document.getElementById("accounts-add-user") as HTMLSpanElement;
private _announceButton = document.getElementById("accounts-announce") as HTMLSpanElement;
private _announceSaveButton = document.getElementById("save-announce") as HTMLSpanElement;
private _announceNameLabel = document.getElementById("announce-name") as HTMLLabelElement;
private _announcePreview: HTMLElement;
private _previewLoaded = false;
private _announceTextarea = document.getElementById("textarea-announce") as HTMLTextAreaElement;
@ -799,16 +807,60 @@ export class accountsList {
this._announcePreview.innerHTML = content;
}
}
announce = () => {
saveAnnouncement = (event: Event) => {
event.preventDefault();
const form = document.getElementById("form-announce") as HTMLFormElement;
const button = form.querySelector("span.submit") as HTMLSpanElement;
if (this._announceNameLabel.classList.contains("unfocused")) {
this._announceNameLabel.classList.remove("unfocused");
form.onsubmit = this.saveAnnouncement;
button.textContent = window.lang.get("strings", "saveAsTemplate");
this._announceSaveButton.classList.add("unfocused");
const details = document.getElementById("announce-details");
details.classList.add("unfocused");
return;
}
const name = (this._announceNameLabel.querySelector("input") as HTMLInputElement).value;
if (!name) { return; }
const subject = document.getElementById("announce-subject") as HTMLInputElement;
let send: announcementTemplate = {
name: name,
subject: subject.value,
message: this._announceTextarea.value
}
_post("/users/announce/template", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
this.reload();
toggleLoader(button);
window.modals.announce.close();
if (req.status != 200 && req.status != 204) {
window.notifications.customError("announcementError", window.lang.notif("errorFailureCheckLogs"));
} else {
window.notifications.customSuccess("announcementSuccess", window.lang.notif("savedAnnouncement"));
}
}
});
}
announce = (event?: Event, template?: announcementTemplate) => {
const modalHeader = document.getElementById("header-announce");
modalHeader.textContent = window.lang.quantity("announceTo", this._collectUsers().length);
const form = document.getElementById("form-announce") as HTMLFormElement;
let list = this._collectUsers();
const button = form.querySelector("span.submit") as HTMLSpanElement;
removeLoader(button);
button.textContent = window.lang.get("strings", "send");
const details = document.getElementById("announce-details");
details.classList.remove("unfocused");
this._announceSaveButton.classList.remove("unfocused");
const subject = document.getElementById("announce-subject") as HTMLInputElement;
subject.value = "";
this._announceTextarea.value = "";
this._announceNameLabel.classList.add("unfocused");
if (template) {
subject.value = template.subject;
this._announceTextarea.value = template.message;
} else {
subject.value = "";
this._announceTextarea.value = "";
}
form.onsubmit = (event: Event) => {
event.preventDefault();
toggleLoader(button);
@ -853,7 +905,53 @@ export class accountsList {
}
});
}
loadTemplates = () => _get("/users/announce", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
this._announceButton.nextElementSibling.children[0].classList.add("unfocused");
return;
}
this._announceButton.nextElementSibling.children[0].classList.remove("unfocused");
const list = req.response["announcements"] as string[];
if (list.length == 0) {
this._announceButton.nextElementSibling.children[0].classList.add("unfocused");
return;
}
const dList = document.getElementById("accounts-announce-templates") as HTMLDivElement;
dList.textContent = '';
for (let name of list) {
const el = document.createElement("div") as HTMLDivElement;
el.classList.add("flex-expand", "ellipsis", "mt-half");
el.innerHTML = `
<span class="button ~neutral sm full-width accounts-announce-template-button">${name}</span><span class="button ~critical fr ml-1 accounts-announce-template-delete">&times;</span>
`;
(el.querySelector("span.accounts-announce-template-button") as HTMLSpanElement).onclick = () => {
_get("/users/announce/" + name, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
let template: announcementTemplate;
if (req.status != 200) {
window.notifications.customError("getTemplateError", window.lang.notif("errorFailureCheckLogs"));
} else {
template = req.response;
}
this.announce(null, template);
}
});
};
(el.querySelector("span.accounts-announce-template-delete") as HTMLSpanElement).onclick = () => {
_delete("/users/announce/" + name, null, (req: XMLHttpRequest) => {
if (req.readyState == 4) {
if (req.status != 200) {
window.notifications.customError("deleteTemplateError", window.lang.notif("errorFailureCheckLogs"));
}
this.reload();
}
});
};
dList.appendChild(el);
}
}
});
enableDisableUsers = () => {
// We can share the delete modal for this
const modalHeader = document.getElementById("header-delete-user");
@ -1154,26 +1252,31 @@ export class accountsList {
}
});
});
this._announceSaveButton.onclick = this.saveAnnouncement;
}
reload = () => _get("/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4 && req.status == 200) {
// same method as inviteList.reload()
let accountsOnDOM: { [id: string]: boolean } = {};
for (let id in this._users) { accountsOnDOM[id] = true; }
for (let u of (req.response["users"] as User[])) {
if (u.id in this._users) {
this._users[u.id].update(u);
delete accountsOnDOM[u.id];
} else {
this.add(u);
reload = () => {
_get("/users", null, (req: XMLHttpRequest) => {
if (req.readyState == 4 && req.status == 200) {
// same method as inviteList.reload()
let accountsOnDOM: { [id: string]: boolean } = {};
for (let id in this._users) { accountsOnDOM[id] = true; }
for (let u of (req.response["users"] as User[])) {
if (u.id in this._users) {
this._users[u.id].update(u);
delete accountsOnDOM[u.id];
} else {
this.add(u);
}
}
for (let id in accountsOnDOM) {
this._users[id].remove();
delete this._users[id];
}
this._checkCheckCount();
}
for (let id in accountsOnDOM) {
this._users[id].remove();
delete this._users[id];
}
this._checkCheckCount();
}
})
});
this.loadTemplates();
}
}

Loading…
Cancel
Save