Merge branch '1.5.x' of https://github.com/appwrite/console into fix-command-center

This commit is contained in:
Christy Jacob
2024-03-01 14:06:47 +00:00
122 changed files with 2998 additions and 1573 deletions
+13 -13
View File
@@ -6,7 +6,7 @@
"": {
"name": "@appwrite/console",
"dependencies": {
"@appwrite.io/console": "^0.6.0-rc.13",
"@appwrite.io/console": "^0.6.0-rc.14",
"@appwrite.io/pink": "0.4.0",
"@appwrite.io/pink-icons": "0.4.0",
"@popperjs/core": "^2.11.8",
@@ -158,9 +158,9 @@
"integrity": "sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg=="
},
"node_modules/@appwrite.io/console": {
"version": "0.6.0-rc.13",
"resolved": "https://registry.npmjs.org/@appwrite.io/console/-/console-0.6.0-rc.13.tgz",
"integrity": "sha512-/c+HGkqdqWjjlzVgOmC6vb4t2jsHgbmX/aaDLoQHXFmaakEDWzK1cgVYRHoU3o14xIEpBupteh3KzOoqdLvvsQ==",
"version": "0.6.0-rc.14",
"resolved": "https://registry.npmjs.org/@appwrite.io/console/-/console-0.6.0-rc.14.tgz",
"integrity": "sha512-7Od0RuGRF01YUCWGTAvlTvgUG7sUCVHG9ig11OKz9jjBxE91BVcWsmqGWiXw+xb4HhF2jiyHd9ZU3sKAo0IC3w==",
"dependencies": {
"cross-fetch": "3.1.5",
"isomorphic-form-data": "2.0.0"
@@ -2336,9 +2336,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz",
"integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.1.tgz",
"integrity": "sha512-TKj08o3mJCoQNLTdRdGkHPePTCPUGTgkew65RDqjVU3MtPVxljsofXQYfXndHfq0P7KoPRO/0/reF6HesU0Djw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@@ -3256,9 +3256,9 @@
}
},
"node_modules/ai": {
"version": "2.2.35",
"resolved": "https://registry.npmjs.org/ai/-/ai-2.2.35.tgz",
"integrity": "sha512-YzR56VAMjqvGIVzMUFwH7jjo7K+Z3Old4nXcigiWsuqXnWQeQi3iPR8p2/xOZzmuZyMTOC+6jjbIJ8hoEOhfcQ==",
"version": "2.2.36",
"resolved": "https://registry.npmjs.org/ai/-/ai-2.2.36.tgz",
"integrity": "sha512-QX3ZXgHjn4cQ233PHne1m7ZP+nfP93CkDEgNCIxuiDp+mhLEIEV10F1Qk3NozJ8mmSoyIJR82ea16kMZKOi8hw==",
"dependencies": {
"eventsource-parser": "1.0.0",
"nanoid": "3.3.6",
@@ -9514,9 +9514,9 @@
}
},
"node_modules/vitest/node_modules/npm-run-path": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz",
"integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"dev": true,
"dependencies": {
"path-key": "^4.0.0"
+4 -4
View File
@@ -12,13 +12,13 @@
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test": "vitest run",
"test:ui": "vitest --ui",
"test:watch": "vitest watch",
"test": "TZ=EST vitest run",
"test:ui": "TZ=EST vitest --ui",
"test:watch": "TZ=EST vitest watch",
"e2e": "playwright test tests/e2e"
},
"dependencies": {
"@appwrite.io/console": "^0.6.0-rc.13",
"@appwrite.io/console": "^0.6.0-rc.14",
"@appwrite.io/pink": "0.4.0",
"@appwrite.io/pink-icons": "0.4.0",
"@popperjs/core": "^2.11.8",
+2 -1
View File
@@ -245,6 +245,7 @@ export enum Submit {
WebhookUpdateUrl = 'submit_webhook_update_url',
WebhookUpdateEvents = 'submit_webhook_update_events',
WebhookUpdateName = 'submit_webhook_update_name',
WebhookUpdateEnabled = 'submit_webhook_update_enabled',
WebhookUpdateSecurity = 'submit_webhook_update_security',
BucketCreate = 'submit_bucket_create',
BucketDelete = 'submit_bucket_delete',
@@ -301,7 +302,7 @@ export enum Submit {
MessagingTopicCreate = 'submit_messaging_topic_create',
MessagingTopicDelete = 'submit_messaging_topic_delete',
MessagingTopicUpdateName = 'submit_messaging_topic_update_name',
MessagingTopicUpdateDescription = 'submit_messaging_topic_update_description',
MessagingTopicUpdatePermissions = 'submit_messaging_topic_update_permissions',
MessagingTopicSubscriberAdd = 'submit_messaging_topic_subscriber_add',
MessagingTopicSubscriberDelete = 'submit_messaging_topic_subscriber_delete'
}
+31 -4
View File
@@ -3,10 +3,11 @@
export let withIndentation = false;
export let open = false;
export let disabled = false;
</script>
<li class="collapsible-item">
<details class="collapsible-wrapper" bind:open>
<details class="collapsible-wrapper" class:is-disabled={disabled} bind:open>
<!-- svelte-ignore a11y-no-redundant-roles -->
<summary
class="collapsible-button u-position-relative"
@@ -16,13 +17,18 @@
tabindex="0">
<slot name="beforetitle" />
<div>
<span class="text"><slot name="title" /></span>
<span class="text" class:u-color-text-disabled={disabled}
><slot name="title" /></span>
{#if $$slots.subtitle}
<span class="collapsible-button-optional"><slot name="subtitle" /></span>
<span class="collapsible-button-optional" class:u-color-text-disabled={disabled}
><slot name="subtitle" /></span>
{/if}
</div>
<div class="icon">
<span class="icon-cheveron-down u-font-size-20" aria-hidden="true" />
<span
class="icon-cheveron-down u-font-size-20"
class:u-color-text-disabled={disabled}
aria-hidden="true" />
</div>
</summary>
<div
@@ -33,3 +39,24 @@
</div>
</details>
</li>
<style lang="scss">
// TODO: remove once pink is updated
.collapsible-item {
.collapsible-wrapper.is-disabled {
&[open] .icon-cheveron-down {
rotate: unset;
}
cursor: not-allowed;
* {
cursor: not-allowed;
}
.collapsible {
&-content {
display: none !important;
}
}
}
}
</style>
+2
View File
@@ -12,6 +12,7 @@
export let childStart = false;
export let noStyle = false;
export let fullWidth = false;
export let wrapperFullWidth = false;
export let fixed = false;
export let display = 'block';
@@ -104,6 +105,7 @@
<div
class:drop-wrapper={!noStyle}
class:u-cross-child-start={childStart}
class:u-width-full-line={wrapperFullWidth}
bind:this={element}
style:display>
<slot />
+11 -1
View File
@@ -11,10 +11,20 @@
export let noStyle = false;
export let width: string = null;
export let fullWidth = false;
export let wrapperFullWidth = false;
export let position: 'relative' | 'static' = 'relative';
</script>
<Drop bind:show {placement} {childStart} {noArrow} {noStyle} {fullWidth} {fixed} on:blur>
<Drop
bind:show
{placement}
{childStart}
{noArrow}
{noStyle}
{fullWidth}
{wrapperFullWidth}
{fixed}
on:blur>
<slot />
<svelte:fragment slot="list">
<div
+2 -1
View File
@@ -9,6 +9,7 @@
export let query = '[]';
export let columns: Writable<Column[]>;
export let fullWidthMobile = false;
const parsedQueries = queryParamToMap(query);
queries.set(parsedQueries);
@@ -55,7 +56,7 @@
</div>
<div class="is-only-mobile">
<Button secondary on:click={() => (showFiltersMobile = !showFiltersMobile)}>
<Button secondary on:click={() => (showFiltersMobile = !showFiltersMobile)} {fullWidthMobile}>
<i class="icon-filter u-opacity-50" />
Filters
{#if applied > 0}
+2 -1
View File
@@ -12,6 +12,7 @@
export let disabled = false;
export let autofocus = false;
export let isWithEndButton = true;
export let fullWidth = false;
let element: HTMLInputElement;
let timer: ReturnType<typeof setTimeout>;
@@ -58,7 +59,7 @@
</script>
<div class="u-flex u-gap-12 common-section u-main-space-between">
<div class="u-flex-basis-50-percent">
<div class={fullWidth ? 'u-width-full-line' : 'u-flex-basis-50-percent'}>
<div class="input-text-wrapper" class:is-with-end-button={isWithEndButton}>
<input
{placeholder}
+15 -8
View File
@@ -16,6 +16,7 @@
export let hideColumns = false;
export let allowNoColumns = false;
export let showColsTextMobile = false;
export let fullWidthMobile = false;
let showSelectColumns = false;
@@ -31,12 +32,15 @@
} else {
const prefs = preferences.get($page.route);
columns.set(
$columns.map((column) => {
column.show = prefs.columns?.includes(column.id) ?? true;
return column;
})
);
// Override the shown columns only if a preference was set
if (prefs?.columns) {
columns.set(
$columns.map((column) => {
column.show = prefs.columns?.includes(column.id) ?? true;
return column;
})
);
}
}
columns.subscribe((ctx) => {
@@ -71,8 +75,11 @@
<div class="grid-header-col-4">
{#if !hideColumns && view === View.Table}
{#if $columns?.length}
<DropList bind:show={showSelectColumns} scrollable>
<Button secondary on:click={() => (showSelectColumns = !showSelectColumns)}>
<DropList bind:show={showSelectColumns} scrollable wrapperFullWidth={fullWidthMobile}>
<Button
secondary
on:click={() => (showSelectColumns = !showSelectColumns)}
{fullWidthMobile}>
<span
class="icon-view-boards u-opacity-50"
aria-hidden="true"
+18 -3
View File
@@ -13,6 +13,8 @@
let element: HTMLOListElement;
let autoSubmitted = false;
const {
elements: { root, input }
} = createPinInput({
@@ -20,14 +22,27 @@
defaultValue: value.split(''),
onValueChange: ({ next }) => {
value = next.join('');
if (value.length === 6 && !autoSubmitted) {
autoSubmitted = true;
const firstInputElement = element.querySelector('input');
firstInputElement?.form.requestSubmit();
}
return next;
}
});
onMount(() => {
if (element && autofocus) {
element.querySelector('input')?.focus();
}
const interval = setInterval(() => {
const input = element.querySelector('input');
if (element) {
if (input && autofocus) {
input.focus();
}
clearInterval(interval);
}
}, 10);
});
</script>
+2 -1
View File
@@ -15,6 +15,7 @@
export let autocomplete = false;
export let maxlength: number = null;
export let isPopoverDefined = true;
export let fullWidth = false;
// https://www.geeksforgeeks.org/how-to-validate-a-domain-name-using-regular-expression/
const pattern = String.raw`(?!-)[A-Za-z0-9\-]+([\-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,18}`;
@@ -49,7 +50,7 @@
}
</script>
<FormItem>
<FormItem {fullWidth}>
<Label {required} hide={!showLabel} for={id}>
{label}{#if $$slots.popover && isPopoverDefined}
<Drop bind:show display="inline-block">
+2 -1
View File
@@ -18,6 +18,7 @@
export let autocomplete = false;
export let tooltip: string = null;
export let isPopoverDefined = true;
export let fullWidth = false;
let element: HTMLInputElement;
let error: string;
@@ -58,7 +59,7 @@
}
</script>
<FormItem>
<FormItem {fullWidth}>
<Label {required} {optionalText} {tooltip} hide={!showLabel} for={id}>
{label}{#if $$slots.popover && isPopoverDefined}
<Drop bind:show display="inline-block">
+2 -1
View File
@@ -17,6 +17,7 @@
export let minlength = 8;
export let maxlength: number = null;
export let isPopoverDefined = true;
export let fullWidth = false;
let element: HTMLInputElement;
let error: string;
@@ -49,7 +50,7 @@
}
</script>
<FormItem>
<FormItem {fullWidth}>
<Label {required} hide={!showLabel} for={id}>
{label}{#if $$slots.popover && isPopoverDefined}
<Drop bind:show={showPopover} display="inline-block">
+2 -1
View File
@@ -15,6 +15,7 @@
export let autocomplete = false;
export let maxlength: number = null;
export let isPopoverDefined = true;
export let fullWidth = false;
const pattern = String.raw`^\+?[1-9]\d{1,14}$`;
@@ -48,7 +49,7 @@
}
</script>
<FormItem>
<FormItem {fullWidth}>
<Label {required} hide={!showLabel} for={id}>
{label}{#if $$slots.popover && isPopoverDefined}
<Drop bind:show display="inline-block">
+32
View File
@@ -41,6 +41,38 @@ export const toLocaleDateTime = (datetime: string | number) => {
return date.toLocaleDateString('en', options);
};
/**
* Returns a date string usig the local timezone in ISO format (yyyy-mm-dd)
*
* @param datetime date string or milliseconds since the epoch
*/
export const toLocaleDateISO = (datetime: string | number) => {
const date = new Date(datetime);
if (isNaN(date.getTime())) {
return 'n/a';
}
// Use Sweden's locale (sv) since it matches ISO format
return date.toLocaleDateString('sv');
};
/**
* Returns a time string usig the local timezone in ISO format (hh:mm:ss)
*
* @param datetime date string or milliseconds since the epoch
*/
export const toLocaleTimeISO = (datetime: string | number) => {
const date = new Date(datetime);
if (isNaN(date.getTime())) {
return 'n/a';
}
// Use Sweden's locale (sv) since it matches ISO format
return date.toLocaleTimeString('sv');
};
export const isSameDay = (date1: Date, date2: Date) => {
return (
date1.getFullYear() === date2.getFullYear() &&
+1 -1
View File
@@ -34,7 +34,7 @@
</script>
<WizardStep beforeSubmit={createDomain}>
<svelte:fragment slot="title">Add function domain</svelte:fragment>
<svelte:fragment slot="title">Domain</svelte:fragment>
<svelte:fragment slot="subtitle">
Use your self-owned domain as the endpoint of your Appwrite API. <a
href="https://appwrite.io/docs/advanced/platform/custom-domains"
+58 -4
View File
@@ -4,11 +4,11 @@ type TemplateRuntime = {
};
const TemplateRuntimes = {
NODE: { name: 'node', versions: ['20.0', '19.0', '18.0', '16.0', '14.5'] },
PHP: { name: 'php', versions: ['8.2', '8.1', '8.0'] },
RUBY: { name: 'ruby', versions: ['3.2', '3.1', '3.0'] },
NODE: { name: 'node', versions: ['21.0', '20.0', '19.0', '18.0', '16.0', '14.5'] },
PHP: { name: 'php', versions: ['8.3', '8.2', '8.1', '8.0'] },
RUBY: { name: 'ruby', versions: ['3.3', '3.2', '3.1', '3.0'] },
PYTHON: { name: 'python', versions: ['3.12', '3.11', '3.10', '3.9', '3.8'] },
DART: { name: 'dart', versions: ['3.1', '3.0', '2.19', '2.18', '2.17', '2.16', '2.16'] },
DART: { name: 'dart', versions: ['3.3', '3.1', '3.0', '2.19', '2.18', '2.17', '2.16', '2.16'] },
BUN: { name: 'bun', versions: ['1.0'] }
};
@@ -515,6 +515,30 @@ export const marketplace: MarketplaceTemplate[] = [
'npm install',
'src/main.js',
'node/sync-with-meilisearch'
),
...getRuntimes(
TemplateRuntimes.PYTHON,
'pip install -r requirements.txt',
'src/main.py',
'python/sync-with-meilisearch'
),
...getRuntimes(
TemplateRuntimes.PHP,
'composer install',
'src/index.php',
'php/sync-with-meilisearch'
),
...getRuntimes(
TemplateRuntimes.BUN,
'bun install',
'src/main.ts',
'bun/sync-with-meilisearch'
),
...getRuntimes(
TemplateRuntimes.RUBY,
'bundle install',
'lib/main.rb',
'ruby/sync-with-meilisearch'
)
],
instructions: `For documentation and instructions check out <a target="_blank" rel="noopener noreferrer" class="link" href="https://github.com/appwrite/templates/tree/main/node/sync-with-meilisearch">file</a>.`,
@@ -599,6 +623,36 @@ export const marketplace: MarketplaceTemplate[] = [
'npm install',
'src/main.js',
'node/whatsapp-with-vonage'
),
...getRuntimes(
TemplateRuntimes.PYTHON,
'pip install -r requirements.txt',
'src/main.py',
'python/whatsapp_with_vonage'
),
...getRuntimes(
TemplateRuntimes.PHP,
'composer install',
'src/index.php',
'php/whatsapp-with-vonage'
),
...getRuntimes(
TemplateRuntimes.DART,
'dart pub get',
'lib/main.dart',
'dart/whatsapp-with-vonage'
),
...getRuntimes(
TemplateRuntimes.RUBY,
'bundle install',
'lib/main.rb',
'ruby/whatsapp-with-vonage'
),
...getRuntimes(
TemplateRuntimes.BUN,
'bun install',
'src/main.ts',
'bun/whatsapp-with-vonage'
)
],
instructions: `For documentation and instructions check out <a target="_blank" rel="noopener noreferrer" class="link" href="https://github.com/appwrite/templates/tree/main/node/whatsapp-with-vonage">file</a>.`,
@@ -5,7 +5,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">Execution permissions</svelte:fragment>
<svelte:fragment slot="title">Permissions</svelte:fragment>
<svelte:fragment slot="subtitle">
Choose who can execute this function using the client API. For more information, visit our
<a
+2 -1
View File
@@ -54,7 +54,8 @@
'/recover',
'/invite',
'/card',
'/hackathon'
'/hackathon',
'/mfa'
];
if ($user) {
if (
@@ -12,7 +12,6 @@
let selectedOrg = organizations.length ? organizations[0].$id : null;
let projects = [] as Models.ProjectList['projects'];
$: hasProjects = projects.length > 0;
let loadingProjects = false;
let projectType = 'existing';
@@ -43,9 +42,7 @@
</script>
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">
{hasProjects ? 'Select project' : 'Create project'}
</svelte:fragment>
<svelte:fragment slot="title">Project</svelte:fragment>
<div class="u-flex u-flex-vertical u-gap-24">
{#if organizations.length > 1}
@@ -7,7 +7,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">Select resources</svelte:fragment>
<svelte:fragment slot="title">Resources</svelte:fragment>
<svelte:fragment slot="subtitle">
Select the resources you need to migrate to Appwrite. Some resources can be migrated, but
with limitations. <a
+36 -5
View File
@@ -13,6 +13,7 @@
export let showSetup = false;
export let showRecoveryCodes = false;
let copyParent: HTMLElement;
let code: string;
let type: Models.MfaType = null;
async function addAuthenticator(): Promise<URL> {
@@ -48,13 +49,43 @@
Install an authenticator app on your mobile device, open it and scan the provided QR
code or enter it manually.
</p>
<div class="qr">
{#await addAuthenticator()}
{#await addAuthenticator()}
<div class="qr">
<LoadingDots />
{:then qr}
</div>
{:then qr}
<div class="qr">
<img alt="MFA QR Code" class="code" src={qr.toString()} />
{/await}
</div>
</div>
<span class="with-separators eyebrow-heading-3">or</span>
<div>
<label class="label" for="manual-code">Manual entry code</label>
<button class="tooltip" aria-label="sec retinfo">
<span class="icon-info" aria-hidden="true"></span>
<span class="tooltip-popup" role="tooltip">
<p class="text u-margin-block-start-8">
Manually enter the following code into the authenticator app
</p>
</span>
</button>
<div class="input-text-wrapper" style="--amount-of-buttons:1">
<input id="manual-code" type="text" value={type.secret} readonly={true} />
<div class="options-list">
<div bind:this={copyParent}>
{#key copyParent}
<Copy value={type.secret} appendTo="parent">
<span class="icon-duplicate" aria-hidden="true" />
</Copy>
{/key}
</div>
</div>
</div>
</div>
{/await}
<hr />
<FormList>
<label for="code">Enter the 6-digit one-time code generated by the app</label>
@@ -38,7 +38,7 @@
</script>
<WizardStep beforeSubmit={validateCoupon}>
<svelte:fragment slot="title">Add credits</svelte:fragment>
<svelte:fragment slot="title">Credits</svelte:fragment>
<svelte:fragment slot="subtitle">
Add Appwrite credits to your organization. A payment method is required before credits can
be applied.
@@ -41,7 +41,7 @@
</script>
<WizardStep beforeSubmit={handleSubmit}>
<svelte:fragment slot="title">Payment details</svelte:fragment>
<svelte:fragment slot="title">Details</svelte:fragment>
<svelte:fragment slot="subtitle">
Add a payment method to your organization. You will not be charged until you upgrade to a
Pro or Scale plan.
@@ -13,7 +13,6 @@
import ChangeOrganizationTierCloud from '../changeOrganizationTierCloud.svelte';
import { goto } from '$app/navigation';
import { last } from '$lib/helpers/array';
import { BillingPlan } from '$lib/constants';
import { trackEvent } from '$lib/actions/analytics';
export let show = false;
@@ -58,25 +57,14 @@
</script>
<Modal bind:show title="Limit reached">
<svelte:fragment slot="title">
Your usage exceeds the {tierToPlan($organization.billingPlan).name} plan limits
</svelte:fragment>
{#if $organization.billingPlan === BillingPlan.STARTER}
<p class="text">
Usage for <b>{$organization.name}</b> organization has reached the limits of the {tierToPlan(
$organization.billingPlan
).name} plan. Consider upgrading to increase your resource usage.
</p>
{:else}
<p class="text">
Usage for <b>{$organization.name}</b> organization has reached the limits of the {tierToPlan(
$organization.billingPlan
).name} plan. Excess usage fees will apply.
</p>
{/if}
<p class="text">
Usage for <b>{$organization.name}</b> organization has reached the limits of the {tierToPlan(
$organization.billingPlan
).name} plan. Excess usage fees will apply.
</p>
<PlanExcess {excess} currentTier={$organization.billingPlan} />
<svelte:fragment slot="footer">
<div class="u-flex u-main-space-between u-width-full-line">
<Button text on:click={() => (show = false)}>Cancel</Button>
@@ -9,7 +9,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">Project Details</svelte:fragment>
<svelte:fragment slot="title">Details</svelte:fragment>
<FormList>
<InputText
label="Name"
@@ -60,6 +60,7 @@
EmailTemplateType,
EmailTemplateLocale
} from '@appwrite.io/console';
import Email2FaTemplate from './email2FATemplate.svelte';
const projectId = $page.params.project;
@@ -68,6 +69,7 @@
$: emailMagicSessionOpen = emailOpen === 'magicSession';
$: emailResetPassword = emailOpen === 'recovery';
$: emailInviteUser = emailOpen === 'invitation';
$: email2FAVerificationOpen = emailOpen === 'mfaChallenge';
// let smsOpen = 'verification';
// $: smsVerificationOpen = smsOpen === 'verification';
@@ -192,6 +194,16 @@
<p class="text">Send an invitation email to become a member of your project.</p>
<EmailInviteTemplate />
</CollapsibleItem>
<CollapsibleItem
bind:open={email2FAVerificationOpen}
on:click={(e) => {
e.preventDefault();
openEmail('mfaChallenge');
}}>
<svelte:fragment slot="title">2FA verification</svelte:fragment>
<p class="text">Send a two-factor authentication email to a user.</p>
<Email2FaTemplate />
</CollapsibleItem>
</Collapsible>
</svelte:fragment>
<svelte:fragment slot="actions">
@@ -0,0 +1,44 @@
<script lang="ts">
import EmailTemplate from './emailTemplate.svelte';
import LocaleOptions from './localeOptions.svelte';
import { loadEmailTemplate } from './+page.svelte';
import { page } from '$app/stores';
import { baseEmailTemplate, emailTemplate } from './store';
import { addNotification } from '$lib/stores/notifications';
import { Id } from '$lib/components';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
const projectId = $page.params.project;
let locale = 'en';
let loading = false;
async function onLocaleChange() {
const timeout = setTimeout(() => {
loading = true;
}, 1000);
try {
const template = await loadEmailTemplate(projectId, 'mfaChallenge', locale);
emailTemplate.set(template);
$baseEmailTemplate = { ...$emailTemplate };
trackEvent(Submit.EmailChangeLocale, { locale, type: 'mfaChallenge' });
} catch (error) {
trackError(error, Submit.EmailChangeLocale);
addNotification({
type: 'error',
message: error.message
});
} finally {
clearTimeout(timeout);
loading = false;
}
}
</script>
<div class="boxes-wrapper u-margin-block-start-16">
<LocaleOptions on:select={onLocaleChange} bind:value={locale} />
<EmailTemplate bind:loading>
<Id value={'{{user}}'}>{'{{user}}'}</Id>
<Id value={'{{project}}'}>{'{{project}}'}</Id>
<Id value={'{{otp}}'}>{'{{otp}}'}</Id>
</EmailTemplate>
</div>
@@ -39,7 +39,6 @@
<div class="boxes-wrapper u-margin-block-start-16">
<LocaleOptions on:select={onLocaleChange} bind:value={locale} />
<EmailTemplate bind:loading>
<Id value={'{{team}}'}>{'{{team}}'}</Id>
<Id value={'{{user}}'}>{'{{user}}'}</Id>
<Id value={'{{project}}'}>{'{{project}}'}</Id>
<Id value={'{{redirect}}'}>{'{{redirect}}'}</Id>
@@ -38,7 +38,6 @@
<div class="boxes-wrapper u-margin-block-start-16">
<LocaleOptions on:select={onLocaleChange} bind:value={locale} />
<EmailTemplate bind:loading>
<Id value={'{{team}}'}>{'{{team}}'}</Id>
<Id value={'{{user}}'}>{'{{user}}'}</Id>
<Id value={'{{project}}'}>{'{{project}}'}</Id>
<Id value={'{{redirect}}'}>{'{{redirect}}'}</Id>
@@ -39,7 +39,6 @@
<LocaleOptions on:select={onLocaleChange} bind:value={locale} />
<EmailTemplate bind:loading>
<Id value={'{{team}}'}>{'{{team}}'}</Id>
<Id value={'{{user}}'}>{'{{user}}'}</Id>
<Id value={'{{project}}'}>{'{{project}}'}</Id>
<Id value={'{{redirect}}'}>{'{{redirect}}'}</Id>
@@ -15,7 +15,7 @@
async function deleteProvider() {
try {
await sdk.forConsole.users.deleteAuthenticator($user.$id, AuthenticatorType.Totp, '');
await sdk.forConsole.users.deleteAuthenticator($user.$id, AuthenticatorType.Totp);
await invalidate(Dependencies.ACCOUNT);
await invalidate(Dependencies.FACTORS);
showDelete = false;
@@ -43,12 +43,8 @@
state="warning"
bind:error
headerDivider={false}>
<p class="u-bold">
Deleting the authentication method will disable multi-factor authentication for this
account. To re-enable it, user will need to add a new authentication method.
</p>
<p>Are you sure you want to delete this authentication method from your account?</p>
<p>Are you sure you want to delete this authentication?</p>
<p class="text u-bold">This action is irreversible.</p>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button secondary submit>Delete</Button>
@@ -1,7 +1,7 @@
<script lang="ts">
import { CardGrid, Heading } from '$lib/components';
import EmptySearch from '$lib/components/emptySearch.svelte';
import { Button } from '$lib/elements/forms';
import { Form, Button, FormList, InputChoice } from '$lib/elements/forms';
import {
Table,
TableBody,
@@ -11,41 +11,94 @@
TableHeader,
TableRow
} from '$lib/elements/table';
import { onMount } from 'svelte';
import DeleteMfa from './deleteMfa.svelte';
import { userFactors } from './store';
import { user } from './store';
import { sdk } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { addNotification } from '$lib/stores/notifications';
import { Dependencies } from '$lib/constants';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
let showDelete = false;
let userMfa: boolean = null;
onMount(async () => {
userMfa ??= $user.mfa;
});
async function updateMfa() {
try {
await sdk.forProject.users.updateMfa($user.$id, userMfa);
await invalidate(Dependencies.USER);
addNotification({
message: `Multi-factor authentication has been ${userMfa ? 'enabled' : 'disabled'}`,
type: 'success'
});
trackEvent(Submit.UserUpdateEmail);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.UserUpdateEmail);
}
}
</script>
<CardGrid>
<Heading tag="h6" size="7">Multi-factor authentication</Heading>
<p class="text">MFA allow users to enhance the security of their account in you app.</p>
<svelte:fragment slot="aside">
{#if $userFactors.totp}
<Table noMargin noStyles transparent>
<TableHeader>
<TableCellHead>Authenticator</TableCellHead>
<TableCellHead width={40} />
</TableHeader>
<TableBody>
<TableRow>
<TableCellText title="Authenticator">TOTP (One-time code)</TableCellText>
<TableCell>
<Button round text ariaLabel="Delete authenticator">
<span class="icon-trash" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<Button disabled text noMargin>
<span class="icon-plus" />
<span class="text">Add authentication factor</span>
</Button>
{:else}
<EmptySearch hidePagination hidePages>
<p class="text u-text-center">
No authenticators have been enabled. Once the user adds an authenticator, youll
see it here.
</p>
</EmptySearch>
{/if}
</svelte:fragment>
</CardGrid>
<Form onSubmit={updateMfa}>
<CardGrid>
<Heading tag="h6" size="7">Multi-factor authentication</Heading>
<p class="text">MFA allow users to enhance the security of their account in you app.</p>
<svelte:fragment slot="aside">
<FormList>
<InputChoice
type="switchbox"
id="mfa"
label="Multi-factor authentication"
bind:value={userMfa} />
</FormList>
{#if $userFactors.totp}
<Table noMargin noStyles transparent>
<TableHeader>
<TableCellHead>Authenticator</TableCellHead>
<TableCellHead width={40} />
</TableHeader>
<TableBody>
<TableRow>
<TableCellText title="Authenticator"
>TOTP (One-time code)</TableCellText>
<TableCell>
<Button
on:click={() => (showDelete = true)}
round
text
ariaLabel="Delete authenticator">
<span class="icon-trash" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<Button disabled text noMargin>
<span class="icon-plus" />
<span class="text">Add authentication factor</span>
</Button>
{:else}
<EmptySearch hidePagination hidePages>
<p class="text u-text-center">
No authenticators have been enabled. Once the user adds an authenticator,
youll see it here.
</p>
</EmptySearch>
{/if}
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={userMfa === $user.mfa} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
<DeleteMfa bind:showDelete />
@@ -5,7 +5,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">Create data</svelte:fragment>
<svelte:fragment slot="title">Data</svelte:fragment>
<svelte:fragment slot="subtitle">
Provide document data based on attributes you created earlier.
</svelte:fragment>
@@ -121,11 +121,7 @@
</Button>
</svelte:fragment>
</Alert>
<InputTextarea
label="Body"
placeholder={`{ "myKey": "myValue" }`}
id="body"
bind:value={body} />
<InputTextarea label="Body" placeholder={`Hello, World!`} id="body" bind:value={body} />
{:else}
<InputText label="Path" id="path" placeholder="/" bind:value={path} required />
@@ -214,7 +210,7 @@
<InputTextarea
label="Body"
placeholder={`{ "myKey": "myValue" }`}
placeholder={`Hello, World!`}
id="body"
bind:value={body} />
</FormList>
@@ -20,7 +20,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">Configuration function</svelte:fragment>
<svelte:fragment slot="title">Configuration</svelte:fragment>
<svelte:fragment slot="subtitle">Create your new Appwrite Function.</svelte:fragment>
<FormList>
<InputText
@@ -27,19 +27,21 @@
TableScroll
} from '$lib/elements/table';
import { toLocaleDateTime } from '$lib/helpers/date';
import { Container } from '$lib/layout';
import { MessageStatus, MessagingProviderType } from '@appwrite.io/console';
import { Container, ContainerHeader } from '$lib/layout';
import { MessagingProviderType } from '@appwrite.io/console';
import type { PageData } from './$types';
import CreateMessageDropdown from './createMessageDropdown.svelte';
import FailedModal from './failedModal.svelte';
import MessageStatusPill from './messageStatusPill.svelte';
import ProviderType from './providerType.svelte';
import { columns, showCreate } from './store';
import { showCreate } from './store';
import { sdk } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import type { Column } from '$lib/helpers/types';
import { writable } from 'svelte/store';
export let data: PageData;
let selected: string[] = [];
@@ -47,9 +49,16 @@
let deleting = false;
let showFailed = false;
let errors: string[] = [];
let showCreateDropdownMobile = false;
let showCreateDropdownDesktop = false;
let showCreateDropdownEmpty = false;
const columns = writable<Column[]>([
{ id: '$id', title: 'Message ID', type: 'string', show: true, width: 140 },
{ id: 'message', title: 'Message', type: 'string', show: false, filter: false, width: 140 },
{ id: 'providerType', title: 'Type', type: 'string', show: true, width: 100 },
{ id: 'status', title: 'Status', type: 'string', show: true, width: 120 },
{ id: 'scheduledAt', title: 'Scheduled at', type: 'datetime', show: true, width: 120 },
{ id: 'deliveredAt', title: 'Delivered at', type: 'datetime', show: false, width: 120 }
]);
const project = $page.params.project;
@@ -82,46 +91,42 @@
</script>
<Container>
<div class="u-flex u-flex-vertical">
<div class="u-flex u-main-space-between">
<Heading tag="h2" size="5">Messages</Heading>
<div class="is-only-mobile">
<CreateMessageDropdown bind:showCreateDropdown={showCreateDropdownMobile} />
</div>
</div>
<!-- TODO: fix width of search input in mobile -->
<ContainerHeader title="Messages">
<CreateMessageDropdown bind:showCreateDropdown={showCreateDropdownDesktop} />
</ContainerHeader>
<div class="u-flex u-flex-vertical u-gap-16 u-margin-block-start-16">
<SearchQuery
fullWidth
search={data.search}
placeholder="Search by message ID, description, type, or status">
<div class="u-flex u-gap-16 is-not-mobile">
<!-- TODO: make this not database-specific -->
<Filters query={data.query} {columns} />
<ViewSelector
view={data.view}
{columns}
hideView
allowNoColumns
showColsTextMobile />
<CreateMessageDropdown bind:showCreateDropdown={showCreateDropdownDesktop} />
<div class="is-not-mobile u-width-full-line">
<div class="u-flex u-gap-16 u-main-end">
<!-- TODO: make this not database-specific -->
<Filters query={data.query} {columns} />
<ViewSelector
view={data.view}
{columns}
hideView
allowNoColumns
showColsTextMobile />
</div>
</div>
</SearchQuery>
<div class="u-flex u-gap-16 is-only-mobile u-margin-block-start-16">
<div class="is-only-mobile u-flex u-gap-16">
<div class="u-flex-basis-50-percent">
<!-- TODO: fix width -->
<ViewSelector
view={data.view}
{columns}
hideView
allowNoColumns
showColsTextMobile />
showColsTextMobile
fullWidthMobile />
</div>
<div class="u-flex-basis-50-percent">
<!-- TODO: fix width -->
<Filters query={data.query} {columns} />
<Filters query={data.query} {columns} fullWidthMobile />
</div>
</div>
</div>
{#if data.messages.total}
<TableScroll>
<TableHeader>
@@ -141,7 +146,7 @@
<TableCellCheck
bind:selectedIds={selected}
id={message.$id}
disabled={message.status === MessageStatus.Processing} />
disabled={message.status === 'processing'} />
{#each $columns as column (column.id)}
{#if column.show}
@@ -0,0 +1,151 @@
<script lang="ts">
import { Wizard } from '$lib/layout';
import type { WizardStepsType } from '$lib/layout/wizard.svelte';
import Step1 from './wizard/step1.svelte';
import Step2 from './wizard/step2.svelte';
import Step3 from './wizard/step3.svelte';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { project } from '../store';
import { wizard } from '$lib/stores/wizard';
import { providerType, messageParams } from './wizard/store';
import { ID, MessagingProviderType, type Models } from '@appwrite.io/console';
async function create() {
try {
let response: Models.Message;
const messageId = $messageParams[$providerType].messageId || ID.unique();
switch ($providerType) {
case MessagingProviderType.Email:
response = await sdk.forProject.messaging.createEmail(
messageId,
$messageParams[$providerType].subject,
$messageParams[$providerType].content,
$messageParams[$providerType].topics,
$messageParams[$providerType].users,
$messageParams[$providerType].targets,
undefined,
undefined,
undefined,
$messageParams[$providerType].draft,
$messageParams[$providerType].html,
$messageParams[$providerType].scheduledAt
);
break;
case MessagingProviderType.Sms:
response = await sdk.forProject.messaging.createSms(
messageId,
$messageParams[$providerType].content,
$messageParams[$providerType].topics,
$messageParams[$providerType].users,
$messageParams[$providerType].targets,
$messageParams[$providerType].draft,
$messageParams[$providerType].scheduledAt
);
break;
case MessagingProviderType.Push:
{
const customData: Record<string, string> = {};
const { data } = $messageParams[MessagingProviderType.Push];
if (data && data.length > 0) {
data.forEach((item) => {
if (item[0] === '') return;
customData[item[0]] = item[1];
});
}
response = await sdk.forProject.messaging.createPush(
messageId,
$messageParams[$providerType].title,
$messageParams[$providerType].body,
$messageParams[$providerType].topics,
$messageParams[$providerType].users,
$messageParams[$providerType].targets,
customData,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
$messageParams[$providerType].draft,
$messageParams[$providerType].scheduledAt
);
}
break;
}
wizard.hide();
let message = '';
switch (response.status) {
case 'draft':
message = 'The message has been saved as draft.';
break;
case 'processing':
message = 'The message is queued for processing.';
break;
case 'scheduled':
message = 'The message has been scheduled.';
break;
}
addNotification({
type: 'success',
message
});
trackEvent(Submit.MessagingMessageCreate, {
providerType: $providerType,
status: response.status
});
await goto(`${base}/console/project-${$project.$id}/messaging/message-${response.$id}`);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingMessageCreate);
}
}
async function saveDraft() {
$messageParams[$providerType].draft = true;
create();
}
const stepsComponents: WizardStepsType = new Map();
stepsComponents.set(1, {
label: 'Message',
component: Step1,
actions: [
{
label: 'Save as draft',
onClick: saveDraft
}
]
});
stepsComponents.set(2, {
label: 'Targets',
component: Step2,
actions: [
{
label: 'Save as draft',
onClick: saveDraft
}
]
});
stepsComponents.set(3, {
label: 'Schedule',
component: Step3,
actions: [
{
label: 'Save as draft',
onClick: saveDraft
}
]
});
</script>
<Wizard title="Create message" steps={stepsComponents} on:finish={create} finalAction="Send" />
@@ -3,8 +3,8 @@
import { Button } from '$lib/elements/forms';
import { wizard } from '$lib/stores/wizard';
import { providers } from './providers/store';
import Wizard from './wizard.svelte';
import { messageParams, operation, providerType, targetsById } from './wizard/store';
import Create from './create.svelte';
import { messageParams, providerType, targetsById } from './wizard/store';
import { topicsById } from './store';
import { MessagingProviderType } from '@appwrite.io/console';
@@ -30,7 +30,6 @@
)
return;
$providerType = type;
$operation = 'create';
$topicsById = {};
$targetsById = {};
const common = {
@@ -62,7 +61,7 @@
break;
}
showCreateDropdown = false;
wizard.start(Wizard);
wizard.start(Create);
}}>
{option.name}
</DropListItem>
@@ -1,118 +1,40 @@
<script lang="ts">
import { Container } from '$lib/layout';
import Delete from './delete.svelte';
import EmailPreview from './emailPreview.svelte';
import EmailMessage from './emailMessage.svelte';
import Overview from './overview.svelte';
import { message } from './store';
import SMSPreview from './smsPreview.svelte';
import PushPreview from './pushPreview.svelte';
import { messageParams, operation, providerType, targetsById } from '../wizard/store';
import { topicsById } from '../store';
import { wizard } from '$lib/stores/wizard';
import Wizard from '../wizard.svelte';
import SMSMessage from './smsMessage.svelte';
import PushMessage from './pushMessage.svelte';
import { providerType } from '../wizard/store';
import type { PageData } from './$types';
import { isValueOfStringEnum } from '$lib/helpers/types';
import { MessageStatus, MessagingProviderType } from '@appwrite.io/console';
import Topics from './topics.svelte';
import Targets from './targets.svelte';
import { MessagingProviderType } from '@appwrite.io/console';
import UpdateTopics from './updateTopics.svelte';
import UpdateTargets from './updateTargets.svelte';
import { onMount } from 'svelte';
export let data: PageData;
async function onEdit() {
if (!isValueOfStringEnum(MessagingProviderType, $message.providerType)) {
throw new Error(`Invalid provider type: ${$message.providerType}`);
onMount(() => {
if (isValueOfStringEnum(MessagingProviderType, $message.providerType)) {
$providerType = $message.providerType;
}
$operation = 'update';
$providerType = $message.providerType;
$topicsById = {};
$targetsById = {};
$topicsById = data.topicsById;
$targetsById = data.targetsById;
$messageParams[$providerType] = {
messageId: $message.$id,
topics: $message.topics,
users: $message.users,
targets: $message.targets,
status: MessageStatus.Draft,
scheduledAt: $message.scheduledAt
};
switch ($providerType) {
case MessagingProviderType.Email:
{
const { data } = $message;
const params = ['subject', 'content', 'html'];
params.forEach((key) => {
if (typeof data[key] !== 'undefined') {
$messageParams[$providerType][key] = data[key];
}
});
}
break;
case MessagingProviderType.Sms:
{
const { data } = $message;
const params = ['content'];
params.forEach((key) => {
if (typeof data[key] !== 'undefined') {
$messageParams[$providerType][key] = data[key];
}
});
}
break;
case MessagingProviderType.Push:
{
const { data } = $message;
const params = [
'title',
'body',
'action',
'icon',
'sound',
'color',
'tag',
'badge'
];
params.forEach((key) => {
if (typeof data[key] !== 'undefined') {
$messageParams[$providerType][key] = data[key];
}
});
const dataEntries: [string, string][] = [];
Object.entries(data['data'] ?? {}).forEach(([key, value]) => {
dataEntries.push([key, value.toString()]);
});
$messageParams[$providerType]['data'] = dataEntries.length
? dataEntries
: [['', '']];
}
break;
}
wizard.start(Wizard);
}
});
</script>
<Container>
<Overview />
<Overview message={$message} topics={Object.values(data.topicsById)} />
{#if $message.providerType === MessagingProviderType.Email}
<EmailPreview
message={$message}
onEdit={$message.status === MessageStatus.Draft ? onEdit : null} />
<EmailMessage message={$message} />
{:else if $message.providerType === MessagingProviderType.Sms}
<SMSPreview
message={$message}
onEdit={$message.status === MessageStatus.Draft ? onEdit : null} />
<SMSMessage message={$message} />
{:else if $message.providerType === MessagingProviderType.Push}
<PushPreview
message={$message}
onEdit={$message.status === MessageStatus.Draft ? onEdit : null} />
<PushMessage message={$message} />
{/if}
<Topics topics={Object.values(data.topicsById)} />
<Targets targets={Object.values(data.targetsById)} usersById={data.usersById} />
{#if $message.status !== MessageStatus.Processing}
<UpdateTopics message={$message} selectedTopicsById={data.topicsById} />
<UpdateTargets message={$message} selectedTargetsById={data.targetsById} />
{#if $message.status !== 'processing'}
<Delete message={$message} />
{/if}
</Container>
@@ -0,0 +1,95 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { MessagingProviderType, type Models } from '@appwrite.io/console';
import { Dependencies } from '$lib/constants';
export let show = false;
export let message: Models.Message & { data: Record<string, unknown> };
const update = async () => {
try {
if (message.providerType == MessagingProviderType.Email) {
await sdk.forProject.messaging.updateEmail(
message.$id,
undefined,
undefined,
undefined,
undefined,
undefined,
true
);
} else if (message.providerType == MessagingProviderType.Sms) {
await sdk.forProject.messaging.updateSms(
message.$id,
undefined,
undefined,
undefined,
undefined,
true
);
} else if (message.providerType == MessagingProviderType.Push) {
await sdk.forProject.messaging.updatePush(
message.$id,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
true
);
}
await invalidate(Dependencies.MESSAGING_MESSAGE);
addNotification({
message: `The scheduling has been cancelled.`,
type: 'success'
});
trackEvent(Submit.MessagingMessageUpdate, {
providerType: message.providerType,
status
});
show = false;
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.MessagingMessageUpdate);
}
};
</script>
<Modal
title="Cancel scheduling"
bind:show
onSubmit={update}
headerDivider={false}
size="small"
icon="exclamation"
state="warning">
<div class="u-flex-vertical u-gap-16">
<p data-private>
Are you sure you want to cancel the scheduling of <span class="u-bold"
>{message.data.title ??
message.data.subject ??
message.data.content ??
'Message'}</span
>?
</p>
</div>
<svelte:fragment slot="footer">
<Button secondary submit>Cancel scheduling</Button>
</svelte:fragment>
</Modal>
@@ -0,0 +1,93 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import { CardGrid, Heading } from '$lib/components';
import { Dependencies } from '$lib/constants';
import {
Button,
Form,
FormList,
InputSwitch,
InputText,
InputTextarea
} from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import type { Models } from '@appwrite.io/console';
import { onMount } from 'svelte';
export let message: Models.Message & { data: Record<string, string> };
let subject = '';
let content = '';
let html = false;
let disabled = true;
onMount(() => {
subject = message.data.subject;
content = message.data.content;
html = (message.data['html'] ?? false) as boolean;
});
async function update() {
try {
await sdk.forProject.messaging.updateEmail(
message.$id,
undefined,
undefined,
undefined,
subject,
content,
undefined,
html
);
await invalidate(Dependencies.MESSAGING_MESSAGE);
addNotification({
message: 'Message has been updated',
type: 'success'
});
trackEvent(Submit.MessagingMessageUpdate);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.MessagingMessageUpdate);
}
}
$: disabled =
subject === message.data.subject &&
content === message.data.content &&
html === ((message.data['html'] ?? false) as boolean);
</script>
<Form onSubmit={update}>
<CardGrid hideFooter={message.status != 'draft'}>
<div class="grid-1-2-col-1 u-flex u-cross-center u-gap-16">
<Heading tag="h6" size="7">Message</Heading>
</div>
<svelte:fragment slot="aside">
<FormList>
<InputText
id="subject"
label="Subject"
disabled={message.status != 'draft'}
bind:value={subject}></InputText>
<InputTextarea
id="message"
label="Message"
disabled={message.status != 'draft'}
bind:value={content}></InputTextarea>
<InputSwitch label="HTML mode" id="html" bind:value={html}>
<svelte:fragment slot="description">
Enable the HTML mode if your message contains HTML tags.
</svelte:fragment>
</InputSwitch>
</FormList>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button {disabled} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,33 +0,0 @@
<script lang="ts">
import { CardGrid, Heading } from '$lib/components';
import { Button, FormList, InputText, InputTextarea } from '$lib/elements/forms';
import type { Models } from '@appwrite.io/console';
export let message: Models.Message & { data: Record<string, string> };
export let onEdit: () => void = null;
</script>
<CardGrid>
<div class="grid-1-2-col-1 u-flex u-cross-center u-gap-16">
<Heading tag="h6" size="7">Preview</Heading>
</div>
<svelte:fragment slot="aside">
<FormList>
<InputText
id="subject"
label="Subject"
disabled={true}
bind:value={message.data.subject}>
</InputText>
<InputTextarea
id="message"
label="Message"
disabled={true}
bind:value={message.data.content}>
</InputTextarea>
<div class="u-flex u-main-end">
<Button secondary disabled={onEdit == null} on:click={onEdit}>Edit message</Button>
</div>
</FormList>
</svelte:fragment>
</CardGrid>
@@ -1,64 +1,77 @@
<script lang="ts">
import { CardGrid, Heading } from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import { message } from './store';
import ProviderType from '../providerType.svelte';
import MessageStatusPill from '../messageStatusPill.svelte';
import { MessagingProviderType } from '@appwrite.io/console';
import type { Models } from '@appwrite.io/console';
import { Button } from '$lib/elements/forms';
import FailedModal from '../failedModal.svelte';
import SendModal from './sendModal.svelte';
import ScheduleModal from './scheduleModal.svelte';
import CancelModal from './cancelModal.svelte';
let scheduledAt: string = '';
export let message: Models.Message & { data: Record<string, string> };
export let topics: Models.Topic[];
let showSend = false;
let showSchedule = false;
let showCancel = false;
let showFailed = false;
let errors: string[] = [];
if ($message.status === 'sent') {
scheduledAt = $message.deliveredAt;
} else if ($message.status === 'scheduled') {
scheduledAt = $message.scheduledAt;
}
let providerType = 'Invalid provider type';
switch ($message.providerType) {
case MessagingProviderType.Email:
providerType = 'Email';
break;
case MessagingProviderType.Sms:
providerType = 'SMS';
break;
case MessagingProviderType.Push:
providerType = 'Push';
break;
}
</script>
<CardGrid hideFooter={$message.status != 'failed'}>
<CardGrid hideFooter={['processing', 'sent'].includes(message.status)}>
<div class="grid-1-2-col-1 u-flex u-cross-center u-gap-16" data-private>
<ProviderType type={$message.providerType} size="l">
<Heading tag="h6" size="7">{providerType}</Heading>
<ProviderType type={message.providerType} size="l">
<Heading tag="h6" size="7">
<ProviderType type={message.providerType} noIcon />
</Heading>
</ProviderType>
</div>
<svelte:fragment slot="aside">
<div class="u-flex u-main-space-between">
<div data-private>
<p class="title">Created: {toLocaleDateTime($message.$createdAt)}</p>
<p class="title">Scheduled at: {toLocaleDateTime(scheduledAt)}</p>
<p class="title">Created: {toLocaleDateTime(message.$createdAt)}</p>
{#if message.scheduledAt}
<p class="title">Scheduled at: {toLocaleDateTime(message.scheduledAt)}</p>
{/if}
{#if message.deliveredAt}
<p class="title">Sent at: {toLocaleDateTime(message.deliveredAt)}</p>
{/if}
</div>
<div class="u-flex u-flex-vertical u-cross-end">
<MessageStatusPill status={$message.status} />
<MessageStatusPill status={message.status} />
</div>
</div>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button
secondary
on:click={(e) => {
e.preventDefault();
errors = $message.deliveryErrors;
showFailed = true;
}}>View logs</Button>
{#if message.status === 'draft'}
<div class="u-flex u-gap-16">
<Button text on:click={() => (showSchedule = true)}>Schedule</Button>
<Button secondary on:click={() => (showSend = true)}>Send message</Button>
</div>
{:else if message.status === 'scheduled'}
<div class="u-flex u-gap-16">
<Button text on:click={() => (showCancel = true)}>Cancel scheduling</Button>
<Button secondary on:click={() => (showSchedule = true)}>Reschedule</Button>
</div>
{:else if message.status === 'failed'}
<Button
secondary
on:click={(e) => {
e.preventDefault();
errors = message.deliveryErrors;
showFailed = true;
}}>View logs</Button>
{/if}
</svelte:fragment>
</CardGrid>
<ScheduleModal bind:show={showSchedule} {message} {topics} />
<SendModal bind:show={showSend} {message} {topics} />
<CancelModal bind:show={showCancel} {message} />
<FailedModal bind:show={showFailed} {errors} />
@@ -0,0 +1,180 @@
<script lang="ts">
import { CardGrid, Heading } from '$lib/components';
import {
Button,
Form,
FormItem,
FormItemPart,
FormList,
Helper,
InputText,
InputTextarea,
Label
} from '$lib/elements/forms';
import type { Models } from '@appwrite.io/console';
import PushPhone from '../pushPhone.svelte';
import { onMount } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { validateData } from '../wizard/pushFormList.svelte';
export let message: Models.Message & { data: Record<string, string> };
let title = '';
let body = '';
let originalCustomData: [string, string][] = [['', '']];
let customData: [string, string][] = [['', '']];
let dataError = '';
let disabled = true;
onMount(() => {
title = message.data.title;
body = message.data.body;
const dataEntries: [string, string][] = [];
Object.entries(message.data['data'] ?? {}).forEach(([key, value]) => {
dataEntries.push([key, value.toString()]);
});
customData = dataEntries.length ? dataEntries : [['', '']];
originalCustomData = structuredClone(customData);
});
async function update() {
try {
const data = customData.reduce((acc, [key, value]) => {
if (key) {
acc[key] = value;
}
return acc;
}, {});
await sdk.forProject.messaging.updatePush(
message.$id,
undefined,
undefined,
undefined,
title,
body,
data
);
originalCustomData = structuredClone(customData);
await invalidate(Dependencies.MESSAGING_MESSAGE);
addNotification({
message: 'Message has been updated',
type: 'success'
});
trackEvent(Submit.MessagingMessageUpdate);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.MessagingMessageUpdate);
}
}
$: dataError = validateData(customData || []);
$: disabled =
title === message.data.title &&
body === message.data.body &&
originalCustomData.length === customData.length &&
originalCustomData.every(
(ocd, i) =>
ocd.length === customData[i].length && ocd.every((v, j) => v === customData[i][j])
);
</script>
<Form onSubmit={update}>
<CardGrid hideFooter={message.status != 'draft'}>
<div class="grid-1-2-col-1 u-flex-vertical u-cross-start u-gap-16">
<Heading tag="h6" size="7">Message</Heading>
<div class="u-flex u-margin-block-start-24 u-width-full-line">
<PushPhone {title} {body} />
</div>
</div>
<svelte:fragment slot="aside">
<FormList>
<InputText
id="title"
label="Title"
disabled={message.status != 'draft'}
bind:value={title}></InputText>
<InputTextarea
id="message"
label="Message"
disabled={message.status != 'draft'}
bind:value={body}></InputTextarea>
<form class="form">
<FormItem>
<Label
tooltip="A key/value payload of additional metadata that's hidden from users. Use this to include information to support logic such as redirection and routing."
>Custom data <span class="u-color-text-gray">(Optional)</span></Label>
</FormItem>
<div class=" u-grid u-gap-8">
<ul class="form-list" style="--p-form-list-gap: 1rem">
{#each customData || [] as _, rowIndex}
<FormItem isMultiple>
<InputText
id={`${rowIndex}-key`}
isMultiple
fullWidth
disabled={message.status != 'draft'}
bind:value={customData[rowIndex][0]}
placeholder="Enter key"
label="Key"
showLabel={false} />
<InputText
id={`${rowIndex}-value`}
isMultiple
fullWidth
disabled={message.status != 'draft'}
bind:value={customData[rowIndex][1]}
placeholder="Enter value"
label="Value"
showLabel={false}
required />
<FormItemPart alignEnd>
<Button
text
disabled={message.status != 'draft'}
on:click={() => {
if (customData.length === 1) {
customData = [['', '']];
return;
}
customData = customData.filter(
(_, i) => i !== rowIndex
);
}}>
<span class="icon-x" aria-hidden="true" />
</Button>
</FormItemPart>
</FormItem>
{/each}
</ul>
{#if dataError}
<Helper type="warning">{dataError}</Helper>
{/if}
<Button
noMargin
text
disabled={customData && customData[customData.length - 1][0] === ''}
on:click={() => {
customData = [...customData, ['', '']];
}}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Add data</span>
</Button>
</div>
</form>
</FormList>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button {disabled} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,33 +0,0 @@
<script lang="ts">
import { CardGrid, Heading } from '$lib/components';
import { Button, FormList, InputText, InputTextarea } from '$lib/elements/forms';
import type { Models } from '@appwrite.io/console';
import PushPhone from '../pushPhone.svelte';
export let message: Models.Message & { data: Record<string, string> };
export let onEdit: () => void = null;
</script>
<CardGrid>
<div class="grid-1-2-col-1 u-flex-vertical u-cross-start u-gap-16">
<Heading tag="h6" size="7">Preview</Heading>
<div class="u-flex u-margin-block-start-24 u-width-full-line">
<PushPhone title={message.data.title} body={message.data.body} />
</div>
</div>
<svelte:fragment slot="aside">
<FormList>
<InputText id="title" label="Title" disabled={true} bind:value={message.data.title}>
</InputText>
<InputTextarea
id="message"
label="Message"
disabled={true}
bind:value={message.data.body}>
</InputTextarea>
<div class="u-flex u-main-end">
<Button secondary disabled={onEdit == null} on:click={onEdit}>Edit message</Button>
</div>
</FormList>
</svelte:fragment>
</CardGrid>
@@ -0,0 +1,138 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Modal } from '$lib/components';
import { Button, FormList, InputDate, InputTime, Helper } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { MessagingProviderType, type Models } from '@appwrite.io/console';
import { Dependencies } from '$lib/constants';
import { isSameDay, toLocaleDateISO, toLocaleTimeISO } from '$lib/helpers/date';
export let show = false;
export let message: Models.Message & { data: Record<string, unknown> };
export let topics: Models.Topic[];
let now = new Date();
let minDate: string;
// Use Sweden's locale (sv) since it matches ISO format
let date = message.scheduledAt == null ? null : toLocaleDateISO(message.scheduledAt);
let time = message.scheduledAt == null ? null : toLocaleTimeISO(message.scheduledAt);
let dateTime: Date;
let totalTargets = message.targets?.length ?? 0;
const formatOptions: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hourCycle: 'h23',
timeZoneName: 'longGeneric'
};
for (const topic of topics) {
if (message.providerType == MessagingProviderType.Push) {
totalTargets = totalTargets + topic.pushTotal;
} else if (message.providerType == MessagingProviderType.Email) {
totalTargets = totalTargets + topic.emailTotal;
} else if (message.providerType == MessagingProviderType.Sms) {
totalTargets = totalTargets + topic.smsTotal;
}
}
const update = async () => {
try {
if (message.providerType == MessagingProviderType.Email) {
await sdk.forProject.messaging.updateEmail(
message.$id,
undefined,
undefined,
undefined,
undefined,
undefined,
false,
undefined,
undefined,
undefined,
dateTime.toISOString()
);
} else if (message.providerType == MessagingProviderType.Sms) {
await sdk.forProject.messaging.updateSms(
message.$id,
undefined,
undefined,
undefined,
undefined,
false,
dateTime.toISOString()
);
} else if (message.providerType == MessagingProviderType.Push) {
await sdk.forProject.messaging.updatePush(
message.$id,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
false,
dateTime.toISOString()
);
}
await invalidate(Dependencies.MESSAGING_MESSAGE);
addNotification({
message: `The message has been scheduled and will be sent to an estimated ${totalTargets} targets.`,
type: 'success'
});
trackEvent(Submit.MessagingMessageUpdate, {
providerType: message.providerType,
status
});
show = false;
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.MessagingMessageUpdate);
}
};
$: minDate = toLocaleDateISO(now.getTime());
$: minTime = isSameDay(new Date(date), new Date(minDate))
? toLocaleTimeISO(now.getTime())
: '00:00';
$: dateTime = new Date(`${date}T${time}`);
</script>
<Modal title="Schedule message" bind:show onSubmit={update} headerDivider={false} size="big">
<div>
<FormList>
<div
class="u-grid u-gap-16"
style="grid-auto-rows: 1fr; grid-template-columns: 1fr 1fr;">
<InputDate id="date" label="Date" required={true} min={minDate} bind:value={date} />
<InputTime id="time" label="Time" required={true} min={minTime} bind:value={time} />
</div>
</FormList>
<Helper type="neutral">
{#if !dateTime || isNaN(dateTime.getTime())}
The message will be sent later
{:else}
The message will be sent at {dateTime.toLocaleString('en', formatOptions)}
{/if}
</Helper>
</div>
<svelte:fragment slot="footer">
<Button text on:click={() => (show = false)}>Cancel</Button>
<Button submit>Schedule</Button>
</svelte:fragment>
</Modal>
@@ -0,0 +1,100 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { MessagingProviderType, type Models } from '@appwrite.io/console';
import { Dependencies } from '$lib/constants';
export let show = false;
export let message: Models.Message & { data: Record<string, unknown> };
export let topics: Models.Topic[];
let totalTargets = message.targets?.length ?? 0;
for (const topic of topics) {
if (message.providerType == MessagingProviderType.Push) {
totalTargets = totalTargets + topic.pushTotal;
} else if (message.providerType == MessagingProviderType.Email) {
totalTargets = totalTargets + topic.emailTotal;
} else if (message.providerType == MessagingProviderType.Sms) {
totalTargets = totalTargets + topic.smsTotal;
}
}
const update = async () => {
try {
if (message.providerType == MessagingProviderType.Email) {
await sdk.forProject.messaging.updateEmail(
message.$id,
undefined,
undefined,
undefined,
undefined,
undefined,
false
);
} else if (message.providerType == MessagingProviderType.Sms) {
await sdk.forProject.messaging.updateSms(
message.$id,
undefined,
undefined,
undefined,
undefined,
false
);
} else if (message.providerType == MessagingProviderType.Push) {
await sdk.forProject.messaging.updatePush(
message.$id,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
false
);
}
await invalidate(Dependencies.MESSAGING_MESSAGE);
addNotification({
message: `The message has been sent to an estimated ${totalTargets} targets.`,
type: 'success'
});
trackEvent(Submit.MessagingMessageUpdate, {
providerType: message.providerType,
status
});
show = false;
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.MessagingMessageUpdate);
}
};
</script>
<Modal title="Send message" bind:show onSubmit={update} headerDivider={false} size="small">
<div class="u-flex-vertical u-gap-16">
<p data-private>
You are about to send a message to an estimated <span class="u-bold"
>{totalTargets}</span> targets. Would you like to proceed?
</p>
<p class="u-bold">This action is irreversible.</p>
</div>
<svelte:fragment slot="footer">
<Button text on:click={() => (show = false)}>Cancel</Button>
<Button submit>Send</Button>
</svelte:fragment>
</Modal>
@@ -0,0 +1,68 @@
<script lang="ts">
import { CardGrid, Heading } from '$lib/components';
import { Button, Form, FormList, InputTextarea } from '$lib/elements/forms';
import type { Models } from '@appwrite.io/console';
import SMSPhone from '../smsPhone.svelte';
import { onMount } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
export let message: Models.Message & { data: Record<string, string> };
let content = '';
let disabled = true;
onMount(() => {
content = message.data.content;
});
async function update() {
try {
await sdk.forProject.messaging.updateSms(
message.$id,
undefined,
undefined,
undefined,
content
);
await invalidate(Dependencies.MESSAGING_MESSAGE);
addNotification({
message: 'Message has been updated',
type: 'success'
});
trackEvent(Submit.MessagingMessageUpdate);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.MessagingMessageUpdate);
}
}
$: disabled = content === message.data.content;
</script>
<Form onSubmit={update}>
<CardGrid hideFooter={message.status != 'draft'}>
<div class="grid-1-2-col-1 u-flex-vertical u-cross-start u-gap-16">
<Heading tag="h6" size="7">Message</Heading>
<SMSPhone {content} />
</div>
<svelte:fragment slot="aside">
<FormList>
<InputTextarea
id="message"
label="Message"
disabled={message.status != 'draft'}
bind:value={content}></InputTextarea>
</FormList>
</svelte:fragment>
<svelte:fragment slot="actions">
<Button {disabled} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,29 +0,0 @@
<script lang="ts">
import { CardGrid, Heading } from '$lib/components';
import { Button, FormList, InputTextarea } from '$lib/elements/forms';
import type { Models } from '@appwrite.io/console';
import SMSPhone from '../smsPhone.svelte';
export let message: Models.Message & { data: Record<string, string> };
export let onEdit: () => void = null;
</script>
<CardGrid>
<div class="grid-1-2-col-1 u-flex-vertical u-cross-start u-gap-16">
<Heading tag="h6" size="7">Preview</Heading>
<SMSPhone content={message.data.content} />
</div>
<svelte:fragment slot="aside">
<FormList>
<InputTextarea
id="message"
label="Message"
disabled={true}
bind:value={message.data.content}>
</InputTextarea>
<div class="u-flex u-main-end">
<Button secondary disabled={onEdit == null} on:click={onEdit}>Edit message</Button>
</div>
</FormList>
</svelte:fragment>
</CardGrid>
@@ -1,62 +0,0 @@
<script lang="ts">
import { MessagingProviderType, type Models } from '@appwrite.io/console';
import {
Table,
TableBody,
TableCell,
TableCellHead,
TableCellText,
TableHeader,
TableRow
} from '$lib/elements/table';
import { CardGrid, Heading, Empty, PaginationInline, Id } from '$lib/components';
export let targets: Models.Target[];
export let usersById: Record<string, Models.User<Models.Preferences>>;
let offset = 0;
const limit = 10;
</script>
<CardGrid>
<Heading tag="h6" size="7" id="variables">Targets</Heading>
<svelte:fragment slot="aside">
{@const sum = targets.length}
{#if sum}
<div class="u-flex u-flex-vertical u-gap-24">
<Table noMargin noStyles>
<TableHeader>
<TableCellHead>ID</TableCellHead>
<TableCellHead>Name</TableCellHead>
<TableCellHead>Identifier</TableCellHead>
</TableHeader>
<TableBody>
{#each targets.slice(offset, offset + limit) as target (target.$id)}
<TableRow>
<TableCell title="id">
<Id value={target.$id}>{target.$id}</Id>
</TableCell>
<TableCellText title="name">
{usersById[target.userId]?.name || 'Unknown'}
</TableCellText>
<TableCellText title="subscribers">
{target.providerType === MessagingProviderType.Push
? target.name
: target.identifier}
</TableCellText>
</TableRow>
{/each}
</TableBody>
</Table>
<div class="u-flex u-main-space-between">
<p class="text">Total targets: {sum}</p>
<PaginationInline {sum} {limit} bind:offset />
</div>
</div>
{:else}
<Empty>Edit the message to add a Target</Empty>
{/if}
</svelte:fragment>
</CardGrid>
@@ -1,59 +0,0 @@
<script lang="ts">
import type { Models } from '@appwrite.io/console';
import {
Table,
TableBody,
TableCell,
TableCellHead,
TableCellText,
TableHeader,
TableRow
} from '$lib/elements/table';
import { CardGrid, Heading, Empty, PaginationInline, Id } from '$lib/components';
export let topics: Models.Topic[];
let offset = 0;
const limit = 10;
</script>
<CardGrid>
<Heading tag="h6" size="7" id="variables">Topics</Heading>
<svelte:fragment slot="aside">
{@const sum = topics.length}
{#if sum}
<div class="u-flex u-flex-vertical u-gap-24">
<Table noMargin noStyles>
<TableHeader>
<TableCellHead>ID</TableCellHead>
<TableCellHead>Name</TableCellHead>
<TableCellHead>Subscribers</TableCellHead>
</TableHeader>
<TableBody>
{#each topics.slice(offset, offset + limit) as topic (topic.$id)}
<TableRow>
<TableCell title="id">
<Id value={topic.$id}>{topic.$id}</Id>
</TableCell>
<TableCellText title="name">
{topic.name}
</TableCellText>
<TableCellText title="subscribers">
{topic.smsTotal + topic.emailTotal + topic.pushTotal}
</TableCellText>
</TableRow>
{/each}
</TableBody>
</Table>
<div class="u-flex u-main-space-between">
<p class="text">Total topics: {sum}</p>
<PaginationInline {sum} {limit} bind:offset />
</div>
</div>
{:else}
<Empty>Edit the message to add a Topic</Empty>
{/if}
</svelte:fragment>
</CardGrid>
@@ -0,0 +1,208 @@
<script lang="ts">
import { MessagingProviderType, type Models } from '@appwrite.io/console';
import {
Table,
TableBody,
TableCell,
TableCellHead,
TableHeader,
TableRow
} from '$lib/elements/table';
import { CardGrid, Heading, Empty, PaginationInline, EmptySearch } from '$lib/components';
import { onMount } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { symmetricDifference } from '$lib/helpers/array';
import { addNotification } from '$lib/stores/notifications';
import { Button, Form } from '$lib/elements/forms';
import UserTargetsModal from '../userTargetsModal.svelte';
import { isValueOfStringEnum } from '$lib/helpers/types';
export let message: Models.Message & { data: Record<string, unknown> };
export let selectedTargetsById: Record<string, Models.Target>;
let providerType: MessagingProviderType;
let offset = 0;
const limit = 10;
let showTargets = false;
let targetsById: Record<string, Models.Target>;
let targetIds: string[] = [];
let targets: Models.Target[] = [];
let disabled = true;
if (isValueOfStringEnum(MessagingProviderType, message.providerType)) {
providerType = message.providerType;
}
onMount(() => {
targetsById = { ...selectedTargetsById };
});
function removeTarget(targetId: string) {
const { [targetId]: _, ...rest } = targetsById;
targetsById = rest;
}
async function update() {
try {
if (message.providerType == MessagingProviderType.Email) {
await sdk.forProject.messaging.updateEmail(
message.$id,
undefined,
undefined,
targetIds
);
} else if (message.providerType == MessagingProviderType.Sms) {
await sdk.forProject.messaging.updateSms(
message.$id,
undefined,
undefined,
targetIds
);
} else if (message.providerType == MessagingProviderType.Push) {
await sdk.forProject.messaging.updatePush(
message.$id,
undefined,
undefined,
targetIds
);
}
await invalidate(Dependencies.MESSAGING_MESSAGE);
addNotification({
message: 'Targets have been updated',
type: 'success'
});
trackEvent(Submit.MessagingMessageUpdate);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.MessagingMessageUpdate);
}
}
$: {
targetIds = [];
targets = [];
for (const targetId in targetsById) {
targetIds.push(targetId);
targets.push(targetsById[targetId]);
}
}
$: disabled = symmetricDifference(targetIds, Object.keys(selectedTargetsById)).length === 0;
</script>
<Form onSubmit={update}>
<CardGrid hideFooter={message.status != 'draft'}>
<Heading tag="h6" size="7" id="variables">Targets</Heading>
<svelte:fragment slot="aside">
{@const sum = targetIds.length}
{#if sum}
<div class="u-flex u-cross-center u-main-space-between">
<div>
<span class="eyebrow-heading-3">Target</span>
</div>
{#if message.status == 'draft'}
<Button
text
noMargin
on:click={() => {
showTargets = true;
}}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Add</span>
</Button>
{/if}
</div>
<div class="u-flex u-flex-vertical u-gap-24">
<Table noMargin noStyles>
<TableHeader>
<TableCellHead style="padding: 0" />
<TableCellHead width={40} style="padding: 0" />
</TableHeader>
<TableBody>
<TableRow />
{#each targets.slice(offset, offset + limit) as target (target.$id)}
<TableRow>
<TableCell title="Target">
<div class="u-flex u-cross-center">
<span class="title">
<span class="u-line-height-1-5">
<span class="body-text-2" data-private>
{#if target.providerType === MessagingProviderType.Push}
{target.name}
{:else}
{target.identifier}
{/if}
</span>
</span></span>
</div>
</TableCell>
<TableCell title="Remove">
{#if message.status === 'draft'}
<div
class="u-flex u-main-end"
style="--p-button-size: 1.25rem">
<Button
text
class="is-only-icon"
ariaLabel="delete"
disabled={message.status != 'draft'}
on:click={() => removeTarget(target.$id)}>
<span
class="icon-x u-font-size-20"
aria-hidden="true" />
</Button>
</div>
{/if}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
<div class="u-flex u-main-space-between">
<p class="text">Total targets: {sum}</p>
<PaginationInline {sum} {limit} bind:offset />
</div>
</div>
{:else if message.status == 'draft'}
<Empty on:click={() => (showTargets = true)}>Add a target</Empty>
{:else}
<EmptySearch hidePagination>
<div class="u-text-center">
No targets have been selected.
<p>
Need a hand? Check out our <Button
link
external
href="https://appwrite.io/docs/products/messaging/targets"
text>
documentation</Button
>.
</p>
</div>
</EmptySearch>
{/if}
</svelte:fragment>
<svelte:fragment slot="actions">
<Button {disabled} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
<UserTargetsModal
title="Select targets"
{providerType}
bind:show={showTargets}
{targetsById}
on:update={(e) => {
showTargets = false;
targetsById = e.detail;
}}>
<svelte:fragment slot="description"
>Select existing targets to which you want to send this message.</svelte:fragment>
</UserTargetsModal>
@@ -0,0 +1,189 @@
<script lang="ts">
import { type Models, MessagingProviderType } from '@appwrite.io/console';
import {
Table,
TableBody,
TableCell,
TableCellHead,
TableHeader,
TableRow
} from '$lib/elements/table';
import { CardGrid, Heading, Empty, PaginationInline, EmptySearch } from '$lib/components';
import TopicsModal from '../topicsModal.svelte';
import { sdk } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
import { addNotification } from '$lib/stores/notifications';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { symmetricDifference } from '$lib/helpers/array';
import { Form, Button } from '$lib/elements/forms';
import { onMount } from 'svelte';
import { getTotal } from '../wizard/store';
import { isValueOfStringEnum } from '$lib/helpers/types';
export let message: Models.Message;
export let selectedTopicsById: Record<string, Models.Topic>;
let providerType: MessagingProviderType;
let offset = 0;
const limit = 10;
let showTopics = false;
let topicsById: Record<string, Models.Topic> = {};
let topicIds: string[] = [];
let topics: Models.Topic[] = [];
let disabled = true;
if (isValueOfStringEnum(MessagingProviderType, message.providerType)) {
providerType = message.providerType;
}
onMount(() => {
topicsById = { ...selectedTopicsById };
});
function removeTopic(topicId: string) {
const { [topicId]: _, ...rest } = topicsById;
topicsById = rest;
}
async function update() {
try {
if (message.providerType == MessagingProviderType.Email) {
await sdk.forProject.messaging.updateEmail(message.$id, topicIds);
} else if (message.providerType == MessagingProviderType.Sms) {
await sdk.forProject.messaging.updateSms(message.$id, topicIds);
} else if (message.providerType == MessagingProviderType.Push) {
await sdk.forProject.messaging.updatePush(message.$id, topicIds);
}
await invalidate(Dependencies.MESSAGING_MESSAGE);
addNotification({
message: 'Topics have been updated',
type: 'success'
});
trackEvent(Submit.MessagingMessageUpdate);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.MessagingMessageUpdate);
}
}
$: {
topicIds = [];
topics = [];
for (const topicId in topicsById) {
topicIds.push(topicId);
topics.push(topicsById[topicId]);
}
}
$: disabled = symmetricDifference(topicIds, Object.keys(selectedTopicsById)).length === 0;
</script>
<Form onSubmit={update}>
<CardGrid hideFooter={message.status != 'draft'}>
<Heading tag="h6" size="7" id="variables">Topics</Heading>
<svelte:fragment slot="aside">
{@const sum = topicIds.length}
{#if sum}
<div class="u-flex u-cross-center u-main-space-between">
<div>
<span class="eyebrow-heading-3">Topic</span>
</div>
{#if message.status == 'draft'}
<Button
text
noMargin
on:click={() => {
showTopics = true;
}}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Add</span>
</Button>
{/if}
</div>
<div class="u-flex u-flex-vertical u-gap-24">
<Table noMargin noStyles>
<TableHeader>
<TableCellHead style="padding: 0" />
<TableCellHead width={40} style="padding: 0" />
</TableHeader>
<TableBody>
{#each topics.slice(offset, offset + limit) as topic (topic.$id)}
<TableRow>
<TableCell title="Topic">
<div class="u-flex u-cross-center">
<span class="title">
<span class="u-line-height-1-5">
<span class="body-text-2 u-bold" data-private>
{topic.name}
</span>
<span class="collapsible-button-optional">
({getTotal(topic)} targets)
</span>
</span>
</span>
</div>
</TableCell>
<TableCell title="Remove" width={40}>
{#if message.status === 'draft'}
<div
class="u-flex u-main-end"
style="--p-button-size: 1.25rem">
<Button
text
class="is-only-icon"
ariaLabel="delete"
disabled={message.status != 'draft'}
on:click={() => removeTopic(topic.$id)}>
<span
class="icon-x u-font-size-20"
aria-hidden="true" />
</Button>
</div>
{/if}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
<div class="u-flex u-main-space-between">
<p class="text">Total topics: {sum}</p>
<PaginationInline {sum} {limit} bind:offset />
</div>
</div>
{:else if message.status == 'draft'}
<Empty on:click={() => (showTopics = true)}>Add a topic</Empty>
{:else}
<EmptySearch hidePagination>
<div class="u-text-center">
No topics have been selected.
<p>
Need a hand? Check out our <Button
link
external
href="https://appwrite.io/docs/products/messaging/topics"
text>
documentation</Button
>.
</p>
</div>
</EmptySearch>
{/if}
</svelte:fragment>
<svelte:fragment slot="actions">
<Button {disabled} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
<TopicsModal
{providerType}
bind:show={showTopics}
{topicsById}
on:update={(e) => {
showTopics = false;
topicsById = e.detail;
}} />
@@ -44,8 +44,8 @@
providerId,
$providerParams[$provider].name,
$providerParams[$provider].from,
$providerParams[$provider].username,
$providerParams[$provider].password,
$providerParams[$provider].customerId,
$providerParams[$provider].apiKey,
$providerParams[$provider].enabled
);
break;
@@ -156,7 +156,7 @@
const stepsComponents: WizardStepsType = new Map();
stepsComponents.set(1, {
label: 'Proivder',
label: 'Provider',
component: Provider
});
stepsComponents.set(2, {
@@ -48,8 +48,8 @@
providerId: $providerData.$id,
name: $providerData.name,
enabled: $providerData.enabled,
username: $providerData.credentials['username'],
password: $providerData.credentials['password'],
customerId: $providerData.credentials['customerId'],
apiKey: $providerData.credentials['apiKey'],
from: $providerData.options['from']
};
break;
@@ -78,7 +78,7 @@
providerId: $providerData.$id,
name: $providerData.name,
enabled: $providerData.enabled,
isEuRegion: false,
isEuRegion: $providerData.credentials['isEuRegion'],
fromEmail: $providerData.options['fromEmail'],
fromName: $providerData.options['fromName'],
replyToEmail: $providerData.options['replyToEmail'],
@@ -13,12 +13,13 @@
import { onMount } from 'svelte';
import CreateMember from '$routes/console/organization-[organization]/createMember.svelte';
export let inputs: ProviderInput[];
export let inputs: (ProviderInput | ProviderInput[])[];
export let providerType: MessagingProviderType;
export let provider: Providers;
export let providerId: string;
export let params: object;
let flattened: ProviderInput[] = [];
let formValues: object = {};
let originalfiles: Record<string, FileList> = {};
let files: Record<string, FileList> = {};
@@ -41,7 +42,15 @@
}
onMount(() => {
for (const input of inputs) {
flattened = [];
for (const i of inputs) {
if (Array.isArray(i)) {
flattened.push(...i);
} else {
flattened.push(i);
}
}
for (const input of flattened) {
const value = params[input.name];
formValues[input.name] = value;
if (input.type === 'file' && typeof value == 'string' && value.length > 0) {
@@ -91,8 +100,8 @@
providerId,
undefined,
undefined,
formValues['username'],
formValues['password'],
formValues['customerId'],
formValues['apiKey'],
formValues['from']
);
break;
@@ -198,7 +207,7 @@
}
$: updated =
inputs
flattened
.filter((input) => input.type !== 'file')
.some((input) => formValues[input.name] !== params[input.name]) ||
Object.keys(files).some((key) => files[key] !== originalfiles[key]);
@@ -134,16 +134,20 @@
</div>
<svelte:fragment slot="aside">
<div class="u-flex u-main-space-between">
<div data-private>
<div data-private class="u-flex-vertical u-gap-16">
<ul>
<InputSwitch
id="enabled"
label={enabled ? 'Enabled' : 'Disabled'}
bind:value={enabled} />
</ul>
<p class="title">Provider: <Provider noIcon provider={$providerData.provider} /></p>
<p class="title">Channel: <ProviderType noIcon type={$providerData.type} /></p>
<p>Created: {toLocaleDateTime($providerData.$createdAt)}</p>
<div>
<p class="title">
Provider: <Provider noIcon provider={$providerData.provider} />
</p>
<p class="title">Type: <ProviderType noIcon type={$providerData.type} /></p>
<p>Created: {toLocaleDateTime($providerData.$createdAt)}</p>
</div>
</div>
</div>
</svelte:fragment>
@@ -0,0 +1,147 @@
<script lang="ts">
import {
InputDomain,
InputEmail,
InputFile,
InputSelect,
InputPhone,
InputSwitch,
InputText,
InputPassword
} from '$lib/elements/forms';
import InputCheckbox from '$lib/elements/forms/inputCheckbox.svelte';
import PopoverContent from './popoverContent.svelte';
import type {
ProviderInput,
TwilioProviderParams,
Msg91ProviderParams,
TelesignProviderParams,
TextmagicProviderParams,
VonageProviderParams,
MailgunProviderParams,
SendgridProviderParams,
SMTPProviderParams,
FCMProviderParams,
APNSProviderParams
} from './store';
export let files: Record<string, FileList>;
export let input: ProviderInput;
export let params: Partial<
| TwilioProviderParams
| Msg91ProviderParams
| TelesignProviderParams
| TextmagicProviderParams
| VonageProviderParams
| MailgunProviderParams
| SendgridProviderParams
| SMTPProviderParams
| FCMProviderParams
| APNSProviderParams
>;
export let fullWidth = false;
let popoverImage = input.popoverImage;
</script>
{#if input.type === 'text'}
<InputText
id={input.name}
label={input.label}
placeholder={input.placeholder}
required={!input.optional}
tooltip={input.tooltip}
isPopoverDefined={input.popover !== undefined}
{fullWidth}
bind:value={params[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputText>
{:else if input.type === 'password'}
<InputPassword
id={input.name}
label={input.label}
placeholder={input.placeholder}
required={!input.optional}
showPasswordButton
isPopoverDefined={input.popover !== undefined}
{fullWidth}
bind:value={params[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputPassword>
{:else if input.type === 'email'}
<InputEmail
id={input.name}
label={input.label}
placeholder={input.placeholder}
required={!input.optional}
tooltip={input.tooltip}
isPopoverDefined={input.popover !== undefined}
{fullWidth}
bind:value={params[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputEmail>
{:else if input.type === 'domain'}
<InputDomain
id={input.name}
label={input.label}
placeholder={input.placeholder}
required={!input.optional}
isPopoverDefined={input.popover !== undefined}
{fullWidth}
bind:value={params[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputDomain>
{:else if input.type === 'phone'}
<InputPhone
id={input.name}
label={input.label}
placeholder={input.placeholder}
required={!input.optional}
isPopoverDefined={input.popover !== undefined}
{fullWidth}
bind:value={params[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputPhone>
{:else if input.type === 'file'}
<InputFile
label={input.label}
allowedFileExtensions={input.allowedFileExtensions}
required={!input.optional}
tooltip={input.tooltip}
isPopoverDefined={input.popover !== undefined}
bind:files={files[input.name]}>
{fullWidth}
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputFile>
{:else if input.type === 'switch'}
<InputSwitch label={input.label} id={input.name} bind:value={params[input.name]}>
<svelte:fragment slot="description">
<p>{@html input.description}</p>
</svelte:fragment>
</InputSwitch>
{:else if input.type === 'checkbox'}
<InputCheckbox label={input.label} id={input.name} bind:checked={params[input.name]}>
<svelte:fragment slot="description">
{input.description}
</svelte:fragment>
</InputCheckbox>
{:else if input.type === 'select'}
<InputSelect
label={input.label}
id={input.name}
options={input.options}
required={!input.optional}
bind:value={params[input.name]} />
{/if}
@@ -1,17 +1,6 @@
<script lang="ts">
import {
FormList,
InputDomain,
InputEmail,
InputFile,
InputSelect,
InputPhone,
InputSwitch,
InputText,
InputPassword
} from '$lib/elements/forms';
import InputCheckbox from '$lib/elements/forms/inputCheckbox.svelte';
import PopoverContent from './popoverContent.svelte';
import { FormItem, FormList } from '$lib/elements/forms';
import SettingsFormInput from './settingsFormInput.svelte';
import type {
ProviderInput,
TwilioProviderParams,
@@ -27,7 +16,7 @@
} from './store';
export let files: Record<string, FileList>;
export let inputs: ProviderInput[];
export let inputs: (ProviderInput | ProviderInput[])[];
export let params: Partial<
| TwilioProviderParams
| Msg91ProviderParams
@@ -44,98 +33,16 @@
<FormList>
{#each inputs as input}
{@const popoverImage = input.popoverImage}
{#if input.type === 'text'}
<InputText
id={input.name}
label={input.label}
placeholder={input.placeholder}
required={!input.optional}
isPopoverDefined={input.popover !== undefined}
bind:value={params[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputText>
{:else if input.type === 'password'}
<InputPassword
id={input.name}
label={input.label}
placeholder={input.placeholder}
required={!input.optional}
showPasswordButton
isPopoverDefined={input.popover !== undefined}
bind:value={params[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputPassword>
{:else if input.type === 'email'}
<InputEmail
id={input.name}
label={input.label}
placeholder={input.placeholder}
required={!input.optional}
isPopoverDefined={input.popover !== undefined}
bind:value={params[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputEmail>
{:else if input.type === 'domain'}
<InputDomain
id={input.name}
label={input.label}
placeholder={input.placeholder}
required={!input.optional}
isPopoverDefined={input.popover !== undefined}
bind:value={params[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputDomain>
{:else if input.type === 'phone'}
<InputPhone
id={input.name}
label={input.label}
placeholder={input.placeholder}
required={!input.optional}
isPopoverDefined={input.popover !== undefined}
bind:value={params[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputPhone>
{:else if input.type === 'file'}
<InputFile
label={input.label}
allowedFileExtensions={input.allowedFileExtensions}
required={!input.optional}
isPopoverDefined={input.popover !== undefined}
bind:files={files[input.name]}>
<svelte:fragment slot="popover">
<PopoverContent lines={input.popover} {popoverImage} />
</svelte:fragment>
</InputFile>
{:else if input.type === 'switch'}
<InputSwitch label={input.label} id={input.name} bind:value={params[input.name]}>
<svelte:fragment slot="description">
{input.description}
</svelte:fragment>
</InputSwitch>
{:else if input.type === 'checkbox'}
<InputCheckbox label={input.label} id={input.name} bind:checked={params[input.name]}>
<svelte:fragment slot="description">
{input.description}
</svelte:fragment>
</InputCheckbox>
{:else if input.type === 'select'}
<InputSelect
label={input.label}
id={input.name}
options={input.options}
required={!input.optional}
bind:value={params[input.name]} />
{#if Array.isArray(input)}
<FormItem isMultiple>
{#each input as i}
<div class="u-flex u-flex-basis-50-percent">
<SettingsFormInput input={i} bind:params bind:files fullWidth />
</div>
{/each}
</FormItem>
{:else}
<SettingsFormInput {input} bind:params bind:files />
{/if}
{/each}
</FormList>
@@ -26,6 +26,7 @@ export type ProviderInput = {
| 'checkbox';
placeholder?: string;
description?: string;
tooltip?: string;
popover?: string[];
popoverImage?: { src: { light: string; dark: string }; alt: string };
allowedFileExtensions?: string[];
@@ -45,7 +46,7 @@ type ProvidersMap = {
title: string;
description: string;
needAHand?: string[];
configure: ProviderInput[];
configure: (ProviderInput | ProviderInput[])[];
};
};
};
@@ -167,7 +168,8 @@ export const providers: ProvidersMap = {
label: 'Sandbox',
name: 'sandbox',
type: 'switch',
description: 'Use the development APNS server.'
description:
'Enable sandbox mode for testing on apps signed with development provisioning profiles. <a class="link" href="https://appwrite.io/docs/products/messaging/apns#configure-provider" target="_blank" rel="noopener noreferrer">Learn more</a>.'
}
]
}
@@ -217,33 +219,37 @@ export const providers: ProvidersMap = {
description:
'Enable the EU region setting if your domain is within the European Union.'
},
{
label: 'Sender email',
name: 'fromEmail',
type: 'email',
placeholder: 'Enter email'
},
{
label: 'Sender name',
name: 'fromName',
type: 'text',
optional: true,
placeholder: 'Enter name'
},
{
label: 'Reply to email',
name: 'replyToEmail',
type: 'email',
optional: true,
placeholder: 'Enter email'
},
{
label: 'Reply to name',
name: 'replyToName',
type: 'text',
optional: true,
placeholder: 'Enter name'
}
[
{
label: 'Sender email',
name: 'fromEmail',
type: 'email',
placeholder: 'Enter email'
},
{
label: 'Sender name',
name: 'fromName',
type: 'text',
optional: true,
placeholder: 'Enter name'
}
],
[
{
label: 'Reply-to email',
name: 'replyToEmail',
type: 'email',
optional: true,
placeholder: 'Enter email'
},
{
label: 'Reply-to name',
name: 'replyToName',
type: 'text',
optional: true,
placeholder: 'Enter name'
}
]
]
},
[Providers.Sendgrid]: {
@@ -262,66 +268,44 @@ export const providers: ProvidersMap = {
'Head to <b>Settings -> API Keys -> Create API key.</b>'
]
},
{
label: 'Sender email',
name: 'fromEmail',
type: 'email',
placeholder: 'Enter email'
},
{
label: 'Sender name',
name: 'fromName',
type: 'text',
optional: true,
placeholder: 'Enter name'
},
{
label: 'Reply to email',
name: 'replyToEmail',
type: 'email',
optional: true,
placeholder: 'Enter email'
},
{
label: 'Reply to name',
name: 'replyToName',
type: 'text',
optional: true,
placeholder: 'Enter name'
}
[
{
label: 'Sender email',
name: 'fromEmail',
type: 'email',
placeholder: 'Enter email'
},
{
label: 'Sender name',
name: 'fromName',
type: 'text',
optional: true,
placeholder: 'Enter name'
}
],
[
{
label: 'Reply-to email',
name: 'replyToEmail',
type: 'email',
optional: true,
placeholder: 'Enter email'
},
{
label: 'Reply-to name',
name: 'replyToName',
type: 'text',
optional: true,
placeholder: 'Enter name'
}
]
]
},
[Providers.SMTP]: {
classIcon: 'mail',
title: 'SMTP',
description: '',
description: 'Generic SMTP server.',
configure: [
{
label: 'Sender name',
name: 'fromName',
type: 'text',
optional: true,
placeholder: 'Enter name'
},
{
label: 'Sender email',
name: 'fromEmail',
type: 'email',
placeholder: 'Enter email'
},
{
label: 'Reply to name',
name: 'replyToName',
type: 'text',
optional: true,
placeholder: 'Enter name'
},
{
label: 'Reply to email',
name: 'replyToEmail',
type: 'email',
optional: true,
placeholder: 'Enter email'
},
{
label: 'Server host',
name: 'host',
@@ -366,16 +350,45 @@ export const providers: ProvidersMap = {
type: 'checkbox',
optional: true
},
[
{
label: 'Sender email',
name: 'fromEmail',
type: 'email',
placeholder: 'Enter email'
},
{
label: 'Sender name',
name: 'fromName',
type: 'text',
optional: true,
placeholder: 'Enter name'
}
],
[
{
label: 'Reply-to email',
name: 'replyToEmail',
type: 'email',
optional: true,
placeholder: 'Enter email'
},
{
label: 'Reply-to name',
name: 'replyToName',
type: 'text',
optional: true,
placeholder: 'Enter name'
}
],
{
label: 'Mailer',
label: 'X-Mailer header',
name: 'mailer',
type: 'text',
optional: true,
placeholder: 'Enter mailer',
popover: [
'<b>What is the Mailer?</b>',
'The value to use for the X-Mailer header.'
]
tooltip:
'The X-Mailer header is used to identify the software or client used for composing or sending the email. It typically includes information such as the name and version of the email program, aiding in troubleshooting and debugging.'
}
]
}
@@ -469,16 +482,21 @@ export const providers: ProvidersMap = {
description: '',
configure: [
{
label: 'Username',
name: 'username',
label: 'Customer ID',
name: 'customerId',
type: 'text',
placeholder: 'Enter username'
placeholder: 'Enter customer ID'
},
{
label: 'Password',
name: 'password',
label: 'API Key',
name: 'apiKey',
type: 'password',
placeholder: 'Enter password'
placeholder: 'Enter API key',
popover: [
'<b>How to get the API key?</b>',
'Create an account in <a class="link" href="https://portal.telesign.com/" target="_blank" rel="noopener noreferrer">Telesign</a>.',
'Head to <b>Telesign portal -> Profile -> API Keys.</b>'
]
},
{
label: 'Sender number',
@@ -579,8 +597,8 @@ export type Msg91ProviderParams = ProviderParams & {
export type TelesignProviderParams = ProviderParams & {
from: string;
username: string;
password: string;
customerId: string;
apiKey: string;
};
export type TextmagicProviderParams = ProviderParams & {
@@ -39,8 +39,8 @@
providerId: id,
name: name,
enabled: true,
username: '',
password: '',
customerId: '',
apiKey: '',
from: ''
};
break;
@@ -134,6 +134,7 @@
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">Provider</svelte:fragment>
<div class="u-sep-block-start u-padding-block-end-32" />
<FormList gap={16}>
<InputText
id="name"
@@ -173,3 +174,15 @@
</div>
</FormList>
</WizardStep>
<style lang="scss">
.grid-box {
--grid-gap: 1rem;
grid-template-columns: 1fr 1fr;
}
:global(body:not(.theme-dark)) .grid-box :global(.card) {
--p-card-bg-color-default: var(--color-neutral-5);
--p-card-bg-color-hover: var(--color-neutral-10);
}
</style>
@@ -21,7 +21,15 @@
let open = false;
onMount(() => {
for (const input of inputs) {
const flattened = [];
for (const i of inputs) {
if (Array.isArray(i)) {
flattened.push(...i);
} else {
flattened.push(i);
}
}
for (const input of flattened) {
if (input.type === 'file' && $providerParams[$provider][input.name].length > 0) {
const dataTransfer = new DataTransfer();
const f = new File(
@@ -97,7 +105,7 @@
<ClickableList>
<ClickableListItem
href={`https://appwrite.io/docs/messaging/${$provider}`}
href={`https://appwrite.io/docs/products/messaging/${$provider}`}
external>
<div class="u-flex u-cross-center u-main-space-between">
<div class="u-flex u-cross-center u-gap-16">
@@ -135,7 +143,7 @@
<style lang="scss">
.need-a-hand {
--p-bg-color-hover: var(--color-neutral-10);
--p-bg-color-hover: var(--color-neutral-5);
.how-to-enable {
border-radius: var(--border-radius-small);
@@ -150,7 +158,8 @@
}
:global(.clickable-list) {
--color-border: var(--color-neutral-10);
--color-border: var(--color-neutral-5);
--p-clickable-button-bg-color-hover: var(--color-border);
:global(.theme-dark) & {
--color-border: var(--color-neutral-85);
@@ -1,17 +1,7 @@
import type { Column } from '$lib/helpers/types';
import type { Models } from '@appwrite.io/console';
import { writable } from 'svelte/store';
export const showCreate = writable(false);
export const columns = writable<Column[]>([
{ id: '$id', title: 'Message ID', type: 'string', show: true, width: 140 },
{ id: 'message', title: 'Message', type: 'string', show: false, filter: false, width: 140 },
{ id: 'providerType', title: 'Type', type: 'string', show: true, width: 100 },
{ id: 'status', title: 'Status', type: 'string', show: true, width: 120 },
{ id: 'scheduledAt', title: 'Scheduled at', type: 'datetime', show: true, width: 120 },
{ id: 'deliveredAt', title: 'Delivered at', type: 'datetime', show: false, width: 120 }
]);
export const targetsById = writable<Record<string, Models.Target>>({});
export const topicsById = writable<Record<string, Models.Topic>>({});
@@ -16,14 +16,33 @@
import { base } from '$app/paths';
import type { Models } from '@appwrite.io/console';
import type { PageData } from './$types';
import { columns, showCreate } from './store';
import { showCreate } from './store';
import { View } from '$lib/helpers/load';
import { Filters, hasPageQueries } from '$lib/components/filters';
import Table from './table.svelte';
import type { Column } from '$lib/helpers/types';
import { writable } from 'svelte/store';
export let data: PageData;
const project = $page.params.project;
const columns = writable<Column[]>([
{ id: '$id', title: 'Topic ID', type: 'string', show: true, width: 140 },
{ id: 'name', title: 'Name', type: 'string', show: true, width: 140 },
{ id: 'emailTotal', title: 'Email Subscribers', type: 'integer', show: false, width: 140 },
{ id: 'smsTotal', title: 'SMS Subscribers', type: 'integer', show: false, width: 140 },
{ id: 'pushTotal', title: 'Push Subscribers', type: 'integer', show: false, width: 140 },
{
id: 'total',
title: 'Subscribers',
type: 'integer',
show: true,
filter: false,
width: 140
},
{ id: '$createdAt', title: 'Created', type: 'datetime', show: true, width: 140 }
]);
const topicCreated = async (event: CustomEvent<Models.Team<Record<string, unknown>>>) => {
await goto(`${base}/console/project-${project}/messaging/topics/topic-${event.detail.$id}`);
};
@@ -73,7 +92,7 @@
</div>
</div>
{#if data.topics.total}
<Table {data} />
<Table columns={$columns} {data} />
<PaginationWithLimit
name="Topics"
@@ -1,14 +1,3 @@
import type { Column } from '$lib/helpers/types';
import { writable } from 'svelte/store';
export const showCreate = writable(false);
export const columns = writable<Column[]>([
{ id: '$id', title: 'Topic ID', type: 'string', show: true, width: 140 },
{ id: 'name', title: 'Name', type: 'string', show: true, width: 140 },
{ id: 'emailTotal', title: 'Email Subscribers', type: 'integer', show: false, width: 140 },
{ id: 'smsTotal', title: 'SMS Subscribers', type: 'integer', show: false, width: 140 },
{ id: 'pushTotal', title: 'Push Subscribers', type: 'integer', show: false, width: 140 },
{ id: 'total', title: 'Subscribers', type: 'integer', show: true, filter: false, width: 140 },
{ id: '$createdAt', title: 'Created', type: 'datetime', show: true, width: 140 }
]);
@@ -16,13 +16,14 @@
} from '$lib/elements/table';
import { addNotification } from '$lib/stores/notifications';
import type { PageData } from './$types';
import { columns } from './store';
import { project } from '$routes/console/project-[project]/store';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { toLocaleDateTime } from '$lib/helpers/date';
import type { Column } from '$lib/helpers/types';
export let columns: Column[];
export let data: PageData;
let selectedIds: string[] = [];
@@ -62,7 +63,7 @@
<TableCellHeadCheck
bind:selected={selectedIds}
pageItemsIds={data.topics.topics.map((d) => d.$id)} />
{#each $columns as column}
{#each columns as column}
{#if column.show}
<TableCellHead width={column.width}>{column.title}</TableCellHead>
{/if}
@@ -74,10 +75,10 @@
href={`${base}/console/project-${$project.$id}/messaging/topics/topic-${topic.$id}`}>
<TableCellCheck bind:selectedIds id={topic.$id} />
{#each $columns as column (column.id)}
{#each columns as column (column.id)}
{#if column.show}
{#if column.id === '$id'}
{#key $columns}
{#key column.id}
<TableCell title={column.title} width={column.width}>
<Id value={topic.$id}>{topic.$id}</Id>
</TableCell>
@@ -1,12 +1,178 @@
<script lang="ts">
import { page } from '$app/stores';
import { Button } from '$lib/elements/forms';
import {
Empty,
EmptySearch,
SearchQuery,
PaginationWithLimit,
Heading,
ViewSelector,
EmptyFilter
} from '$lib/components';
import { Container } from '$lib/layout';
import DangerZone from './dangerZone.svelte';
import Details from './details.svelte';
import UpdateName from './updateName.svelte';
import { ID, type Models } from '@appwrite.io/console';
import type { PageData } from './$types';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import Table from './table.svelte';
import { targetsById } from '../../store';
import UserTargetsModal from '../../userTargetsModal.svelte';
import { onMount } from 'svelte';
import { Filters, hasPageQueries } from '$lib/components/filters';
import { View } from '$lib/helpers/load';
import { writable } from 'svelte/store';
import type { Column } from '$lib/helpers/types';
export let data: PageData;
let showAdd = false;
let subscribersByTargetId: Record<string, Models.Subscriber> = {};
const columns = writable<Column[]>([
{ id: '$id', title: 'Subscriber ID', type: 'string', show: true, width: 140 },
{ id: 'userName', title: 'Name', type: 'string', show: true, filter: false, width: 100 },
{ id: 'targetId', title: 'Target ID', type: 'string', show: true, width: 140 },
{ id: 'target', title: 'Target', type: 'string', show: true, filter: false, width: 140 },
{ id: 'type', title: 'Type', type: 'string', show: true, width: 80 },
{ id: '$createdAt', title: 'Created', type: 'datetime', show: true, width: 100 }
]);
onMount(() => {
$targetsById = {};
for (const subscriber of $page.data.subscribers.subscribers) {
const { target } = subscriber;
$targetsById[target.$id] = target;
subscribersByTargetId[target.$id] = subscriber;
}
});
async function addTargets(event: CustomEvent<Record<string, Models.Target>>) {
showAdd = false;
$targetsById = event.detail;
const targetIds = Object.keys($targetsById).filter(
(targetId) => !(targetId in subscribersByTargetId)
);
const promises = targetIds.map(async (targetId) => {
const subscriber = await sdk.forProject.messaging.createSubscriber(
$page.params.topic,
ID.unique(),
targetId
);
subscribersByTargetId[targetId] = subscriber;
});
try {
await Promise.all(promises);
addNotification({
type: 'success',
message: `Added ${targetIds.length} subscriber${
targetIds.length > 1 ? 's' : ''
} to topic`
});
trackEvent(Submit.MessagingTopicSubscriberAdd);
await invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingTopicSubscriberAdd);
}
}
</script>
<Container>
<Details />
<UpdateName />
<DangerZone />
<div class="u-flex u-flex-vertical">
<div class="u-flex u-main-space-between">
<Heading tag="h2" size="5">Subscribers</Heading>
<div class="is-only-mobile">
<Button on:click={() => (showAdd = true)} event="create_subscriber">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Add subscriber</span>
</Button>
</div>
</div>
<!-- TODO: fix width of search input in mobile -->
<SearchQuery
search={data.search}
placeholder="Search by subscriber ID, target ID, user ID, or type">
<div class="u-flex u-gap-16 is-not-mobile">
<Filters query={data.query} {columns} />
<ViewSelector
view={View.Table}
{columns}
hideView
allowNoColumns
showColsTextMobile />
<Button on:click={() => (showAdd = true)} event="create_subscriber">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Add subscriber</span>
</Button>
</div>
</SearchQuery>
<div class="u-flex u-gap-16 is-only-mobile u-margin-block-start-16">
<div class="u-flex-basis-50-percent">
<!-- TODO: fix width -->
<ViewSelector
view={View.Table}
{columns}
hideView
allowNoColumns
showColsTextMobile />
</div>
<div class="u-flex-basis-50-percent">
<!-- TODO: fix width -->
<Filters query={data.query} {columns} />
</div>
</div>
</div>
{#if data.subscribers.total}
<Table columns={$columns} {data} />
<PaginationWithLimit
name="Subscribers"
limit={data.limit}
offset={data.offset}
total={data.subscribers.total} />
{:else if $hasPageQueries}
<EmptyFilter resource="subscribers" />
{:else if data.search}
<EmptySearch>
<div class="u-text-center">
<b>Sorry, we couldn't find '{data.search}'</b>
<p>There are no subscribers that match your search.</p>
</div>
<Button
secondary
href={`/console/project-${$page.params.project}/messaging/topics/topic-${$page.params.topic}/subscribers`}>
Clear Search
</Button>
</EmptySearch>
{:else}
<Empty
single
on:click={() => (showAdd = true)}
href="https://appwrite.io/docs/products/messaging/topics#subscribe-targets-to-topics"
target="subscriber" />
{/if}
</Container>
<UserTargetsModal
title="Select subscribers"
bind:show={showAdd}
bind:targetsById={$targetsById}
on:update={addTargets}>
<svelte:fragment slot="description">
<p class="text">
Add subscribers to this topic by selecting the targets for directing messages. <a
href="https://appwrite.io/docs/products/messaging/topics#subscribe-targets-to-topics"
target="_blank"
rel="noopener noreferrer"
class="link">Learn more about subscribers</a
>.
</p>
</svelte:fragment>
</UserTargetsModal>
@@ -11,11 +11,6 @@
const tabs = [
{
href: path,
title: 'Overview',
event: 'overview'
},
{
href: `${path}/subscribers`,
title: 'Subscribers',
event: 'subscribers'
},
@@ -23,6 +18,11 @@
href: `${path}/activity`,
title: 'Activity',
event: 'activity'
},
{
href: `${path}/settings`,
title: 'Settings',
event: 'settings'
}
];
</script>
@@ -0,0 +1,14 @@
<script lang="ts">
import { Container } from '$lib/layout';
import DangerZone from './dangerZone.svelte';
import Details from './details.svelte';
import UpdateName from './updateName.svelte';
import UpdatePermissions from './updatePermissions.svelte';
</script>
<Container>
<Details />
<UpdateName />
<UpdatePermissions />
<DangerZone />
</Container>
@@ -1,20 +1,10 @@
<script lang="ts" context="module">
import { get } from 'svelte/store';
let showDelete = writable(false);
export const promptDeleteUser = (id: string) => {
showDelete.set(true);
goto(`/console/project-${get(project).$id}/auth/user-${id}`);
};
</script>
<script lang="ts">
import { CardGrid, BoxAvatar, Heading } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { writable } from 'svelte/store';
import { topic, topicTotal } from './store';
import { goto } from '$app/navigation';
import { project } from '$routes/console/project-[project]/store';
import DeleteTopic from './deleteTopic.svelte';
import { topic, topicTotal } from '../store';
import DeleteTopic from '../deleteTopic.svelte';
let showDelete = false;
</script>
<CardGrid danger>
@@ -37,9 +27,9 @@
</svelte:fragment>
<svelte:fragment slot="actions">
<Button secondary on:click={() => ($showDelete = true)} event="delete_messaging_provider"
<Button secondary on:click={() => (showDelete = true)} event="delete_messaging_topic"
>Delete</Button>
</svelte:fragment>
</CardGrid>
<DeleteTopic bind:showDelete={$showDelete} />
<DeleteTopic bind:showDelete />
@@ -1,7 +1,7 @@
<script lang="ts">
import { CardGrid, Heading } from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import { topic, topicTotal } from './store';
import { topic, topicTotal } from '../store';
</script>
<CardGrid>
@@ -2,7 +2,7 @@
import { CardGrid, Heading } from '$lib/components';
import { Button, Form, InputText } from '$lib/elements/forms';
import { onMount } from 'svelte';
import { topic } from './store';
import { topic } from '../store';
import { invalidate } from '$app/navigation';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
@@ -0,0 +1,65 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { page } from '$app/stores';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { CardGrid, Heading } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button, Form } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { Roles } from '$lib/components/permissions';
import { symmetricDifference } from '$lib/helpers/array';
import { topic } from '../store';
const topicId = $page.params.topic;
let arePermsDisabled = true;
let permissions: string[] = [];
onMount(async () => {
permissions = $topic.subscribe || [];
});
async function updatePermissions() {
try {
await sdk.forProject.messaging.updateTopic(topicId, undefined, permissions);
await invalidate(Dependencies.MESSAGING_TOPIC);
addNotification({
message: 'Permissions have been updated',
type: 'success'
});
trackEvent(Submit.MessagingTopicUpdatePermissions);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
trackError(error, Submit.MessagingTopicUpdatePermissions);
}
}
$: arePermsDisabled = symmetricDifference(permissions, $topic.subscribe).length == 0;
</script>
<Form onSubmit={updatePermissions}>
<CardGrid>
<Heading tag="h6" size="7" id="permissions">Subscription access</Heading>
<p>
Choose who can subscribe to this topic using the client API. Learn more about <a
href="https://appwrite.io/docs/advanced/platform/permissions"
target="_blank"
rel="noopener noreferrer"
class="link">
Permissions</a
>.
</p>
<svelte:fragment slot="aside">
<Roles bind:roles={permissions} />
</svelte:fragment>
<svelte:fragment slot="actions">
<Button disabled={arePermsDisabled} submit>Update</Button>
</svelte:fragment>
</CardGrid>
</Form>
@@ -1,168 +0,0 @@
<script lang="ts">
import { page } from '$app/stores';
import { Button } from '$lib/elements/forms';
import {
Empty,
EmptySearch,
SearchQuery,
PaginationWithLimit,
Heading,
ViewSelector,
EmptyFilter
} from '$lib/components';
import { Container } from '$lib/layout';
import { ID, type Models } from '@appwrite.io/console';
import type { PageData } from './$types';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import Table from './table.svelte';
import { targetsById } from '../../../store';
import UserTargetsModal from '../../../userTargetsModal.svelte';
import { onMount } from 'svelte';
import { Filters, hasPageQueries } from '$lib/components/filters';
import { columns } from './store';
import { View } from '$lib/helpers/load';
export let data: PageData;
let showAdd = false;
let subscribersByTargetId: Record<string, Models.Subscriber> = {};
onMount(() => {
$targetsById = {};
for (const subscriber of $page.data.subscribers.subscribers) {
const { target } = subscriber;
$targetsById[target.$id] = target;
subscribersByTargetId[target.$id] = subscriber;
}
});
async function addTargets(event: CustomEvent<Record<string, Models.Target>>) {
showAdd = false;
$targetsById = event.detail;
const targetIds = Object.keys($targetsById).filter(
(targetId) => !(targetId in subscribersByTargetId)
);
const promises = targetIds.map(async (targetId) => {
const subscriber = await sdk.forProject.messaging.createSubscriber(
$page.params.topic,
ID.unique(),
targetId
);
subscribersByTargetId[targetId] = subscriber;
});
try {
await Promise.all(promises);
addNotification({
type: 'success',
message: `Added ${targetIds.length} subscriber${
targetIds.length > 1 ? 's' : ''
} to topic`
});
trackEvent(Submit.MessagingTopicSubscriberAdd);
await invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingTopicSubscriberAdd);
}
}
</script>
<Container>
<div class="u-flex u-flex-vertical">
<div class="u-flex u-main-space-between">
<Heading tag="h2" size="5">Subscribers</Heading>
<div class="is-only-mobile">
<Button on:click={() => (showAdd = true)} event="create_subscriber">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Add subscriber</span>
</Button>
</div>
</div>
<!-- TODO: fix width of search input in mobile -->
<SearchQuery
search={data.search}
placeholder="Search by subscriber ID, target ID, user ID, or type">
<div class="u-flex u-gap-16 is-not-mobile">
<Filters query={data.query} {columns} />
<ViewSelector
view={View.Table}
{columns}
hideView
allowNoColumns
showColsTextMobile />
<Button on:click={() => (showAdd = true)} event="create_subscriber">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Add subscriber</span>
</Button>
</div>
</SearchQuery>
<div class="u-flex u-gap-16 is-only-mobile u-margin-block-start-16">
<div class="u-flex-basis-50-percent">
<!-- TODO: fix width -->
<ViewSelector
view={View.Table}
{columns}
hideView
allowNoColumns
showColsTextMobile />
</div>
<div class="u-flex-basis-50-percent">
<!-- TODO: fix width -->
<Filters query={data.query} {columns} />
</div>
</div>
</div>
{#if data.subscribers.total}
<Table {data} />
<PaginationWithLimit
name="Subscribers"
limit={data.limit}
offset={data.offset}
total={data.subscribers.total} />
{:else if $hasPageQueries}
<EmptyFilter resource="subscribers" />
{:else if data.search}
<EmptySearch>
<div class="u-text-center">
<b>Sorry, we couldn't find '{data.search}'</b>
<p>There are no subscribers that match your search.</p>
</div>
<Button
secondary
href={`/console/project-${$page.params.project}/messaging/topics/topic-${$page.params.topic}/subscribers`}>
Clear Search
</Button>
</EmptySearch>
{:else}
<Empty
single
on:click={() => (showAdd = true)}
href="https://appwrite.io/docs/products/messaging/topics/#subscribe-targets-to-topics"
target="subscriber" />
{/if}
</Container>
<UserTargetsModal
title="Select subscribers"
bind:show={showAdd}
bind:targetsById={$targetsById}
on:update={addTargets}>
<svelte:fragment slot="description">
<p class="text">
Add subscribers to this topic by selecting the targets for directing messages. <a
href="https://appwrite.io/docs/products/messaging/topics"
target="_blank"
rel="noopener noreferrer"
class="link">Learn more about subscribers.</a>
</p>
</svelte:fragment>
</UserTargetsModal>
@@ -1,11 +0,0 @@
import type { Column } from '$lib/helpers/types';
import { writable } from 'svelte/store';
export const columns = writable<Column[]>([
{ id: '$id', title: 'Subscriber ID', type: 'string', show: true, width: 140 },
{ id: 'userName', title: 'Name', type: 'string', show: true, filter: false, width: 100 },
{ id: 'targetId', title: 'Target ID', type: 'string', show: true, width: 140 },
{ id: 'target', title: 'Target', type: 'string', show: true, filter: false, width: 140 },
{ id: 'type', title: 'Type', type: 'string', show: true, width: 80 },
{ id: '$createdAt', title: 'Created', type: 'datetime', show: true, width: 100 }
]);
@@ -18,15 +18,16 @@
} from '$lib/elements/table';
import { addNotification } from '$lib/stores/notifications';
import type { PageData } from './$types';
import { columns } from './store';
import ProviderType from '../../../providerType.svelte';
import ProviderType from '../../providerType.svelte';
import { toLocaleDateTime } from '$lib/helpers/date';
import { project } from '$routes/console/project-[project]/store';
import { sdk } from '$lib/stores/sdk';
import { page } from '$app/stores';
import { targetsById } from '../../../store';
import { targetsById } from '../../store';
import { MessagingProviderType, type Models } from '@appwrite.io/console';
import type { Column } from '$lib/helpers/types';
export let columns: Column[];
export let data: PageData;
let subscribers: Record<string, Models.Subscriber> = {};
@@ -84,7 +85,7 @@
<TableCellHeadCheck
bind:selected={selectedIds}
pageItemsIds={data.subscribers.subscribers.map((d) => d.$id)} />
{#each $columns as column}
{#each columns as column}
{#if column.show}
<TableCellHead width={column.width}>{column.title}</TableCellHead>
{/if}
@@ -97,10 +98,10 @@
href={`${base}/console/project-${$project.$id}/auth/user-${subscriber.target.userId}`}>
<TableCellCheck bind:selectedIds id={subscriber.$id} />
{#each $columns as column}
{#each columns as column}
{#if column.show}
{#if column.id === '$id'}
{#key $columns}
{#key column.id}
<TableCell title="Subscriber ID">
<Id value={subscriber.$id}>
{subscriber.$id}
@@ -91,86 +91,105 @@
</script>
<Modal {title} bind:show onSubmit={submit} on:close={reset} headerDivider={false} size="big">
<p class="text">
Select existing topics you want to send this message to its targets. The message will be
sent only to <ProviderType type={providerType} noIcon /> targets.
</p>
<InputSearch
autofocus
disabled={totalResults === 0 && !search}
placeholder="Search for topics"
bind:value={search} />
{#if Object.keys(topicResultsById).length > 0}
<FormList>
<Table noMargin noStyles>
<TableBody>
{#each Object.entries(topicResultsById) as [topicId, topic]}
<TableRow>
<TableCell>
<InputCheckbox
id={topicId}
disabled={!!topicsById[topicId]}
checked={!!selected[topicId]}
on:change={(event) => onTopicSelection(event, topic)}>
<svelte:fragment slot="description">
<span class="title">
<span class="u-line-height-1-5">
<span class="body-text-1 u-bold" data-private>
{topic.name}
</span>
<span
class="collapsible-button-optional"
style="--p-toggle-optional-color: var(--color-neutral-50);">
({getTotal(topic)} targets)
</span>
</span></span>
</svelte:fragment>
</InputCheckbox>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</FormList>
<div class="u-flex u-main-space-between">
<p class="text">Total results: {totalResults}</p>
<PaginationInline limit={5} bind:offset sum={totalResults} hidePages />
<div class="u-flex u-flex-vertical u-gap-32">
<p class="text">
Select existing topics you want to send this message to its targets. The message will be
sent only to <ProviderType type={providerType} noIcon /> targets.
</p>
<div class="u-flex u-flex-vertical u-gap-16">
<InputSearch
autofocus
disabled={totalResults === 0 && !search}
placeholder="Search for topics"
bind:value={search} />
<div>
{#if Object.keys(topicResultsById).length > 0}
<div class="u-flex-vertical u-gap-8">
<FormList>
<Table noMargin noStyles>
<TableBody>
{#each Object.entries(topicResultsById) as [topicId, topic]}
<TableRow>
<TableCell>
<InputCheckbox
id={topicId}
disabled={!!topicsById[topicId]}
checked={!!selected[topicId]}
on:change={(event) =>
onTopicSelection(event, topic)}>
<svelte:fragment slot="description">
<span class="title">
<span class="u-line-height-1-5">
<span
class="body-text-1 u-bold"
data-private>
{topic.name}
</span>
<span
class="collapsible-button-optional"
style="--p-toggle-optional-color: var(--color-neutral-50);">
({getTotal(topic)} targets)
</span>
</span></span>
</svelte:fragment>
</InputCheckbox>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</FormList>
<div class="u-flex u-main-space-between u-cross-center">
<p class="text">Total results: {totalResults}</p>
<PaginationInline limit={5} bind:offset sum={totalResults} hidePages />
</div>
</div>
{:else if search}
<EmptySearch hidePagination>
<div class="common-section">
<div class="u-text-center common-section">
<b class="body-text-2 u-bold">Sorry we couldn't find "{search}"</b>
<p>There are no topics that match your search.</p>
</div>
<div class="u-flex u-gap-16 common-section u-main-center">
<Button
external
href="https://appwrite.io/docs/products/messaging/topics"
text>Documentation</Button>
<Button secondary on:click={() => (search = '')}
>Clear search</Button>
</div>
</div>
</EmptySearch>
{:else}
<EmptySearch hidePagination>
<div class="common-section">
<div class="u-text-center common-section">
<p class="text u-line-height-1-5">
You have no topics. Create a topic to see them here.
</p>
<p class="text u-line-height-1-5">
Need a hand? Learn more in our <Button
link
external
href="https://appwrite.io/docs/products/messaging/topics"
>documentation</Button
>.
</p>
</div>
</div>
</EmptySearch>
{/if}
</div>
</div>
{:else if search}
<EmptySearch hidePages>
<div class="common-section">
<div class="u-text-center common-section">
<b class="body-text-2 u-bold">Sorry we couldn't find "{search}"</b>
<p>There are no topics that match your search.</p>
</div>
<div class="u-flex u-gap-16 common-section u-main-center">
<Button external href="https://appwrite.io/docs/products/messaging/topics" text
>Documentation</Button>
<Button secondary on:click={() => (search = '')}>Clear search</Button>
</div>
</div>
</EmptySearch>
{:else}
<EmptySearch hidePages>
<div class="common-section">
<div class="u-text-center common-section">
<p class="text u-line-height-1-5">
You have no topics. Create a topic to see them here.
</p>
<p class="text u-line-height-1-5">
Need a hand? Learn more in our <a
href="https://appwrite.io/docs/products/messaging/topics"
target="_blank"
rel="noopener noreferrer">
documentation</a
>.
</p>
</div>
</div>
</EmptySearch>
{/if}
</div>
<svelte:fragment slot="footer">
<Button submit disabled={!hasSelection}>Add</Button>
<div class="u-flex u-gap-16 u-cross-center">
<div class="u-flex u-gap-8">
<span class="inline-tag"><span class="text">{selectedSize}</span></span>
<span class="body-text-2">Topics selected</span>
</div>
<Button submit disabled={!hasSelection}>Add</Button>
</div>
</svelte:fragment>
</Modal>
@@ -24,6 +24,8 @@
let totalResults = 0;
let userResultsById: Record<string, Models.User<Record<string, unknown>>> = {}; // use a hash map so we can quickly look up a user by id
let selected: Record<string, Models.Target> = {};
let selectedSize = 0;
let selectedUsers = 0;
let hasSelection = false;
function reset() {
@@ -108,7 +110,16 @@
request();
}
$: selectedSize = Object.keys(selected).length;
$: {
selectedSize = 0;
const users = new Set();
for (const s in selected) {
const target = selected[s];
users.add(target.userId);
selectedSize++;
}
selectedUsers = users.size;
}
$: hasSelection = selectedSize > 0;
@@ -118,120 +129,160 @@
</script>
<Modal {title} bind:show onSubmit={submit} on:close={reset} size="big" headerDivider={false}>
<slot name="description" />
<div class="u-flex u-flex-vertical u-gap-32">
<slot name="description" />
<InputSearch
autofocus
disabled={totalResults === 0 && !search}
placeholder="Search by name, email, phone or ID"
bind:value={search} />
{#if Object.keys(userResultsById).length > 0}
<div class="u-sep-block-end">
<Collapsible>
{#each Object.entries(userResultsById) as [userId, user] (userId)}
{@const selectedCount = user.targets.filter(
(target) => selected[target.$id]
).length}
<CollapsibleItem withIndentation>
<svelte:fragment slot="beforetitle">
<InputCheckbox
id={userId}
size="small"
disabled={user.targets.length > 0 &&
user.targets.every((target) => targetsById[target.$id])}
checked={selectedCount > 0 && selectedCount === user.targets.length}
on:change={(event) => onUserSelection(event, userId)} />
</svelte:fragment>
<svelte:fragment slot="title">
<span class="u-line-height-1-5">
<span
class="body-text-1 u-bold"
style="color:hsl(var(--color-neutral-80))"
data-private>
{#if user.name}
{user.name}
{:else if user.email}
{user.email}
{:else if user.phone}
{user.phone}
{:else}
{userId}
{/if}
</span>
</span>
</svelte:fragment>
<svelte:fragment slot="subtitle">
({selectedCount}/{user.targets.length} targets)
</svelte:fragment>
<FormList>
{#each user.targets as target}
<div class="u-flex">
<InputCheckbox
id={target.$id}
size="small"
disabled={!!targetsById[target.$id]}
checked={!!selected[target.$id]}
on:change={(event) => onTargetSelection(event, target)}>
<svelte:fragment slot="description">
<div class="u-inline-flex u-gap-8">
<span class="inline-tag u-normal u-x-small"
><ProviderType
type={target.providerType}
noIcon /></span>
{#if target.providerType !== MessagingProviderType.Push}
{target.identifier}
{:else}
{target.name}
{/if}
</div>
<div class="u-flex u-flex-vertical u-gap-16">
<InputSearch
autofocus
disabled={totalResults === 0 && !search}
placeholder="Search by name, email, phone or ID"
bind:value={search} />
<div>
{#if Object.keys(userResultsById).length > 0}
<div class="u-flex-vertical u-gap-8">
<div class="u-sep-block-end">
<Collapsible>
{#each Object.entries(userResultsById) as [userId, user] (userId)}
{@const selectedCount = user.targets.filter(
(target) => selected[target.$id]
).length}
<CollapsibleItem
withIndentation
disabled={!user.targets.length}>
<svelte:fragment slot="beforetitle">
<InputCheckbox
id={userId}
size="small"
disabled={!user.targets.length ||
(user.targets.length > 0 &&
user.targets.every(
(target) => targetsById[target.$id]
))}
checked={selectedCount > 0 &&
selectedCount === user.targets.length}
on:change={(event) =>
onUserSelection(event, userId)} />
</svelte:fragment>
</InputCheckbox>
</div>
{/each}
</FormList>
</CollapsibleItem>
{/each}
</Collapsible>
</div>
<div class="u-flex u-main-space-between">
<p class="text">Total results: {totalResults}</p>
<PaginationInline limit={5} bind:offset sum={totalResults} hidePages />
</div>
{:else if search}
<EmptySearch hidePages>
<div class="common-section">
<div class="u-text-center common-section">
<b class="body-text-2 u-bold">Sorry we couldn't find "{search}"</b>
<p>There are no Users that match your search.</p>
</div>
<div class="u-flex u-gap-16 common-section u-main-center">
<Button external href="https://appwrite.io/docs/products/auth/accounts" text
>Documentation</Button>
<Button secondary on:click={() => (search = '')}>Clear search</Button>
</div>
<svelte:fragment slot="title">
<span class="u-line-height-1-5">
<span
class="user-name body-text-1 u-bold"
data-private>
{#if user.name}
{user.name}
{:else if user.email}
{user.email}
{:else if user.phone}
{user.phone}
{:else}
{userId}
{/if}
</span>
</span>
</svelte:fragment>
<svelte:fragment slot="subtitle">
{#if user.targets.length === 0}
(0 targets)
{:else}
({selectedCount}/{user.targets.length} targets)
{/if}
</svelte:fragment>
<FormList>
{#each user.targets as target}
<div class="u-flex">
<InputCheckbox
id={target.$id}
size="small"
disabled={!!targetsById[target.$id]}
checked={!!selected[target.$id]}
on:change={(event) =>
onTargetSelection(event, target)}>
<svelte:fragment slot="description">
<div class="u-inline-flex u-gap-8">
<span
class="inline-tag u-normal u-x-small"
><ProviderType
type={target.providerType}
noIcon /></span>
{#if target.providerType !== MessagingProviderType.Push}
{target.identifier}
{:else}
{target.name}
{/if}
</div>
</svelte:fragment>
</InputCheckbox>
</div>
{/each}
</FormList>
</CollapsibleItem>
{/each}
</Collapsible>
</div>
<div class="u-flex u-main-space-between u-cross-center">
<p class="text">Total results: {totalResults}</p>
<PaginationInline limit={5} bind:offset sum={totalResults} hidePages />
</div>
</div>
{:else if search}
<EmptySearch hidePagination>
<div class="common-section">
<div class="u-text-center common-section">
<b class="body-text-2 u-bold">Sorry we couldn't find "{search}"</b>
<p>There are no Users that match your search.</p>
</div>
<div class="u-flex u-gap-16 common-section u-main-center">
<Button
external
href="https://appwrite.io/docs/products/auth/accounts"
text>Documentation</Button>
<Button secondary on:click={() => (search = '')}
>Clear search</Button>
</div>
</div>
</EmptySearch>
{:else}
<EmptySearch hidePagination>
<div class="common-section">
<div class="u-text-center common-section">
<p class="text u-line-height-1-5">
You have no users. Create a user to see them here.
</p>
<p class="text u-line-height-1-5">
Need a hand? Learn more in our <Button
link
external
href="https://appwrite.io/docs/products/auth/quick-start"
>documentation</Button
>.
</p>
</div>
</div>
</EmptySearch>
{/if}
</div>
</EmptySearch>
{:else}
<EmptySearch hidePages>
<div class="common-section">
<div class="u-text-center common-section">
<p class="text u-line-height-1-5">
You have no users. Create a user to see them here.
</p>
<p class="text u-line-height-1-5">
Need a hand? Learn more in our <a
href="https://appwrite.io/docs/products/auth/quick-start"
target="_blank"
rel="noopener noreferrer">
documentation</a
>.
</p>
</div>
</div>
</EmptySearch>
{/if}
</div>
</div>
<svelte:fragment slot="footer">
<Button submit disabled={!hasSelection}>Add</Button>
<div class="u-flex u-gap-16 u-cross-center">
<div class="u-flex u-gap-8">
<span class="inline-tag"><span class="text">{selectedUsers}</span></span>
<span class="body-text-2">Users selected</span>
</div>
<Button submit disabled={!hasSelection}>Add</Button>
</div>
</svelte:fragment>
</Modal>
<style lang="scss">
:global(.collapsible-wrapper:not(.is-disabled)) {
.user-name {
color: hsl(var(--color-neutral-80));
:global(.theme-dark) & {
color: hsl(var(--color-neutral-20));
}
}
}
</style>
@@ -1,263 +0,0 @@
<script lang="ts">
import { Wizard } from '$lib/layout';
import type { WizardStepsType } from '$lib/layout/wizard.svelte';
import Step1 from './wizard/step1.svelte';
import Step2 from './wizard/step2.svelte';
import Step3 from './wizard/step3.svelte';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { goto, invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { project } from '../store';
import { wizard } from '$lib/stores/wizard';
import { providerType, messageParams, operation } from './wizard/store';
import { ID, MessageStatus, MessagingProviderType, type Models } from '@appwrite.io/console';
import { Dependencies } from '$lib/constants';
async function create() {
try {
let response: Models.Message;
const messageId = $messageParams[$providerType].messageId || ID.unique();
switch ($providerType) {
case MessagingProviderType.Email:
response = await sdk.forProject.messaging.createEmail(
messageId,
$messageParams[$providerType].subject,
$messageParams[$providerType].content,
$messageParams[$providerType].topics,
$messageParams[$providerType].users,
$messageParams[$providerType].targets,
undefined,
undefined,
undefined,
$messageParams[$providerType].status,
$messageParams[$providerType].html,
$messageParams[$providerType].scheduledAt
);
break;
case MessagingProviderType.Sms:
response = await sdk.forProject.messaging.createSms(
messageId,
$messageParams[$providerType].content,
$messageParams[$providerType].topics,
$messageParams[$providerType].users,
$messageParams[$providerType].targets,
$messageParams[$providerType].status,
$messageParams[$providerType].scheduledAt
);
break;
case MessagingProviderType.Push:
{
const customData: Record<string, string> = {};
const { data } = $messageParams[MessagingProviderType.Push];
if (data && data.length > 0) {
data.forEach((item) => {
if (item[0] === '') return;
customData[item[0]] = item[1];
});
}
response = await sdk.forProject.messaging.createPush(
messageId,
$messageParams[$providerType].title,
$messageParams[$providerType].body,
$messageParams[$providerType].topics,
$messageParams[$providerType].users,
$messageParams[$providerType].targets,
customData,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
$messageParams[$providerType].status,
$messageParams[$providerType].scheduledAt
);
}
break;
}
wizard.hide();
let message = '';
switch (response.status) {
case MessageStatus.Draft:
message = 'The message has been saved as draft.';
break;
case MessageStatus.Processing:
message = 'The message is queued for processing.';
break;
case 'scheduled':
// TODO: fix message status
message = 'The message has been scheduled.';
break;
}
addNotification({
type: 'success',
message
});
trackEvent(Submit.MessagingMessageCreate, {
providerType: $providerType,
status: response.status
});
await goto(`${base}/console/project-${$project.$id}/messaging/message-${response.$id}`);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingMessageCreate);
}
}
async function update() {
try {
let response: Models.Message;
const messageId = $messageParams[$providerType].messageId;
const params = $messageParams[$providerType];
console.log(params);
switch ($providerType) {
case MessagingProviderType.Email:
response = await sdk.forProject.messaging.updateEmail(
messageId,
$messageParams[$providerType].topics,
$messageParams[$providerType].users,
$messageParams[$providerType].targets,
$messageParams[$providerType].subject,
$messageParams[$providerType].content,
$messageParams[$providerType].status,
$messageParams[$providerType].html,
undefined,
undefined,
$messageParams[$providerType].scheduledAt
);
break;
case MessagingProviderType.Sms:
response = await sdk.forProject.messaging.updateSms(
messageId,
$messageParams[$providerType].topics,
$messageParams[$providerType].users,
$messageParams[$providerType].targets,
$messageParams[$providerType].content,
$messageParams[$providerType].status,
$messageParams[$providerType].scheduledAt
);
break;
case MessagingProviderType.Push:
{
const customData: Record<string, string> = {};
const { data } = $messageParams[MessagingProviderType.Push];
if (data && data.length > 0) {
data.forEach((item) => {
if (item[0] === '') return;
customData[item[0]] = item[1];
});
}
response = await sdk.forProject.messaging.updatePush(
messageId,
$messageParams[$providerType].topics,
$messageParams[$providerType].users,
$messageParams[$providerType].targets,
$messageParams[$providerType].title,
$messageParams[$providerType].body,
customData,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
$messageParams[$providerType].status,
$messageParams[$providerType].scheduledAt
);
}
break;
}
wizard.hide();
let message = '';
switch (response.status) {
case MessageStatus.Draft:
message = 'The message has been saved as draft.';
break;
case MessageStatus.Processing:
message = 'The message is queued for processing.';
break;
case 'scheduled':
// TODO: fix message status
message = 'The message has been scheduled.';
break;
}
addNotification({
type: 'success',
message
});
await invalidate(Dependencies.MESSAGING_MESSAGE);
trackEvent(Submit.MessagingMessageUpdate, {
providerType: $providerType,
status: params.status
});
await goto(`${base}/console/project-${$project.$id}/messaging/message-${response.$id}`);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingMessageUpdate);
}
}
async function saveDraft() {
$messageParams[$providerType].status = MessageStatus.Draft;
if ($operation === 'create') {
create();
} else {
update();
}
}
const stepsComponents: WizardStepsType = new Map();
stepsComponents.set(1, {
label: 'Message',
component: Step1,
actions: [
{
label: 'Save as draft',
onClick: saveDraft
}
]
});
stepsComponents.set(2, {
label: 'Targets',
component: Step2,
actions: [
{
label: 'Save as draft',
onClick: saveDraft
}
]
});
stepsComponents.set(3, {
label: 'Schedule',
component: Step3,
actions: [
{
label: 'Save as draft',
onClick: saveDraft
}
]
});
</script>
<Wizard
title={$operation === 'create' ? 'Create message' : 'Update message'}
steps={stepsComponents}
on:finish={$operation === 'create' ? create : update}
finalAction="Send" />
@@ -1,5 +1,5 @@
<script lang="ts">
import { messageParams, providerType, operation } from './store';
import { messageParams, providerType } from './store';
import {
Button,
FormList,
@@ -10,10 +10,10 @@
InputTextarea
} from '$lib/elements/forms';
import { Pill } from '$lib/elements';
import { CustomId, Modal } from '$lib/components';
import { Modal } from '$lib/components';
import { user } from '$lib/stores/user';
import { clickOnEnter } from '$lib/helpers/a11y';
import { ID, MessageStatus, MessagingProviderType } from '@appwrite.io/console';
import { ID, MessagingProviderType } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
let showCustomId = false;
@@ -36,7 +36,7 @@
undefined,
undefined,
undefined,
MessageStatus.Processing,
undefined,
$messageParams[MessagingProviderType.Email]?.html || false,
undefined
);
@@ -49,6 +49,7 @@
<InputText
id="subject"
label="Subject"
required
placeholder="Enter subject"
bind:value={$messageParams[$providerType]['subject']}>
</InputText>
@@ -56,10 +57,10 @@
<InputTextarea
id="message"
label="Message"
required
placeholder="Type here..."
bind:value={$messageParams[$providerType]['content']}>
</InputTextarea>
<!-- TODO: Add support for draft messages -->
<!-- <div class="u-flex u-main-end">
<Button text on:click={() => (showTest = true)}>Send test message</Button>
</div> -->
@@ -105,20 +106,12 @@
Enable the HTML mode if your message contains HTML tags.
</svelte:fragment>
</InputSwitch>
{#if $operation === 'create'}
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}
><span class="icon-pencil" aria-hidden="true" /><span class="text">
Message ID
</span></Pill>
</div>
{:else}
<CustomId
bind:show={showCustomId}
name="Message"
bind:id={$messageParams[$providerType].messageId}
autofocus={false} />
{/if}
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}
><span class="icon-pencil" aria-hidden="true" /><span class="text">
Message ID
</span></Pill>
</div>
{/if}
</FormList>
@@ -24,7 +24,7 @@
</script>
<script lang="ts">
import { messageParams, providerType, operation } from './store';
import { messageParams, providerType } from './store';
import {
Button,
FormItem,
@@ -38,10 +38,10 @@
Label
} from '$lib/elements/forms';
import { Pill } from '$lib/elements';
import { CustomId, Modal } from '$lib/components';
import { Modal } from '$lib/components';
import { user } from '$lib/stores/user';
import { clickOnEnter } from '$lib/helpers/a11y';
import { ID, MessageStatus, MessagingProviderType } from '@appwrite.io/console';
import { ID, MessagingProviderType } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import PushPhone from '../pushPhone.svelte';
import { onMount } from 'svelte';
@@ -76,7 +76,7 @@
undefined,
undefined,
undefined,
MessageStatus.Processing,
undefined,
undefined
);
}
@@ -91,6 +91,7 @@
<InputText
id="title"
label="Title"
required
placeholder="Enter title"
bind:value={$messageParams[MessagingProviderType.Push]['title']}>
</InputText>
@@ -99,6 +100,7 @@
id="message"
label="Message"
placeholder="Type here..."
required
maxlength={1000}
bind:value={$messageParams[MessagingProviderType.Push]['body']}>
</InputTextarea>
@@ -214,21 +216,13 @@
</Button>
</div>
</form>
{#if $operation === 'create'}
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}
><span class="icon-pencil" aria-hidden="true" /><span class="text">
Message ID
</span></Pill>
</div>
{:else}
<CustomId
bind:show={showCustomId}
name="Message"
bind:id={$messageParams[$providerType].messageId}
autofocus={false} />
{/if}
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}
><span class="icon-pencil" aria-hidden="true" /><span class="text">
Message ID
</span></Pill>
</div>
{/if}
</FormList>
<PushPhone
@@ -1,11 +1,11 @@
<script lang="ts">
import { messageParams, providerType, operation } from './store';
import { messageParams, providerType } from './store';
import { Button, FormList, InputEmail, InputRadio, InputTextarea } from '$lib/elements/forms';
import { Pill } from '$lib/elements';
import { CustomId, Modal } from '$lib/components';
import { Modal } from '$lib/components';
import { user } from '$lib/stores/user';
import { clickOnEnter } from '$lib/helpers/a11y';
import { ID, MessageStatus, MessagingProviderType } from '@appwrite.io/console';
import { ID, MessagingProviderType } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import SMSPhone from '../smsPhone.svelte';
@@ -24,7 +24,7 @@
$messageParams[MessagingProviderType.Sms]?.topics || [],
$messageParams[MessagingProviderType.Sms]?.users || [],
$messageParams[MessagingProviderType.Sms]?.targets || [],
MessageStatus.Processing,
undefined,
undefined
);
}
@@ -38,8 +38,9 @@
<InputTextarea
id="message"
label="Message"
placeholder="Type here..."
required
maxlength={900}
placeholder="Type here..."
bind:value={$messageParams[$providerType]['content']}>
</InputTextarea>
<!-- TODO: Add support for draft messages -->
@@ -83,21 +84,13 @@
</svelte:fragment>
</Modal>
</div>
{#if $operation === 'create'}
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}
><span class="icon-pencil" aria-hidden="true" /><span class="text">
Message ID
</span></Pill>
</div>
{:else}
<CustomId
bind:show={showCustomId}
name="Message"
bind:id={$messageParams[$providerType].messageId}
autofocus={false} />
{/if}
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}
><span class="icon-pencil" aria-hidden="true" /><span class="text">
Message ID
</span></Pill>
</div>
{/if}
</FormList>
<SMSPhone content={$messageParams[$providerType]['content']} classes="is-only-desktop" />
@@ -41,13 +41,19 @@
$targetsById = rest;
}
async function beforeSubmit() {
if (topicIdsLength === 0 && targetIdsLength === 0) {
throw new Error('At least one topic or target is required.');
}
}
$: topicIdsLength = Object.keys($topicsById).length;
$: targetIdsLength = Object.keys($targetsById).length;
$: $messageParams[$providerType].targets = Object.keys($targetsById);
$: $messageParams[$providerType].topics = Object.keys($topicsById);
</script>
<WizardStep>
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">Targets</svelte:fragment>
<svelte:fragment slot="subtitle">
Select targets to whom this message should be directed.
@@ -89,7 +95,7 @@
{topic.name}
</span>
<span class="collapsible-button-optional">
({getTotal(topic)} subscribers)
({getTotal(topic)} targets)
</span>
</span></span>
</div>
@@ -2,12 +2,12 @@
import { Button, FormList, InputDate, InputSelect, InputTime } from '$lib/elements/forms';
import Helper from '$lib/elements/forms/helper.svelte';
import { WizardStep } from '$lib/layout';
import { MessageStatus, MessagingProviderType } from '@appwrite.io/console';
import { MessagingProviderType } from '@appwrite.io/console';
import { messageParams, providerType } from './store';
import { isSameDay, toLocaleDateISO, toLocaleTimeISO } from '$lib/helpers/date';
let when: 'now' | 'later' = 'now';
let now = new Date();
let timeZoneOffset: number;
let minDate: string;
let date: string;
let time: string;
@@ -40,30 +40,23 @@
timeZoneName: 'longGeneric'
};
async function beforeSubmit() {
$messageParams[$providerType].status = MessageStatus.Processing;
if (when === 'later') {
$messageParams[$providerType].status = 'scheduled' as MessageStatus;
$messageParams[$providerType].scheduledAt = dateTime.toISOString();
}
}
$: if (when === 'now') {
date = time = '';
}
$: if (when === 'later') {
now = new Date();
}
$: timeZoneOffset = now ? now.getTimezoneOffset() * 60 * 1000 : 0;
$: minDate = new Date(now.getTime() - timeZoneOffset).toISOString().split('T')[0];
$: minTime =
date === minDate
? new Date(now.getTime() - timeZoneOffset).toISOString().split('T')[1].substring(0, 5)
: '00:00';
$: minDate = toLocaleDateISO(now.getTime());
$: minTime = isSameDay(new Date(date), new Date(minDate))
? toLocaleTimeISO(now.getTime())
: '00:00';
$: dateTime = new Date(`${date}T${time}`);
$: if (!isNaN(dateTime.getTime())) {
$messageParams[$providerType].scheduledAt = dateTime.toISOString();
}
</script>
<WizardStep {beforeSubmit}>
<WizardStep>
<svelte:fragment slot="title">Schedule</svelte:fragment>
<svelte:fragment slot="subtitle"
>Schedule the time you want to deliver this message. Learn more in our <Button
@@ -1,4 +1,4 @@
import { MessageStatus, MessagingProviderType, type Models } from '@appwrite.io/console';
import { MessagingProviderType, type Models } from '@appwrite.io/console';
import { get, writable } from 'svelte/store';
export type MessageParams = {
@@ -6,7 +6,7 @@ export type MessageParams = {
topics: string[];
users: string[];
targets: string[];
status: MessageStatus;
draft: boolean;
scheduledAt?: string;
};
@@ -32,7 +32,6 @@ export type PushMessageParams = MessageParams & {
badge?: string;
};
export const operation = writable<'create' | 'update'>('create');
export const providerType = writable<MessagingProviderType>(null);
export const targetsById = writable<Record<string, Models.Target>>({});
export const messageParams = writable<{
@@ -6,7 +6,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">Create an API key</svelte:fragment>
<svelte:fragment slot="title">API key</svelte:fragment>
<svelte:fragment slot="subtitle">Let's create an API key.</svelte:fragment>
<FormList>
<InputText
@@ -6,7 +6,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">Add Scopes</svelte:fragment>
<svelte:fragment slot="title">Scopes</svelte:fragment>
<svelte:fragment slot="subtitle">
Choose which permission scopes to grant your application. It is best practice to allow only
the permissions you need to meet your project goals.
@@ -41,7 +41,7 @@
</script>
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">Register your Android app</svelte:fragment>
<svelte:fragment slot="title">Android registration</svelte:fragment>
<FormList>
<InputText
id="name"
@@ -10,7 +10,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">Get the SDK</svelte:fragment>
<svelte:fragment slot="title">Install</svelte:fragment>
<p>First, add this to your root level <code class="inline-code">build.gradle</code> file.</p>
<Code label="Groovy" labelIcon="android" language="kotlin" code={example1} withCopy />
<p class="common-section">And add this to your project's build.gradle file.</p>
@@ -15,7 +15,7 @@ val client = Client(context)
</script>
<WizardStep>
<svelte:fragment slot="title">Initialize SDK</svelte:fragment>
<svelte:fragment slot="title">Initialize</svelte:fragment>
<h2 class="heading-level-7">Initialize your SDK</h2>
<p>
@@ -36,7 +36,7 @@
</script>
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">Register your bundle ID</svelte:fragment>
<svelte:fragment slot="title">Apple registration</svelte:fragment>
<FormList isCommonSection>
<FormItem>
@@ -29,7 +29,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">Get the SDK</svelte:fragment>
<svelte:fragment slot="title">Install</svelte:fragment>
<div class="u-flex u-gap-16 u-margin-block-start-8">
<Pill button on:click={() => (method = Method.Xcode)} selected={method === Method.Xcode}>
Xcode
@@ -17,7 +17,7 @@ let client = Client()
</script>
<WizardStep>
<svelte:fragment slot="title">Initialize SDK</svelte:fragment>
<svelte:fragment slot="title">Initialize</svelte:fragment>
<h2 class="heading-level-7">Initialize your SDK</h2>
<p>
@@ -149,16 +149,16 @@
$: registee = {
[PlatformType.Flutterandroid]: 'package name',
[PlatformType.Flutterios]: 'bundle ID',
[PlatformType.Flutterlinux]: 'package name',
[PlatformType.Fluttermacos]: 'bundle ID',
[PlatformType.Flutterwindows]: 'package name',
[PlatformType.Flutterweb]: 'hostname'
[PlatformType.Flutterios]: 'Bundle ID',
[PlatformType.Flutterlinux]: 'Package name',
[PlatformType.Fluttermacos]: 'Bundle ID',
[PlatformType.Flutterwindows]: 'Package name',
[PlatformType.Flutterweb]: 'Hostname'
}[platform];
</script>
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">Register your {registee}</svelte:fragment>
<svelte:fragment slot="title">{registee} registration</svelte:fragment>
<svelte:fragment slot="subtitle">
<div class="u-flex u-gap-16 u-margin-block-start-8 u-flex-wrap">
<Pill
@@ -9,7 +9,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">Get the SDK</svelte:fragment>
<svelte:fragment slot="title">Install</svelte:fragment>
<p>
Add Appwrite SDK to your package's <code class="inline-code">pubspec.yaml</code> file. You can
@@ -18,7 +18,7 @@ client
</script>
<WizardStep>
<svelte:fragment slot="title">Initialize SDK</svelte:fragment>
<svelte:fragment slot="title">Initialize</svelte:fragment>
<h2 class="heading-level-7">Initialize your SDK</h2>
<p data-private>
@@ -6,7 +6,7 @@
</script>
<WizardStep>
<svelte:fragment slot="title">You're ready to go 🎉</svelte:fragment>
<svelte:fragment slot="title">All set</svelte:fragment>
<p>
{#if isFirstProject}
Congratulations on adding the first platform to your project! Start exploring by
@@ -44,7 +44,7 @@
</script>
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">Register your hostname</svelte:fragment>
<svelte:fragment slot="title">Hostname registration</svelte:fragment>
<FormList>
<InputText
id="name"

Some files were not shown because too many files have changed in this diff Show More