From f5d8a2051612c3c4c3e15b1257b34fa9a597a481 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Wed, 24 Jul 2024 09:26:13 +0200 Subject: [PATCH 01/15] GH-287: Updated Privacy Policy and Terms of Service. --- src/common/policies/privacy.html | 2 +- src/common/policies/terms.html | 3 +-- src/common/static/policies/privacy.json | 2 +- src/common/static/policies/terms.json | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/common/policies/privacy.html b/src/common/policies/privacy.html index 86db55db..c724359b 100644 --- a/src/common/policies/privacy.html +++ b/src/common/policies/privacy.html @@ -26,7 +26,7 @@

How communication is stored

Your browser may also be configured to store communication for your own personal use. This information is not shared with us.

Where the information is stored

-

Mucklet AB is located in Sweden, which is part of EU. All the information will be processed and stored in Sweden.

+

Mucklet AB is located in Sweden, which is part of EU. All the information will be processed and stored within EU.

Cookies

We use cookies. But the cookie that we store locally on your webbrowser does not contain any identifying information; it is only used to keep you logged in.

diff --git a/src/common/policies/terms.html b/src/common/policies/terms.html index 53b3fe3b..26b1bc67 100644 --- a/src/common/policies/terms.html +++ b/src/common/policies/terms.html @@ -55,8 +55,7 @@

Prohibited content

If an image is not a photo, but may be mistaken for one, it will be regarded as a photo.

diff --git a/src/common/static/policies/privacy.json b/src/common/static/policies/privacy.json index e5dcc74f..6ee5bbe0 100644 --- a/src/common/static/policies/privacy.json +++ b/src/common/static/policies/privacy.json @@ -2,5 +2,5 @@ "title": "Mucklet's Privacy Policy", "slug": "privacy", "created": 1688049624253, - "body": "

This service is hosted by Mucklet AB, which is the \"we\", \"us\" and \"our\" in this Privacy Policy.

\n

We don't really want your data. But we need some of it in order to make all of\nthis work, to provide a fun, friendly, and safe place to hang out. And to help\nus make it even better.

\n\n

What information we collect

\n

This is an online service. This means we store the information you give to us, such as username, email address, game settings, character information, and more. That is how it works.

\n

We may also automatically gather information such as IP-address, device/browser being used, or the browser's language settings, to help us improve and protect the service.

\n

In case of payments, we also store required geographical information, such as country and postal code, to be able to comply with global tax laws.

\n\n

How the information is collected

\n

We store it when you visit the website, register your user account, and when you send us the information. It is pretty straight forward.

\n\n

How the information is used

\n

We use the information to run the game. We don't sell any personal information of our users. We don't analyze any information or communication to target ads. We don't even run ads. We use the information for the purpose you have given it to us.

\n

Email, primarily used for restoring lost passwords, may also be used to send information on events directly related to the game. But you can opt out of this under the in-game settings.

\n\n

How passwords are stored

\n

If you use password login, instead of an OpenID service such as Google, we need to store your password. But we ensure your password is stored securely. In fact, we also pre-hash the password in your browser before sending it to us, so that we will never handle your actual password.

\n\n

How content is stored

\n

Content that you create or upload to the game, such as characters, rooms, images, and more, is stored as part of the game realm. In case you decide to delete your account, your personal information will be deleted but the created content will remain.

\n\n

How communication is stored

\n

Communication within the game is not persisted by default, and therefore not part of any backups - with the exception of in-game mail which by nature requires storage. In case a user makes a report against another player's character, communication available to the reporter may be stored to allow moderators to assess the report.

\n

Your browser may also be configured to store communication for your own personal use. This information is not shared with us.

\n\n

Where the information is stored

\n

Mucklet AB is located in Sweden, which is part of EU. All the information will be processed and stored in Sweden.

\n\n

Cookies

\n

We use cookies. But the cookie that we store locally on your webbrowser does not contain any identifying information; it is only used to keep you logged in.

\n\n

Disclosure of information

\n

We are not into the business of selling your information. We don't want to disclose your information. There are, however, some circumstances in which we may still have to do it:

\n\n\n\n

Children or minors

\n

We don't allow users under the age of 18 to register. We do not knowingly collect personal information from minors.\n\n

Contacting us

\n

If you have any questions regarding this Privacy Policy or how your information is handled, feel free to ask us by email: privacy@mucklet.com

\n\n

Changes to this privacy policy

\n

This Privacy Policy may be updated at any time.

" + "body": "

This service is hosted by Mucklet AB, which is the \"we\", \"us\" and \"our\" in this Privacy Policy.

\n

We don't really want your data. But we need some of it in order to make all of\nthis work, to provide a fun, friendly, and safe place to hang out. And to help\nus make it even better.

\n\n

What information we collect

\n

This is an online service. This means we store the information you give to us, such as username, email address, game settings, character information, and more. That is how it works.

\n

We may also automatically gather information such as IP-address, device/browser being used, or the browser's language settings, to help us improve and protect the service.

\n

In case of payments, we also store required geographical information, such as country and postal code, to be able to comply with global tax laws.

\n\n

How the information is collected

\n

We store it when you visit the website, register your user account, and when you send us the information. It is pretty straight forward.

\n\n

How the information is used

\n

We use the information to run the game. We don't sell any personal information of our users. We don't analyze any information or communication to target ads. We don't even run ads. We use the information for the purpose you have given it to us.

\n

Email, primarily used for restoring lost passwords, may also be used to send information on events directly related to the game. But you can opt out of this under the in-game settings.

\n\n

How passwords are stored

\n

If you use password login, instead of an OpenID service such as Google, we need to store your password. But we ensure your password is stored securely. In fact, we also pre-hash the password in your browser before sending it to us, so that we will never handle your actual password.

\n\n

How content is stored

\n

Content that you create or upload to the game, such as characters, rooms, images, and more, is stored as part of the game realm. In case you decide to delete your account, your personal information will be deleted but the created content will remain.

\n\n

How communication is stored

\n

Communication within the game is not persisted by default, and therefore not part of any backups - with the exception of in-game mail which by nature requires storage. In case a user makes a report against another player's character, communication available to the reporter may be stored to allow moderators to assess the report.

\n

Your browser may also be configured to store communication for your own personal use. This information is not shared with us.

\n\n

Where the information is stored

\n

Mucklet AB is located in Sweden, which is part of EU. All the information will be processed and stored within EU.

\n\n

Cookies

\n

We use cookies. But the cookie that we store locally on your webbrowser does not contain any identifying information; it is only used to keep you logged in.

\n\n

Disclosure of information

\n

We are not into the business of selling your information. We don't want to disclose your information. There are, however, some circumstances in which we may still have to do it:

\n\n\n\n

Children or minors

\n

We don't allow users under the age of 18 to register. We do not knowingly collect personal information from minors.\n\n

Contacting us

\n

If you have any questions regarding this Privacy Policy or how your information is handled, feel free to ask us by email: privacy@mucklet.com

\n\n

Changes to this privacy policy

\n

This Privacy Policy may be updated at any time.

" } diff --git a/src/common/static/policies/terms.json b/src/common/static/policies/terms.json index 1817d1e4..96cdc718 100644 --- a/src/common/static/policies/terms.json +++ b/src/common/static/policies/terms.json @@ -2,5 +2,5 @@ "title": "Mucklet's Terms of Service", "slug": "terms", "created": 1688049624253, - "body": "

These Terms of Service (or just \"Terms\") let you know the rules that govern\nour relationship with you as a user of our game and forum, which is collectively\nreferred to as the \"Service\". These Terms form a contract between you and\nMucklet AB (\"we\", \"us\", \"our\"), to let you know what we give you rights to use\nthis Service for, and also what rights you agree to give to us.

\n\n

We reserves the right to update these Terms for reasons including, but not\nlimited to, complying with changes to the law or reflecting enhancements to the\nService. If the changes affect your usage of the Service or your legal rights,\nwe will notify you at least seven days before the changes take effect.

\n\n

By using our Service, you agree to these Terms. Of course, if you don't agree\nwith them, don't use the Service.

\n\n

Who can use the Service

\n

No one under 18 is allowed to create an account or use the Service. This is\nbecause we cannot guarantee that the content is suitable for minors, since much\nof the content is produced by other users. We would also not be able to properly\nsafeguard minors against grooming, or other illicit behavior.

\n\n

Rights we grant you

\n

We own this Service, but we give you the right to use it and enjoy it, in a\nway that these Terms and the in-game rules allow. We may revoke this right at\nany time for reasons including, but not limited to, you using the Service in a\nway not authorized by these Terms (including helping others to do so), or if you\ndo not comply with the in-game rules.

\n\n

Rights you grant us

\n

Our Service allows you to create, send, receive, upload, and store\ninformation; also called \"content\". When you do that, you retain whatever\nownership right in that content you had to begin with. But you grant us the\nright to use that content for the purpose it was provided, even if you decide to\nstop using the Service or delete your account.

\n

You also give us the right to access, review, and delete any content that we\nthink violates these Terms or the in-game rules. You alone, though, remain\nresponsible for the content you provide.

\n\n

Privacy

\n

Our Privacy Policy describes\nhow we store and handle the information you provide to us.

\n\n

The content of others

\n

Just like you keep whatever ownership you had for the content you provide,\nothers will also do so for their content. Whether that content is made public or\nsent to you in private, you may only use that content for your own private\npurpose, unless you get the rights from the owners.

\n

Also, just like you are responsible for the content you provide, others are\nresponsible for the content they provide. Although we reserve the right to\nreview content, we do not necessarily review it all. So we cannot - and do not -\nguarantee that other users' content will comply with these Terms and the in-game\nrules.

\n\n

Prohibited content

\n

You may not upload content that contains:

\n\n

If an image is not a photo, but may be mistaken for one, it will be regarded\nas a photo.

\n\n

Respecting others' rights

\n

You may not provide content that infringes on someone elses intellectual\nproperty. If you use an image, or add content based on someone elses work,\nmake sure you have the rights to do so. And give credits where credit is\ndue.

\n\n

Payments

\n

Our services are free to use, but you may be able to pay for additional\nfeatures and perks. Our Payment\nTerms describe the terms and conditions for payments made to us.\n\n

Permitted use

\n

You must only use the Service in a way permitted by these Terms. This means,\namong other things, you may not do, attempt to do, enable, or encourage anyone\nelse to do, any of the following:

\n\n\n

Disclaimers

\n

We provide this service to you \"as is\" and \"as available\", without warranty\nof any kind. We want to keep it a fun and safe place to hang out, but we cannot\npromise that it will always be available or that it will be fit for any\nparticular purpose.

\n\n

Limitation of Liability

\n

To the extent permitted by law, we don't take responsibility for any negative\nconsequences, direct or indirect, related to using our Service, or inability to\naccess the Service. Such negative consequences includes, but is not limited to,\nloss of data, loss of profit, loss of goodwill, or other intangible losses. And\nyou agree to not hold us, or anyone associated with making this Service\navailable, liable for any such negative consequences.

\n\n

Mucklet AB or its proprietors may not be held accountable for content\nprovided by the users of the Service. We will take reasonable actions to\nmoderate this content in accordance with these Terms.

\n\n

Governing Law

\n

This Agreement shall be governed by and construed in accordance with the laws\nof Sweden. The Swedish court of general jurisdiction, and, in first instance\nthe Örebro District Court (Sw. Örebro tingsrätt), have exclusive jurisdiction to\nsettle any dispute arising out of or in connection with this Agreement.

" + "body": "

These Terms of Service (or just \"Terms\") let you know the rules that govern\nour relationship with you as a user of our game and forum, which is collectively\nreferred to as the \"Service\". These Terms form a contract between you and\nMucklet AB (\"we\", \"us\", \"our\"), to let you know what we give you rights to use\nthis Service for, and also what rights you agree to give to us.

\n\n

We reserves the right to update these Terms for reasons including, but not\nlimited to, complying with changes to the law or reflecting enhancements to the\nService. If the changes affect your usage of the Service or your legal rights,\nwe will notify you at least seven days before the changes take effect.

\n\n

By using our Service, you agree to these Terms. Of course, if you don't agree\nwith them, don't use the Service.

\n\n

Who can use the Service

\n

No one under 18 is allowed to create an account or use the Service. This is\nbecause we cannot guarantee that the content is suitable for minors, since much\nof the content is produced by other users. We would also not be able to properly\nsafeguard minors against grooming, or other illicit behavior.

\n\n

Rights we grant you

\n

We own this Service, but we give you the right to use it and enjoy it, in a\nway that these Terms and the in-game rules allow. We may revoke this right at\nany time for reasons including, but not limited to, you using the Service in a\nway not authorized by these Terms (including helping others to do so), or if you\ndo not comply with the in-game rules.

\n\n

Rights you grant us

\n

Our Service allows you to create, send, receive, upload, and store\ninformation; also called \"content\". When you do that, you retain whatever\nownership right in that content you had to begin with. But you grant us the\nright to use that content for the purpose it was provided, even if you decide to\nstop using the Service or delete your account.

\n

You also give us the right to access, review, and delete any content that we\nthink violates these Terms or the in-game rules. You alone, though, remain\nresponsible for the content you provide.

\n\n

Privacy

\n

Our Privacy Policy describes\nhow we store and handle the information you provide to us.

\n\n

The content of others

\n

Just like you keep whatever ownership you had for the content you provide,\nothers will also do so for their content. Whether that content is made public or\nsent to you in private, you may only use that content for your own private\npurpose, unless you get the rights from the owners.

\n

Also, just like you are responsible for the content you provide, others are\nresponsible for the content they provide. Although we reserve the right to\nreview content, we do not necessarily review it all. So we cannot - and do not -\nguarantee that other users' content will comply with these Terms and the in-game\nrules.

\n\n

Prohibited content

\n

You may not upload content that contains:

\n\n

If an image is not a photo, but may be mistaken for one, it will be regarded\nas a photo.

\n\n

Respecting others' rights

\n

You may not provide content that infringes on someone elses intellectual\nproperty. If you use an image, or add content based on someone elses work,\nmake sure you have the rights to do so. And give credits where credit is\ndue.

\n\n

Payments

\n

Our services are free to use, but you may be able to pay for additional\nfeatures and perks. Our Payment\nTerms describe the terms and conditions for payments made to us.\n\n

Permitted use

\n

You must only use the Service in a way permitted by these Terms. This means,\namong other things, you may not do, attempt to do, enable, or encourage anyone\nelse to do, any of the following:

\n\n\n

Disclaimers

\n

We provide this service to you \"as is\" and \"as available\", without warranty\nof any kind. We want to keep it a fun and safe place to hang out, but we cannot\npromise that it will always be available or that it will be fit for any\nparticular purpose.

\n\n

Limitation of Liability

\n

To the extent permitted by law, we don't take responsibility for any negative\nconsequences, direct or indirect, related to using our Service, or inability to\naccess the Service. Such negative consequences includes, but is not limited to,\nloss of data, loss of profit, loss of goodwill, or other intangible losses. And\nyou agree to not hold us, or anyone associated with making this Service\navailable, liable for any such negative consequences.

\n\n

Mucklet AB or its proprietors may not be held accountable for content\nprovided by the users of the Service. We will take reasonable actions to\nmoderate this content in accordance with these Terms.

\n\n

Governing Law

\n

This Agreement shall be governed by and construed in accordance with the laws\nof Sweden. The Swedish court of general jurisdiction, and, in first instance\nthe Örebro District Court (Sw. Örebro tingsrätt), have exclusive jurisdiction to\nsettle any dispute arising out of or in connection with this Agreement.

" } From e682b0f561be78d2c51f7e6043a621475ee82830 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Wed, 24 Jul 2024 09:26:13 +0200 Subject: [PATCH 02/15] GH-287: Updated Privacy Policy and Terms of Service. --- src/common/policies/privacy.html | 2 +- src/common/policies/terms.html | 3 +-- src/common/static/policies/privacy.json | 2 +- src/common/static/policies/terms.json | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/common/policies/privacy.html b/src/common/policies/privacy.html index 86db55db..c724359b 100644 --- a/src/common/policies/privacy.html +++ b/src/common/policies/privacy.html @@ -26,7 +26,7 @@

How communication is stored

Your browser may also be configured to store communication for your own personal use. This information is not shared with us.

Where the information is stored

-

Mucklet AB is located in Sweden, which is part of EU. All the information will be processed and stored in Sweden.

+

Mucklet AB is located in Sweden, which is part of EU. All the information will be processed and stored within EU.

Cookies

We use cookies. But the cookie that we store locally on your webbrowser does not contain any identifying information; it is only used to keep you logged in.

diff --git a/src/common/policies/terms.html b/src/common/policies/terms.html index 53b3fe3b..26b1bc67 100644 --- a/src/common/policies/terms.html +++ b/src/common/policies/terms.html @@ -55,8 +55,7 @@

Prohibited content

If an image is not a photo, but may be mistaken for one, it will be regarded as a photo.

diff --git a/src/common/static/policies/privacy.json b/src/common/static/policies/privacy.json index e5dcc74f..6ee5bbe0 100644 --- a/src/common/static/policies/privacy.json +++ b/src/common/static/policies/privacy.json @@ -2,5 +2,5 @@ "title": "Mucklet's Privacy Policy", "slug": "privacy", "created": 1688049624253, - "body": "

This service is hosted by Mucklet AB, which is the \"we\", \"us\" and \"our\" in this Privacy Policy.

\n

We don't really want your data. But we need some of it in order to make all of\nthis work, to provide a fun, friendly, and safe place to hang out. And to help\nus make it even better.

\n\n

What information we collect

\n

This is an online service. This means we store the information you give to us, such as username, email address, game settings, character information, and more. That is how it works.

\n

We may also automatically gather information such as IP-address, device/browser being used, or the browser's language settings, to help us improve and protect the service.

\n

In case of payments, we also store required geographical information, such as country and postal code, to be able to comply with global tax laws.

\n\n

How the information is collected

\n

We store it when you visit the website, register your user account, and when you send us the information. It is pretty straight forward.

\n\n

How the information is used

\n

We use the information to run the game. We don't sell any personal information of our users. We don't analyze any information or communication to target ads. We don't even run ads. We use the information for the purpose you have given it to us.

\n

Email, primarily used for restoring lost passwords, may also be used to send information on events directly related to the game. But you can opt out of this under the in-game settings.

\n\n

How passwords are stored

\n

If you use password login, instead of an OpenID service such as Google, we need to store your password. But we ensure your password is stored securely. In fact, we also pre-hash the password in your browser before sending it to us, so that we will never handle your actual password.

\n\n

How content is stored

\n

Content that you create or upload to the game, such as characters, rooms, images, and more, is stored as part of the game realm. In case you decide to delete your account, your personal information will be deleted but the created content will remain.

\n\n

How communication is stored

\n

Communication within the game is not persisted by default, and therefore not part of any backups - with the exception of in-game mail which by nature requires storage. In case a user makes a report against another player's character, communication available to the reporter may be stored to allow moderators to assess the report.

\n

Your browser may also be configured to store communication for your own personal use. This information is not shared with us.

\n\n

Where the information is stored

\n

Mucklet AB is located in Sweden, which is part of EU. All the information will be processed and stored in Sweden.

\n\n

Cookies

\n

We use cookies. But the cookie that we store locally on your webbrowser does not contain any identifying information; it is only used to keep you logged in.

\n\n

Disclosure of information

\n

We are not into the business of selling your information. We don't want to disclose your information. There are, however, some circumstances in which we may still have to do it:

\n\n\n\n

Children or minors

\n

We don't allow users under the age of 18 to register. We do not knowingly collect personal information from minors.\n\n

Contacting us

\n

If you have any questions regarding this Privacy Policy or how your information is handled, feel free to ask us by email: privacy@mucklet.com

\n\n

Changes to this privacy policy

\n

This Privacy Policy may be updated at any time.

" + "body": "

This service is hosted by Mucklet AB, which is the \"we\", \"us\" and \"our\" in this Privacy Policy.

\n

We don't really want your data. But we need some of it in order to make all of\nthis work, to provide a fun, friendly, and safe place to hang out. And to help\nus make it even better.

\n\n

What information we collect

\n

This is an online service. This means we store the information you give to us, such as username, email address, game settings, character information, and more. That is how it works.

\n

We may also automatically gather information such as IP-address, device/browser being used, or the browser's language settings, to help us improve and protect the service.

\n

In case of payments, we also store required geographical information, such as country and postal code, to be able to comply with global tax laws.

\n\n

How the information is collected

\n

We store it when you visit the website, register your user account, and when you send us the information. It is pretty straight forward.

\n\n

How the information is used

\n

We use the information to run the game. We don't sell any personal information of our users. We don't analyze any information or communication to target ads. We don't even run ads. We use the information for the purpose you have given it to us.

\n

Email, primarily used for restoring lost passwords, may also be used to send information on events directly related to the game. But you can opt out of this under the in-game settings.

\n\n

How passwords are stored

\n

If you use password login, instead of an OpenID service such as Google, we need to store your password. But we ensure your password is stored securely. In fact, we also pre-hash the password in your browser before sending it to us, so that we will never handle your actual password.

\n\n

How content is stored

\n

Content that you create or upload to the game, such as characters, rooms, images, and more, is stored as part of the game realm. In case you decide to delete your account, your personal information will be deleted but the created content will remain.

\n\n

How communication is stored

\n

Communication within the game is not persisted by default, and therefore not part of any backups - with the exception of in-game mail which by nature requires storage. In case a user makes a report against another player's character, communication available to the reporter may be stored to allow moderators to assess the report.

\n

Your browser may also be configured to store communication for your own personal use. This information is not shared with us.

\n\n

Where the information is stored

\n

Mucklet AB is located in Sweden, which is part of EU. All the information will be processed and stored within EU.

\n\n

Cookies

\n

We use cookies. But the cookie that we store locally on your webbrowser does not contain any identifying information; it is only used to keep you logged in.

\n\n

Disclosure of information

\n

We are not into the business of selling your information. We don't want to disclose your information. There are, however, some circumstances in which we may still have to do it:

\n\n\n\n

Children or minors

\n

We don't allow users under the age of 18 to register. We do not knowingly collect personal information from minors.\n\n

Contacting us

\n

If you have any questions regarding this Privacy Policy or how your information is handled, feel free to ask us by email: privacy@mucklet.com

\n\n

Changes to this privacy policy

\n

This Privacy Policy may be updated at any time.

" } diff --git a/src/common/static/policies/terms.json b/src/common/static/policies/terms.json index 1817d1e4..96cdc718 100644 --- a/src/common/static/policies/terms.json +++ b/src/common/static/policies/terms.json @@ -2,5 +2,5 @@ "title": "Mucklet's Terms of Service", "slug": "terms", "created": 1688049624253, - "body": "

These Terms of Service (or just \"Terms\") let you know the rules that govern\nour relationship with you as a user of our game and forum, which is collectively\nreferred to as the \"Service\". These Terms form a contract between you and\nMucklet AB (\"we\", \"us\", \"our\"), to let you know what we give you rights to use\nthis Service for, and also what rights you agree to give to us.

\n\n

We reserves the right to update these Terms for reasons including, but not\nlimited to, complying with changes to the law or reflecting enhancements to the\nService. If the changes affect your usage of the Service or your legal rights,\nwe will notify you at least seven days before the changes take effect.

\n\n

By using our Service, you agree to these Terms. Of course, if you don't agree\nwith them, don't use the Service.

\n\n

Who can use the Service

\n

No one under 18 is allowed to create an account or use the Service. This is\nbecause we cannot guarantee that the content is suitable for minors, since much\nof the content is produced by other users. We would also not be able to properly\nsafeguard minors against grooming, or other illicit behavior.

\n\n

Rights we grant you

\n

We own this Service, but we give you the right to use it and enjoy it, in a\nway that these Terms and the in-game rules allow. We may revoke this right at\nany time for reasons including, but not limited to, you using the Service in a\nway not authorized by these Terms (including helping others to do so), or if you\ndo not comply with the in-game rules.

\n\n

Rights you grant us

\n

Our Service allows you to create, send, receive, upload, and store\ninformation; also called \"content\". When you do that, you retain whatever\nownership right in that content you had to begin with. But you grant us the\nright to use that content for the purpose it was provided, even if you decide to\nstop using the Service or delete your account.

\n

You also give us the right to access, review, and delete any content that we\nthink violates these Terms or the in-game rules. You alone, though, remain\nresponsible for the content you provide.

\n\n

Privacy

\n

Our Privacy Policy describes\nhow we store and handle the information you provide to us.

\n\n

The content of others

\n

Just like you keep whatever ownership you had for the content you provide,\nothers will also do so for their content. Whether that content is made public or\nsent to you in private, you may only use that content for your own private\npurpose, unless you get the rights from the owners.

\n

Also, just like you are responsible for the content you provide, others are\nresponsible for the content they provide. Although we reserve the right to\nreview content, we do not necessarily review it all. So we cannot - and do not -\nguarantee that other users' content will comply with these Terms and the in-game\nrules.

\n\n

Prohibited content

\n

You may not upload content that contains:

\n\n

If an image is not a photo, but may be mistaken for one, it will be regarded\nas a photo.

\n\n

Respecting others' rights

\n

You may not provide content that infringes on someone elses intellectual\nproperty. If you use an image, or add content based on someone elses work,\nmake sure you have the rights to do so. And give credits where credit is\ndue.

\n\n

Payments

\n

Our services are free to use, but you may be able to pay for additional\nfeatures and perks. Our Payment\nTerms describe the terms and conditions for payments made to us.\n\n

Permitted use

\n

You must only use the Service in a way permitted by these Terms. This means,\namong other things, you may not do, attempt to do, enable, or encourage anyone\nelse to do, any of the following:

\n\n\n

Disclaimers

\n

We provide this service to you \"as is\" and \"as available\", without warranty\nof any kind. We want to keep it a fun and safe place to hang out, but we cannot\npromise that it will always be available or that it will be fit for any\nparticular purpose.

\n\n

Limitation of Liability

\n

To the extent permitted by law, we don't take responsibility for any negative\nconsequences, direct or indirect, related to using our Service, or inability to\naccess the Service. Such negative consequences includes, but is not limited to,\nloss of data, loss of profit, loss of goodwill, or other intangible losses. And\nyou agree to not hold us, or anyone associated with making this Service\navailable, liable for any such negative consequences.

\n\n

Mucklet AB or its proprietors may not be held accountable for content\nprovided by the users of the Service. We will take reasonable actions to\nmoderate this content in accordance with these Terms.

\n\n

Governing Law

\n

This Agreement shall be governed by and construed in accordance with the laws\nof Sweden. The Swedish court of general jurisdiction, and, in first instance\nthe Örebro District Court (Sw. Örebro tingsrätt), have exclusive jurisdiction to\nsettle any dispute arising out of or in connection with this Agreement.

" + "body": "

These Terms of Service (or just \"Terms\") let you know the rules that govern\nour relationship with you as a user of our game and forum, which is collectively\nreferred to as the \"Service\". These Terms form a contract between you and\nMucklet AB (\"we\", \"us\", \"our\"), to let you know what we give you rights to use\nthis Service for, and also what rights you agree to give to us.

\n\n

We reserves the right to update these Terms for reasons including, but not\nlimited to, complying with changes to the law or reflecting enhancements to the\nService. If the changes affect your usage of the Service or your legal rights,\nwe will notify you at least seven days before the changes take effect.

\n\n

By using our Service, you agree to these Terms. Of course, if you don't agree\nwith them, don't use the Service.

\n\n

Who can use the Service

\n

No one under 18 is allowed to create an account or use the Service. This is\nbecause we cannot guarantee that the content is suitable for minors, since much\nof the content is produced by other users. We would also not be able to properly\nsafeguard minors against grooming, or other illicit behavior.

\n\n

Rights we grant you

\n

We own this Service, but we give you the right to use it and enjoy it, in a\nway that these Terms and the in-game rules allow. We may revoke this right at\nany time for reasons including, but not limited to, you using the Service in a\nway not authorized by these Terms (including helping others to do so), or if you\ndo not comply with the in-game rules.

\n\n

Rights you grant us

\n

Our Service allows you to create, send, receive, upload, and store\ninformation; also called \"content\". When you do that, you retain whatever\nownership right in that content you had to begin with. But you grant us the\nright to use that content for the purpose it was provided, even if you decide to\nstop using the Service or delete your account.

\n

You also give us the right to access, review, and delete any content that we\nthink violates these Terms or the in-game rules. You alone, though, remain\nresponsible for the content you provide.

\n\n

Privacy

\n

Our Privacy Policy describes\nhow we store and handle the information you provide to us.

\n\n

The content of others

\n

Just like you keep whatever ownership you had for the content you provide,\nothers will also do so for their content. Whether that content is made public or\nsent to you in private, you may only use that content for your own private\npurpose, unless you get the rights from the owners.

\n

Also, just like you are responsible for the content you provide, others are\nresponsible for the content they provide. Although we reserve the right to\nreview content, we do not necessarily review it all. So we cannot - and do not -\nguarantee that other users' content will comply with these Terms and the in-game\nrules.

\n\n

Prohibited content

\n

You may not upload content that contains:

\n\n

If an image is not a photo, but may be mistaken for one, it will be regarded\nas a photo.

\n\n

Respecting others' rights

\n

You may not provide content that infringes on someone elses intellectual\nproperty. If you use an image, or add content based on someone elses work,\nmake sure you have the rights to do so. And give credits where credit is\ndue.

\n\n

Payments

\n

Our services are free to use, but you may be able to pay for additional\nfeatures and perks. Our Payment\nTerms describe the terms and conditions for payments made to us.\n\n

Permitted use

\n

You must only use the Service in a way permitted by these Terms. This means,\namong other things, you may not do, attempt to do, enable, or encourage anyone\nelse to do, any of the following:

\n\n\n

Disclaimers

\n

We provide this service to you \"as is\" and \"as available\", without warranty\nof any kind. We want to keep it a fun and safe place to hang out, but we cannot\npromise that it will always be available or that it will be fit for any\nparticular purpose.

\n\n

Limitation of Liability

\n

To the extent permitted by law, we don't take responsibility for any negative\nconsequences, direct or indirect, related to using our Service, or inability to\naccess the Service. Such negative consequences includes, but is not limited to,\nloss of data, loss of profit, loss of goodwill, or other intangible losses. And\nyou agree to not hold us, or anyone associated with making this Service\navailable, liable for any such negative consequences.

\n\n

Mucklet AB or its proprietors may not be held accountable for content\nprovided by the users of the Service. We will take reasonable actions to\nmoderate this content in accordance with these Terms.

\n\n

Governing Law

\n

This Agreement shall be governed by and construed in accordance with the laws\nof Sweden. The Swedish court of general jurisdiction, and, in first instance\nthe Örebro District Court (Sw. Örebro tingsrätt), have exclusive jurisdiction to\nsettle any dispute arising out of or in connection with this Agreement.

" } From 5bed849899a6e4b806901b816774fb2c245ee430 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Mon, 30 Sep 2024 10:41:14 +0200 Subject: [PATCH 03/15] GH-282: Fixed bug where the filtered result was not returned in CharFocus.js --- src/client/modules/main/addons/charFocus/CharFocus.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/client/modules/main/addons/charFocus/CharFocus.js b/src/client/modules/main/addons/charFocus/CharFocus.js index 211ae115..fe8f7c08 100644 --- a/src/client/modules/main/addons/charFocus/CharFocus.js +++ b/src/client/modules/main/addons/charFocus/CharFocus.js @@ -89,9 +89,10 @@ class CharFocus { let f = this.focusChars[c.id]?.props; if (!f) return null; - let list = Object.keys(f).map(k => f[k]); - list.filter(c => c?.name).sort((a, b) => a.name.localeCompare(b.name) || a.surname.localeCompare(b.surname)); - return list; + return Object.keys(f) + .map(k => f[k]) + .filter(c => c?.name) + .sort((a, b) => a.name.localeCompare(b.name) || a.surname.localeCompare(b.surname)); }); this.style = document.createElement('style'); document.head.appendChild(this.style); From 609aac8325064e3465d99905a3a135d8cba05064 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Thu, 3 Oct 2024 11:08:28 +0200 Subject: [PATCH 04/15] GH-288: Added char profile reporting and moderator tools to view them. --- .../modules/main/addons/avatar/Avatar.js | 32 ++- .../reportCharProfile/ReportCharProfile.js | 58 ++++ .../main/dialogs/dialogReport/DialogReport.js | 126 ++++++--- .../menuItem/menuItemReport/MenuItemReport.js | 4 +- .../modules/main/pages/pageChar/PageChar.js | 1 + .../main/pages/pageChar/PageCharComponent.js | 37 ++- .../modules/main/pages/pageChar/pageChar.scss | 7 +- .../DialogCharSnapshotAttachment.js | 53 ++++ .../DialogCharSnapshotAttachmentSnapshot.js | 256 ++++++++++++++++++ .../dialogCharSnapshotAttachment.scss | 61 +++++ .../moderatorPages/pageReports/PageReports.js | 2 +- .../ReportAttachmentCharSnapshot.js | 56 ++++ src/common/scss/_button.scss | 9 +- 13 files changed, 658 insertions(+), 44 deletions(-) create mode 100644 src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js create mode 100644 src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachment.js create mode 100644 src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachmentSnapshot.js create mode 100644 src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/dialogCharSnapshotAttachment.scss create mode 100644 src/client/modules/moderator/moderatorPages/pageReports/reportAttachmentCharSnapshot/ReportAttachmentCharSnapshot.js diff --git a/src/client/modules/main/addons/avatar/Avatar.js b/src/client/modules/main/addons/avatar/Avatar.js index 5fccf16f..72d9bfa1 100644 --- a/src/client/modules/main/addons/avatar/Avatar.js +++ b/src/client/modules/main/addons/avatar/Avatar.js @@ -20,14 +20,32 @@ class Avatar { /** * Creates a new avatar component instance - * @param {Model|object} char Char model or object. Should contain properties id, name, surname, and optionally avatar. + * @param {Model|object} char Char or profile model or object. * @param {object} [opt] Optional parameters. + * @param {object} [opt.char] Character object used to fetch initials in profile is not a character. + * @param {string} [opt.size] Avatar size. May be 'small', 'medium', 'large', or 'xlarge'. + * @param {string} [opt.property] Char property to get the image ID. Defaults to 'avatar'. + * @param {string} [opt.resolve] Resolves the image ID from the property. Defaults to v => v. + * @param {string} [opt.placeholder] Placeholder image to use instead of initials. May be 'avatar', 'room', or 'area'. + * @param {boolean} [opt.modalOnClick] Flag if clicking on the image should show the full image in a modal. * @returns {Component} Avatar component. */ newAvatar(char, opt) { return new AvatarComponent(char, Object.assign({ pattern: this.avatarPattern }, opt)); } + /** + * Creates a new avatar component instance for char images. + * @param {Model|object} char Char or profile model or object. + * @param {object} [opt] Optional parameters. + * @param {object} [opt.char] Character object used to fetch initials in profile is not a character. + * @param {string} [opt.size] Avatar size. May be 'small', 'medium', 'large', or 'xlarge'. + * @param {string} [opt.property] Char property to get the image ID. Defaults to 'image'. + * @param {string} [opt.resolve] Resolves the image ID from the property. Defaults to v => v. + * @param {string} [opt.placeholder] Placeholder image to use instead of initials. May be 'avatar'. Defaults to 'avatar'. + * @param {boolean} [opt.modalOnClick] Flag if clicking on the image should show the full image in a modal. + * @returns {Component} Avatar component. + */ newCharImg(char, opt) { return new AvatarComponent(char, Object.assign({ pattern: this.charImgPattern, @@ -36,6 +54,18 @@ class Avatar { }, opt)); } + /** + * Creates a new avatar component instance for room images. + * @param {Model|object} room Room or profile model or object. + * @param {object} [opt] Optional parameters. + * @param {object} [opt.char] Character object used to fetch initials in profile is not a character. + * @param {string} [opt.size] Avatar size. May be 'small', 'medium', 'large', or 'xlarge'. + * @param {string} [opt.property] Char property to get the image ID. Defaults to 'room'. + * @param {string} [opt.resolve] Resolves the image ID from the property. Defaults to v => v. + * @param {string} [opt.placeholder] Placeholder image to use instead of initials. May be 'avatar'. Defaults to 'avatar'. + * @param {boolean} [opt.modalOnClick] Flag if clicking on the image should show the full image in a modal. + * @returns {Component} Avatar component. + */ newRoomImg(room, opt) { return new AvatarComponent(room, Object.assign({ pattern: this.roomImgPattern, diff --git a/src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js b/src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js new file mode 100644 index 00000000..d7df7d28 --- /dev/null +++ b/src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js @@ -0,0 +1,58 @@ +import { Elem, Txt } from 'modapp-base-component'; +import l10n from 'modapp-l10n'; +import FAIcon from 'components/FAIcon'; + +/** + * ReportCharProfile adds the report char profile tool to PageChar. + */ +class ReportCharProfile { + constructor(app, params) { + this.app = app; + + this.app.require([ + 'pageChar', + 'player', + 'dialogReport', + ], this._init.bind(this)); + } + + _init(module) { + this.module = Object.assign({ self: this }, module); + + // Add logout tool + this.module.pageChar.addTool({ + id: 'reportProfile', + type: 'footer', + sortOrder: 10, + componentFactory: (ctrl, char) => new Elem(n => n.elem('button', { + className: 'btn tiny tinyicon', + events: { + click: () => this.reportProfile(ctrl, char), + }, + }, [ + n.component(new FAIcon('flag')), + n.component(new Txt(l10n.l('reportCharProfile.report', "Report"))), + ])), + filter: (ctrl, char) => !this.module.player.ownsChar(char.id), + }); + + } + + /** + * Reports a character profile for char, using your controlled character, + * ctrl, as reporter. + * @param {Model} ctrl Controlled character making the report. + * @param {Model} char Charater whose profile to report. + */ + reportProfile(ctrl, char) { + this.module.dialogReport.open(ctrl.id, char.id, null, { + attachProfile: true, + }); + } + + dispose() { + this.module.pageChar.addTool('reportProfile'); + } +} + +export default ReportCharProfile; diff --git a/src/client/modules/main/dialogs/dialogReport/DialogReport.js b/src/client/modules/main/dialogs/dialogReport/DialogReport.js index 828cf24a..30ed9c41 100644 --- a/src/client/modules/main/dialogs/dialogReport/DialogReport.js +++ b/src/client/modules/main/dialogs/dialogReport/DialogReport.js @@ -42,9 +42,11 @@ class DialogReport { * @param {string} ctrlId ID of controlled character sending the report. * @param {string} charId ID of target character of the report. * @param {string} puppeteerId ID of target puppeteer of the report. - * @param {object} [ev] Event object attached to the report. + * @param {object} [opt] Optional params + * @param {object} [opt.attachEvent] Event object attached to the report. + * @param {boolean} [opt.attachProfile] Flag to tell if the current charater profile should be attached. */ - open(ctrlId, charId, puppeteerId, ev) { + open(ctrlId, charId, puppeteerId, opt) { if (this.dialog) return; this.dialog = true; @@ -57,10 +59,19 @@ class DialogReport { this.module.api.get('core.char.' + charId) .then(char => { - let model = new Model({ data: { msg: "", attachLog: !!ev, start: -20, end: 10 }, eventBus: this.app.eventBus }); + let model = new Model({ data: { + msg: "", + attachLog: !!opt?.attachEvent, + attachProfile: !!opt?.attachProfile, + start: -20, + end: 10, + snapshotPromise: null, + }, eventBus: this.app.eventBus }); + + this._setCharSnapshot(model, charId); let logAttachComponent = null; - if (ev) { + if (opt?.attachEvent) { logAttachComponent = new Elem(n => n.elem('div', [ n.component(new LabelToggleBox(l10n.l('dialogReport.attachLog', "Attach log to report"), model.attachLog, { className: 'common--sectionpadding', @@ -78,11 +89,11 @@ class DialogReport { c.setComponent(model.attachLog ? new PanelSection( l10n.l('dialogReport.logInterval', "Log interval"), - new Elem(n => n.elem('div', [ + new Elem(n => n.elem('div', { className: 'pad-bottom-l' }, [ n.elem('div', [ n.component(new ModelTxt( model, - m => toTime(ev, m.start), + m => toTime(opt?.attachEvent, m.start), { duration: 0 }, )), n.text(" – "), @@ -96,7 +107,7 @@ class DialogReport { if (change && !change.hasOwnProperty('end')) return; clearTimeout(o.timer); - let endTime = addMin(ev.time, m.end); + let endTime = addMin(opt?.attachEvent.time, m.end); let diff = endTime - (new Date().getTime()); c.setText(diff > 0 ? l10n.l('dialogReport.now', "now") @@ -121,7 +132,7 @@ class DialogReport { density: 60, filter: v => v == 0 ? 0 : -1, format: { - to: v => toTime(ev, v), + to: v => toTime(opt?.attachEvent, v), from: v => v, }, }, @@ -180,10 +191,19 @@ class DialogReport { }, )), n.component(logAttachComponent), + n.component(new LabelToggleBox(l10n.l('dialogReport.attachProfile', "Attach character profile to report"), model.attachProfile, { + className: 'common--sectionpadding', + onChange: (v, c) => { + model.set({ attachProfile: v }); + this._setCharSnapshot(model, charId); + }, + popupTip: l10n.l('dialogReport.attachProfileInfo', "Attaches the character's current profile to the report. This includes information such as image, name, gender, species, description, about, and tags."), + popupTipClassName: 'popuptip--width-m', + })), n.component('message', new Collapser(null)), n.elem('div', { className: 'pad-top-xl' }, [ n.elem('submit', 'button', { - events: { click: () => this._sendReport(ctrlId, charId, puppeteerId, ev, model) }, + events: { click: () => this._sendReport(ctrlId, charId, puppeteerId, opt?.attachEvent, model) }, className: 'btn primary dialog-btn dialogreport--submit', }, [ n.component(new Txt(l10n.l('dialogReport.sendReport', "Send report"))), @@ -211,35 +231,52 @@ class DialogReport { return; } + let attachments = []; + + // Attach logs if needed this.reportPromise = (model.attachLog - ? this._getLog(ctrl, addMin(ev.time, model.start).getTime(), addMin(ev.time, model.end).getTime()) - : Promise.resolve() - ).then(events => { - return this.module.api.call('report.reports', 'create', { - charId: ctrlId, - targetId: charId, - puppeteer: puppeteerId || undefined, - msg: model.msg, - attachment: model.attachLog ? { + ? this._getLog(ctrl, addMin(ev.time, model.start).getTime(), addMin(ev.time, model.end).getTime()).then(events => { + attachments.push({ type: 'log', params: { events }, - } : undefined, - }).then(() => { - if (this.dialog) { - this.dialog.close(); - } - this.module.toaster.open({ - title: l10n.l('dialogReport.reportSent', "Report sent"), - content: new Txt(l10n.l('dialogReport.reportSentBody', "The report was successfully sent to the moderators.")), - closeOn: 'click', - type: 'success', - autoclose: true, }); - }).catch(err => { - if (!this.dialog) return; - this._setMessage(l10n.l(err.code, err.message, err.data)); - }).then(() => { - this.reportPromise = null; + }) + : Promise.resolve() + ).then(() => { + // Attach charSnapshot if needed + return (model.attachProfile + ? model.snapshotPromise.then(snapshotId => { + attachments.push({ + type: 'charSnapshot', + params: { snapshotId }, + }); + }) + : Promise.resolve() + ).then(() => { + // Create report + return this.module.api.call('report.reports', 'create', { + charId: ctrlId, + targetId: charId, + puppeteer: puppeteerId || undefined, + msg: model.msg, + attachments: attachments.length ? attachments : undefined, + }).then(() => { + if (this.dialog) { + this.dialog.close(); + } + this.module.toaster.open({ + title: l10n.l('dialogReport.reportSent', "Report sent"), + content: new Txt(l10n.l('dialogReport.reportSentBody', "The report was successfully sent to the moderators.")), + closeOn: 'click', + type: 'success', + autoclose: true, + }); + }).catch(err => { + if (!this.dialog) return; + this._setMessage(l10n.l(err.code, err.message, err.data)); + }).then(() => { + this.reportPromise = null; + }); }); }); @@ -276,6 +313,27 @@ class DialogReport { return this._getLog(char, startTime, endTime, chunk + 1, end ? l.slice(0, end).filter(ev => ev.sig).concat(log) : log); }); } + + /** + * Checks if a charSnapshot needs to be created, and if so creates it. + * Subsequent calls to this function will not create a new snapshot. + * @param {Model} model Dialog model. + * @param {string} charId Character ID. + */ + _setCharSnapshot(model, charId) { + if (model.snapshotPromise || !model.attachProfile) { + return; + } + model.set({ snapshotPromise: this.module.api.call('report.charsnapshots', 'create', { charId }) + .catch(err => { + this._setMessage(l10n.l(err.code, err.message, err.data)); + model.set({ + attachProfile: false, + snapshotPromise: null, + }); + }), + }); + } } export default DialogReport; diff --git a/src/client/modules/main/layout/charLog/menuItem/menuItemReport/MenuItemReport.js b/src/client/modules/main/layout/charLog/menuItem/menuItemReport/MenuItemReport.js index ad95fd3b..1e4c6b64 100644 --- a/src/client/modules/main/layout/charLog/menuItem/menuItemReport/MenuItemReport.js +++ b/src/client/modules/main/layout/charLog/menuItem/menuItemReport/MenuItemReport.js @@ -24,7 +24,9 @@ class MenuItemReport { } _onClick(charId, ev) { - this.module.dialogReport.open(charId, ev.char.id, ev.puppeteer && ev.puppeteer.id, ev); + this.module.dialogReport.open(charId, ev.char.id, ev.puppeteer && ev.puppeteer.id, { + attachEvent: ev, + }); } dispose() { diff --git a/src/client/modules/main/pages/pageChar/PageChar.js b/src/client/modules/main/pages/pageChar/PageChar.js index c6c4bfd4..00c40d7c 100644 --- a/src/client/modules/main/pages/pageChar/PageChar.js +++ b/src/client/modules/main/pages/pageChar/PageChar.js @@ -56,6 +56,7 @@ class PageChar { * @param {function} tool.componentFactory Tool component factory: function(ctrl, char) -> Component * @param {number} [tool.filter] Filter function: function(ctrl, char) -> bool * @param {number} [tool.className] Class to give to the list item container. + * @param {string} [tool.type] Target type. May be 'header', or 'footer'. Defaults to 'header'; * @returns {this} */ addTool(tool) { diff --git a/src/client/modules/main/pages/pageChar/PageCharComponent.js b/src/client/modules/main/pages/pageChar/PageCharComponent.js index 209991f6..b43214d2 100644 --- a/src/client/modules/main/pages/pageChar/PageCharComponent.js +++ b/src/client/modules/main/pages/pageChar/PageCharComponent.js @@ -66,7 +66,7 @@ class PageCharComponent { let elem = new Elem(n => n.elem('div', [ n.component(new Context( () => new CollectionWrapper(this.module.self.getTools(), { - filter: t => t.filter ? t.filter(this.ctrl, this.char) : true, + filter: t => (t.type || 'header') == 'header' && (!t.filter || t.filter(this.ctrl, this.char)), }), tools => tools.dispose(), tools => new CollectionComponent( @@ -108,10 +108,10 @@ class PageCharComponent { } }, )), - n.elem('div', { className: 'pagechar--details flex-row pad8 common--sectionpadding' }, [ + n.elem('div', { className: 'flex-row pad8 common--sectionpadding' }, [ n.elem('div', { className: 'flex-1' }, [ n.component(new Txt(l10n.l('pageChar.gender', "Gender"), { tagName: 'h3', className: 'margin-bottom-m' })), - n.elem('div', { className: 'pagechar--gender' }, [ + n.elem('div', [ n.component(new ModelComponent( this.char, new Txt(), @@ -124,7 +124,7 @@ class PageCharComponent { ]), n.elem('div', { className: 'flex-1' }, [ n.component(new Txt(l10n.l('pageChar.species', "Species"), { tagName: 'h3', className: 'margin-bottom-m' })), - n.elem('div', { className: 'pagechar--species' }, [ + n.elem('div', [ n.component(new ModelComponent( this.char, new Txt(), @@ -214,6 +214,35 @@ class PageCharComponent { ), (m, c) => this._showAbout(c.getComponent(), about), )), + n.component(new Context( + () => new CollectionWrapper(this.module.self.getTools(), { + filter: t => t.type == 'footer' && (!t.filter || t.filter(this.ctrl, this.char)), + }), + tools => tools.dispose(), + tools => new CollectionComponent( + tools, + new Collapser(), + (col, c, ev) => { + // Collapse if we have no tools to show + if (!col.length) { + c.setComponent(null); + return; + } + + if (!ev || (col.length == 1 && ev.event == 'add')) { + c.setComponent(new CollectionList( + tools, + t => t.componentFactory(this.ctrl, this.char), + { + className: 'pagechar--footertools', + subClassName: t => t.className || null, + horizontal: true, + }, + )); + } + }, + ), + )), ])); this.elem = new ModelComponent( diff --git a/src/client/modules/main/pages/pageChar/pageChar.scss b/src/client/modules/main/pages/pageChar/pageChar.scss index 8f9ec35b..8fadb675 100644 --- a/src/client/modules/main/pages/pageChar/pageChar.scss +++ b/src/client/modules/main/pages/pageChar/pageChar.scss @@ -4,7 +4,7 @@ padding: 8px 16px 16px 16px; margin: 0; - &--tools { + &--tools, &--footertools { display: flex; &::after { @@ -19,6 +19,11 @@ } } + &--footertools { + border-top: 1px solid $color1-dark; + padding-top: 8px; + } + &--image-cont { padding-top: 8px; } diff --git a/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachment.js b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachment.js new file mode 100644 index 00000000..f5f4c030 --- /dev/null +++ b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachment.js @@ -0,0 +1,53 @@ +import { Elem } from 'modapp-base-component'; +import l10n from 'modapp-l10n'; +import Dialog from 'classes/Dialog'; +import DialogCharSnapshotAttachmentSnapshot from './DialogCharSnapshotAttachmentSnapshot'; +import './dialogCharSnapshotAttachment.scss'; + +class DialogCharSnapshotAttachment { + constructor(app, params) { + this.app = app; + + this.app.require([ + 'api', + 'charLog', + 'confirm', + 'avatar', + ], this._init.bind(this)); + } + + _init(module) { + this.module = Object.assign({ self: this }, module); + } + + /** + * Open dialog to show a charSnapshot attachment + * @param {CharSnapshotInfo} info CharSnapshot info model with a snapshotId property. + * @param {Model} reporter Reporter object. + */ + open(info, reporter) { + if (this.dialog) return; + + this.dialog = true; + + this.module.api.get('report.charsnapshot.' + info.snapshotId) + .then(snapshot => { + this.dialog = new Dialog({ + title: l10n.l('dialogCharSnapshotAttachment.characterSnapshot', "Character snapshot"), + className: 'dialogcharsnapshotattachment', + content: new Elem(n => n.elem('div', [ + n.component(new DialogCharSnapshotAttachmentSnapshot(this.module, snapshot, {})), + ])), + onClose: () => this.dialog = null, + }); + + this.dialog.open(); + }) + .catch(err => { + this.dialog = null; + this.module.confirm.openError(err); + }); + } +} + +export default DialogCharSnapshotAttachment; diff --git a/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachmentSnapshot.js b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachmentSnapshot.js new file mode 100644 index 00000000..b8cab918 --- /dev/null +++ b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachmentSnapshot.js @@ -0,0 +1,256 @@ +import { Elem, Txt } from 'modapp-base-component'; +import { ModelTxt, ModelComponent } from 'modapp-resource-component'; +import l10n from 'modapp-l10n'; +import Collapser from 'components/Collapser'; +import PanelSection from 'components/PanelSection'; +import FormatTxt from 'components/FormatTxt'; +import Img from 'components/Img'; +import ModelCollapser from 'components/ModelCollapser'; +import CharTagsList, { hasTags } from 'components/CharTagsList'; +import ImgModal from 'classes/ImgModal'; +import firstLetterUppercase from 'utils/firstLetterUppercase'; +import errString from 'utils/errString'; +import formatDateTime from 'utils/formatDateTime'; +import { getCharIdleLevel } from 'utils/idleLevels'; + +const textNotSet = l10n.l('dialogCharSnapshotAttachment.notSet', "Not set"); +const txtUnknown = l10n.l('dialogCharSnapshotAttachment.unknown', "(Unknown)"); + +/** + * DialogCharSnapshotAttachmentSnapshot renders character info from a snapshot. + */ +class DialogCharSnapshotAttachmentSnapshot { + constructor(module, snapshot, state) { + this.module = module; + this.snapshot = snapshot; + this.state = state || {}; + this.state.description = this.state.description || {}; + this.state.lfrp = this.state.lfrp || {}; + } + + render(el) { + let lvl = getCharIdleLevel(this.snapshot); + let isAwake = this.snapshot.status != 'asleep'; + + let about = new PanelSection( + l10n.l('dialogCharSnapshotAttachment.about', "About"), + new ModelComponent( + this.snapshot, + new Elem(n => n.elem('div', [ + n.component('about', new FormatTxt("", { className: 'common--desc-size', state: this.state.about })), + n.component('tags', new Collapser()), + ])), + (m, c, change) => { + c.getNode('about').setFormatText(m.about); + if (!change || change.hasOwnProperty('tags')) { + c.getNode('tags').setComponent(m.tags ? new CharTagsList(m.tags, { + className: 'dialogcharsnapshotattachment--tags', + static: false, + eventBus: this.module.self.app.eventBus, + tooltipMargin: 'm', + }) : null); + } + }, + ), + { + className: 'common--sectionpadding', + open: this.state.aboutOpen, + onToggle: (c, v) => this.state.aboutOpen = v, + }, + ); + + this.elem = new Elem(n => n.elem('div', [ + + n.elem('div', { className: 'flex-row pad12 pad-bottom-l' }, [ + n.elem('div', { className: 'flex-auto' }, [ + n.component(this.module.avatar.newAvatar(this.snapshot, { className: 'badge--icon', size: 'large' })), + ]), + n.elem('div', { className: 'flex-1' }, [ + n.component(new ModelTxt(this.snapshot, m => errString(m, m => (m.name + ' ' + m.surname).trim(), txtUnknown), { tagName: 'div', className: 'dialogcharsnapshotattachment--fullname' })), + n.elem('div', { className: 'dialogcharsnapshotattachment--status' }, [ + n.elem('span', { className: (this.snapshot.lastAwake + ? isAwake && lvl + ? (' ' + lvl.className) + : '' + : ' common--placeholder' + ) }, [ + n.component(new Txt( + isAwake && lvl + ? lvl.text + : this.snapshot.lastAwake + ? l10n.l('dialogCharSnapshotAttachment.lastSeen', "Last seen {time}", { time: formatDateTime(new Date(this.snapshot.lastAwake)) }) + : l10n.l('dialogCharSnapshotAttachment.neverSeen', "Never seen"), + )), + n.component(this.snapshot.status ? new Txt(' (' + this.snapshot.status + ')') : null), + ]), + n.component(this.snapshot.puppeteer + ? new Elem(n => n.elem('span', [ + n.component(new Txt(l10n.l('dialogCharSnapshotAttachment.controlledBy', ", controlled by "))), + n.component(new ModelTxt(this.snapshot.puppeteer, m => errString(m, m => (m.name + ' ' + m.surname).trim(), txtUnknown))), + ])) + : null, + ), + ]), + n.elem('div', { className: 'dialogcharsnapshotattachment--timestamp flex-1' }, [ + n.component(new ModelTxt(this.snapshot, m => formatDateTime(new Date(m.timestamp)))), + ]), + ]), + ]), + + n.component(new ModelCollapser(this.snapshot, [{ + condition: m => m.image, + factory: m => new PanelSection( + l10n.l('dialogCharSnapshotAttachment.image', "Image"), + new Elem(n => n.elem('div', { className: 'flex-row flex-stretch pad8' }, [ + n.elem('div', { className: 'flex-1' }, [ + n.component(new Img(m.image.href + '?thumb=xl', { className: 'dialogcharsnapshotattachment--image', events: { + click: c => { + if (!c.hasClass('placeholder')) { + new ImgModal(m.image.href).open(); + } + }, + }})), + ]), + // n.elem('div', { className: 'dialogcharsnapshotattachment--imagebtn flex-1' }, [ + // n.component(new ModelComponent( + // this.snapshot, + // new Elem(n => n.elem('button', { + // className: 'btn medium icon-left', + // events: { + // click: () => this.module.confirm.open(() => this._deleteCharImage(), { + // title: l10n.l('dialogCharSnapshotAttachment.confirmDelete', "Confirm deletion"), + // body: l10n.l('dialogCharSnapshotAttachment.deleteImageBody', "Do you really wish to delete the image?"), + // confirm: l10n.l('dialogCharSnapshotAttachment.delete', "Delete"), + // }), + // }, + // }, [ + // n.component(new FAIcon('trash')), + // n.component(new Txt(l10n.l('dialogCharSnapshotAttachment.delete', "Delete"))), + // ])), + // (m, c) => c.setProperty('disabled', m.image ? null : 'disabled'), + // )), + // ]), + ])), + { + className: 'common--sectionpadding', + noToggle: true, + }, + ), + }])), + n.elem('div', { className: 'flex-row pad8 common--sectionpadding' }, [ + n.elem('div', { className: 'flex-1' }, [ + n.component(new Txt(l10n.l('dialogCharSnapshotAttachment.gender', "Gender"), { tagName: 'h3', className: 'margin-bottom-m' })), + n.elem('div', [ + n.component(new ModelComponent( + this.snapshot, + new Txt(), + (m, c) => { + c.setText(m.gender ? firstLetterUppercase(m.gender) : textNotSet); + c[m.gender ? 'removeClass' : 'addClass']('dialogcharsnapshotattachment--notset'); + }, + )), + ]), + ]), + n.elem('div', { className: 'flex-1' }, [ + n.component(new Txt(l10n.l('dialogCharSnapshotAttachment.species', "Species"), { tagName: 'h3', className: 'margin-bottom-m' })), + n.elem('div', [ + n.component(new ModelComponent( + this.snapshot, + new Txt(), + (m, c) => { + c.setText(m.species ? firstLetterUppercase(m.species) : textNotSet); + c[m.species ? 'removeClass' : 'addClass']('dialogcharsnapshotattachment--notset'); + }, + )), + ]), + ]), + ]), + n.component(new ModelCollapser(this.snapshot, [ + { + condition: m => m.rp == 'lfrp' && m.lfrpDesc, + factory: m => new PanelSection( + l10n.l('dialogCharSnapshotAttachment.lookingForRoleplay', "Looking for roleplay"), + new ModelComponent( + m, + new FormatTxt("", { className: 'common--desc-size', state: this.state.lfrp }), + (m, c) => c.setFormatText(m.lfrpDesc), + ), + { + className: 'common--sectionpadding', + open: this.state.lfrpOpen, + onToggle: (c, v) => this.state.lfrpOpen = v, + }, + ), + }, + { + condition: m => m.rp == 'lfrp', + factory: m => new Txt(l10n.l('dialogCharSnapshotAttachment.currentlyLookingForRoleplay', "Currently looking for roleplay."), { + className: 'dialogcharsnapshotattachment--lfrp-placeholder', + }), + }, + ])), + n.component(new PanelSection( + l10n.l('pageChar.description', "Description"), + new ModelComponent( + this.snapshot, + new FormatTxt("", { className: 'common--desc-size', state: this.state.description }), + (m, c) => { + c.setFormatText(m.desc ? m.desc : l10n.t(textNotSet)); + c[m.desc ? 'removeClass' : 'addClass']('dialogcharsnapshotattachment--notset'); + }, + ), + { + className: 'common--sectionpadding', + open: this.state.descriptionOpen, + onToggle: (c, v) => this.state.descriptionOpen = v, + }, + )), + n.component(new ModelCollapser(this.snapshot, [{ + condition: m => m.howToPlay, + factory: m => new PanelSection( + l10n.l('pageChar.howToPlay', "How to play"), + new ModelComponent( + m, + new FormatTxt("", { className: 'common--desc-size', state: this.state.howToPlay }), + (m, c) => { + c.setFormatText(m.howToPlay ? m.howToPlay : l10n.t(textNotSet)); + c[m.howToPlay ? 'removeClass' : 'addClass']('dialogcharsnapshotattachment--notset'); + }, + ), + { + className: 'common--sectionpadding', + open: this.state.howToPlayOpen, + onToggle: (c, v) => this.state.howToPlayOpen = v, + }, + ), + }])), + n.component(new ModelComponent( + this.snapshot, + new ModelComponent( + this.snapshot.tags, + new Collapser(null), + (m, c) => this._showAbout(c, about), + ), + (m, c) => this._showAbout(c.getComponent(), about), + )), + ])); + + return this.elem.render(el); + } + + unrender() { + if (this.elem) { + this.elem.unrender(); + this.elem = null; + } + } + + _showAbout(c, about) { + c.setComponent(this.snapshot.about || hasTags(this.snapshot.tags) + ? about + : null, + ); + } +} + +export default DialogCharSnapshotAttachmentSnapshot; diff --git a/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/dialogCharSnapshotAttachment.scss b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/dialogCharSnapshotAttachment.scss new file mode 100644 index 00000000..be4f6d31 --- /dev/null +++ b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/dialogCharSnapshotAttachment.scss @@ -0,0 +1,61 @@ +@import '~scss/variables'; + +.dialogcharsnapshotattachment { + &.dialog { + width: 80%; + max-height: 80%; + max-width: 652px; + } + + .dialog--content { + padding: 16px; + } + + &--tags { + padding-top: 6px; + } + + &--notset { + font-style: italic; + font-size: $font-size; + color: $color4-dark; + } + + &--fullname { + font-family: $font-text; + color: $color3; + font-size: $font-size-large; + line-height: 24px; + } + + &--status, &--timestamp { + font-size: $font-size-small; + line-height: 20px; + } + + &--image { + display: block; + border-radius: 10px; + width: 140px; + height: 140px; + background: $color1-lighter; + + &:not(.placeholder) { + cursor: pointer; + } + } + + // &--imagebtn { + // > .btn + .btn { + // margin-top: 8px; + // } + // } + + &--lfrp-placeholder { + display: block; + font-style: italic; + font-size: $font-size-small; + padding-bottom: 10px; + } + +} diff --git a/src/client/modules/moderator/moderatorPages/pageReports/PageReports.js b/src/client/modules/moderator/moderatorPages/pageReports/PageReports.js index 4324b501..28e16460 100644 --- a/src/client/modules/moderator/moderatorPages/pageReports/PageReports.js +++ b/src/client/modules/moderator/moderatorPages/pageReports/PageReports.js @@ -100,7 +100,7 @@ class PageReports { * Gets a collection of attachment types. * @returns {Collection} Collection of attachment types. */ - getAttachmentTypes() { + getAttachmentTypes() { return this.attachmentTypes; } diff --git a/src/client/modules/moderator/moderatorPages/pageReports/reportAttachmentCharSnapshot/ReportAttachmentCharSnapshot.js b/src/client/modules/moderator/moderatorPages/pageReports/reportAttachmentCharSnapshot/ReportAttachmentCharSnapshot.js new file mode 100644 index 00000000..6ba465c4 --- /dev/null +++ b/src/client/modules/moderator/moderatorPages/pageReports/reportAttachmentCharSnapshot/ReportAttachmentCharSnapshot.js @@ -0,0 +1,56 @@ +import { Elem, Txt } from 'modapp-base-component'; +import { ModelTxt } from 'modapp-resource-component'; +import l10n from 'modapp-l10n'; +import FAIcon from 'components/FAIcon'; +import formatDateTime from 'utils/formatDateTime'; + +/** + * ReportAttachmentCharSnapshot adds the charSnapshot report attachment type. + */ +class ReportAttachmentCharSnapshot { + constructor(app, params) { + this.app = app; + + this.app.require([ + 'pageReports', + 'dialogCharSnapshotAttachment', + ], this._init.bind(this)); + } + + _init(module) { + this.module = Object.assign({ self: this }, module); + + this.module.pageReports.addAttachmentType({ + id: 'charSnapshot', + componentFactory: (info, reporter) => { + return new Elem(n => n.elem('div', { className: 'reportattachmentcharsnapshot badge--select badge--margin' }, [ + n.elem('button', { + className: 'badge--faicon iconbtn smallicon solid', + events: { + click: (c, e) => { + this.module.dialogCharSnapshotAttachment.open(info, reporter); + e.stopPropagation(); + }, + }, + }, [ + n.component(new FAIcon('user')), + ]), + n.elem('div', { className: 'badge--info' }, [ + n.elem('div', { className: 'badge--subtitle' }, [ + n.component(new Txt(l10n.l('reportAttachmentCharSnapshot.characterSnapshot', "Character snapshot"))), + ]), + n.elem('div', { className: 'badge--text' }, [ + n.component(new ModelTxt(info, m => formatDateTime(new Date(m.timestamp)))), + ]), + ]), + ])); + }, + }); + } + + dispose() { + this.module.pageReports.removeAttachmentType('charSnapshot'); + } +} + +export default ReportAttachmentCharSnapshot; diff --git a/src/common/scss/_button.scss b/src/common/scss/_button.scss index c8caa7af..12ea0374 100644 --- a/src/common/scss/_button.scss +++ b/src/common/scss/_button.scss @@ -56,6 +56,11 @@ width: 100%; } +.btn[title], .iconbtn[title] { + pointer-events: initial; + cursor: help; +} + // .btn.icon-left .fa { // padding: 0 7px; // left: -16px; @@ -170,10 +175,10 @@ > .fa, > * > .fa { width: 16px; - font-size: 14px; + font-size: 12px; } > .fa + *, > * > .fa + * { - padding-left: 4px; + padding-left: 6px; } } From b411fd95162fb6a367e0d0bcff8e3db69416fe51 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Thu, 3 Oct 2024 11:36:33 +0200 Subject: [PATCH 05/15] GH-288: Handling of multiple report attachments per reporter. Set profile reporting for controlling puppeteer. --- .../reportCharProfile/ReportCharProfile.js | 10 ++++- .../moderatorPages/pageReports/PageReports.js | 2 +- .../pageReports/PageReportsReporter.js | 40 ++++++++----------- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js b/src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js index d7df7d28..eeb93745 100644 --- a/src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js +++ b/src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js @@ -10,6 +10,7 @@ class ReportCharProfile { this.app = app; this.app.require([ + 'api', 'pageChar', 'player', 'dialogReport', @@ -45,8 +46,13 @@ class ReportCharProfile { * @param {Model} char Charater whose profile to report. */ reportProfile(ctrl, char) { - this.module.dialogReport.open(ctrl.id, char.id, null, { - attachProfile: true, + this.module.api.get('core.char.' + char.id).catch((err) => { + console.error("Error getting char: ", err); + return char; + }).then(c => { + this.module.dialogReport.open(ctrl.id, c.id, c.puppeteer?.id, { + attachProfile: true, + }); }); } diff --git a/src/client/modules/moderator/moderatorPages/pageReports/PageReports.js b/src/client/modules/moderator/moderatorPages/pageReports/PageReports.js index 28e16460..9f910ca1 100644 --- a/src/client/modules/moderator/moderatorPages/pageReports/PageReports.js +++ b/src/client/modules/moderator/moderatorPages/pageReports/PageReports.js @@ -108,7 +108,7 @@ class PageReports { * Registers a tags type. * @param {object} type Attachment type object * @param {string} type.id Attachment type ID. - * @param {function} type.componentFactory Attachment type component factory: function(ctrl, state) -> Component + * @param {(attachmentInfo: any, reporter: ReporterModel) => Component} type.componentFactory Attachment type component factory. * @returns {this} */ addAttachmentType(type) { diff --git a/src/client/modules/moderator/moderatorPages/pageReports/PageReportsReporter.js b/src/client/modules/moderator/moderatorPages/pageReports/PageReportsReporter.js index a5d0fb72..7beb45af 100644 --- a/src/client/modules/moderator/moderatorPages/pageReports/PageReportsReporter.js +++ b/src/client/modules/moderator/moderatorPages/pageReports/PageReportsReporter.js @@ -1,5 +1,5 @@ import { Elem, Txt } from 'modapp-base-component'; -import { ModelHtml, ModelTxt, ModelComponent } from 'modapp-resource-component'; +import { ModelHtml, ModelTxt, ModelComponent, CollectionList } from 'modapp-resource-component'; import l10n from 'modapp-l10n'; import Collapser from 'components/Collapser'; import FAIcon from 'components/FAIcon'; @@ -74,29 +74,21 @@ class PageReportsReporter { n.elem('div', { className: 'badge--text' }, [ n.component(new ModelHtml(this.reporter, m => formatText(m.msg), { tagName: 'span', className: 'common--formattext' })), ]), - n.component(new ModelComponent( - this.reporter, - new Collapser(), - (m, c, change) => { - if (!change || change.hasOwnProperty('attachmentType') || change.hasOwnProperty('attachmentInfo')) { - if (!m.attachmentType) { - c.setComponent(null); - return; - } - let typ = this.module.self.getAttachmentTypes().get(m.attachmentType); - c.setComponent(typ - ? typ.componentFactory(m.attachmentInfo, m) - : new Elem(n => n.elem('div', [ - n.elem('div', { className: 'flex-row' }, [ - n.component(new Txt(txtType, { className: 'badge--iconcol badge--subtitle' })), - n.component(new ModelTxt(this.reporter, m => m.attachmentType, { - className: 'badge--info badge--text', - })), - ]), - n.component(new ModelTxt(m, m => m.attachmentInfo ? JSON.stringify(m.attachmentInfo, null, 2) : "", { tagName: 'pre', className: 'badge--text common--formattext' })), - ])), - ); - } + n.component(new CollectionList( + this.reporter.attachments, + (attachment) => { + let typ = this.module.self.getAttachmentTypes().get(attachment.type); + return typ + ? typ.componentFactory(attachment.info, this.reporter) + : new Elem(n => n.elem('div', [ + n.elem('div', { className: 'flex-row' }, [ + n.component(new Txt(txtType, { className: 'badge--iconcol badge--subtitle' })), + n.component(new ModelTxt(this.reporter, m => attachment.type, { + className: 'badge--info badge--text', + })), + ]), + n.component(new ModelTxt(attachment, m => m.info ? JSON.stringify(m.info, null, 2) : "", { tagName: 'pre', className: 'badge--text common--formattext' })), + ])); }, )), ])); From 2313c13f3c2e6abdb3e5d154e539a36da7f8ad38 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Thu, 3 Oct 2024 18:14:40 +0200 Subject: [PATCH 06/15] GH-309: Made Attach log always available for DialogReport. --- .../modules/main/commands/report/Report.js | 24 +- .../main/dialogs/dialogReport/DialogReport.js | 208 +++++++++--------- .../main/layout/charPages/CharPagesChar.js | 2 +- 3 files changed, 117 insertions(+), 117 deletions(-) diff --git a/src/client/modules/main/commands/report/Report.js b/src/client/modules/main/commands/report/Report.js index c9cc6bf5..7c5656e1 100644 --- a/src/client/modules/main/commands/report/Report.js +++ b/src/client/modules/main/commands/report/Report.js @@ -7,12 +7,12 @@ import { communicationTooLong } from 'utils/cmdErr'; const usageText = 'report Character = Message'; const shortDesc = 'Report a character'; const helpText = -`

Report a character to the moderators. If the character is a puppet, the current puppeteer is reported.

+`

Open a dialog to report a character to the moderators. If the character is a puppet, the current puppeteer is reported.

For more info on creating reports, type: help reporting

Character is the name of the character to report.

Message is the optional report message. It may be formatted and span multiple paragraphs.

`; const examples = [ - { cmd: 'report John Mischief = Excessive spamming.', desc: l10n.l('report.reportExampleDesc', "Reports John Mischief to the moderators") }, + { cmd: 'report John Mischief = Excessive spamming.', desc: l10n.l('report.reportExampleDesc', "Opens a dialog to report John Mischief to the moderators") }, ]; /** @@ -22,7 +22,14 @@ class Report { constructor(app) { this.app = app; - this.app.require([ 'cmd', 'cmdLists', 'cmdSteps', 'help', 'api', 'charLog', 'info' ], this._init.bind(this)); + this.app.require([ + 'cmd', + 'cmdLists', + 'cmdSteps', + 'help', + 'info', + 'dialogReport', + ], this._init.bind(this)); } _init(module) { @@ -68,13 +75,10 @@ class Report { return (params.charId ? Promise.resolve({ id: params.charId }) : player.call('getChar', { charName: params.charName }) - ).then(c => this.module.api.call('report.reports', 'create', { - charId: char.id, - targetId: c.id, - currentPuppeteer: true, - msg: params.msg || null, - })).then(() => { - this.module.charLog.logInfo(char, l10n.l('report.reportSent', "The report was sent to the moderators.")); + ).then(c => { + this.module.dialogReport.open(char.id, c.id, c.puppeteer?.id, { + msg: params.msg || null, + }); }); } } diff --git a/src/client/modules/main/dialogs/dialogReport/DialogReport.js b/src/client/modules/main/dialogs/dialogReport/DialogReport.js index 30ed9c41..5b135222 100644 --- a/src/client/modules/main/dialogs/dialogReport/DialogReport.js +++ b/src/client/modules/main/dialogs/dialogReport/DialogReport.js @@ -6,6 +6,7 @@ import Collapser from 'components/Collapser'; import PanelSection from 'components/PanelSection'; import LabelToggleBox from 'components/LabelToggleBox'; import NoUiSlider from 'components/NoUiSlider'; +import ModelCollapser from 'components/ModelCollapser'; import Dialog from 'classes/Dialog'; import formatTime from 'utils/formatTime'; import './dialogReport.scss'; @@ -14,10 +15,8 @@ function addMin(time, minDiff) { return new Date(time + 60 * 1000 * minDiff); } -function toTime(ev, diff) { - return ev && ev.time - ? formatTime(addMin(ev.time, diff)) - : '-'; +function toTime(time, diff) { + return formatTime(addMin(time, diff)); } class DialogReport { @@ -43,7 +42,8 @@ class DialogReport { * @param {string} charId ID of target character of the report. * @param {string} puppeteerId ID of target puppeteer of the report. * @param {object} [opt] Optional params - * @param {object} [opt.attachEvent] Event object attached to the report. + * @param {object} [opt.msg] Message to use initially. + * @param {object} [opt.attachEvent] Event object to use as time reference for the report. * @param {boolean} [opt.attachProfile] Flag to tell if the current charater profile should be attached. */ open(ctrlId, charId, puppeteerId, opt) { @@ -59,112 +59,20 @@ class DialogReport { this.module.api.get('core.char.' + charId) .then(char => { + let hasEvent = !!opt?.attachEvent; let model = new Model({ data: { - msg: "", - attachLog: !!opt?.attachEvent, + msg: opt?.msg || "", + attachLog: hasEvent, attachProfile: !!opt?.attachProfile, + time: opt?.attachEvent?.time || Date.now(), start: -20, end: 10, snapshotPromise: null, + }, eventBus: this.app.eventBus }); this._setCharSnapshot(model, charId); - let logAttachComponent = null; - if (opt?.attachEvent) { - logAttachComponent = new Elem(n => n.elem('div', [ - n.component(new LabelToggleBox(l10n.l('dialogReport.attachLog', "Attach log to report"), model.attachLog, { - className: 'common--sectionpadding', - onChange: (v, c) => { - model.set({ attachLog: v }); - }, - popupTip: l10n.l('dialogReport.attachLogInfo', "Attaches a section of your character's log to the report.\nIt will only be seen by moderators."), - popupTipClassName: 'popuptip--width-m', - })), - n.component(new ModelComponent( - model, - new Collapser(), - (m, c, change) => { - if (change && !change.hasOwnProperty('attachLog')) return; - c.setComponent(model.attachLog - ? new PanelSection( - l10n.l('dialogReport.logInterval', "Log interval"), - new Elem(n => n.elem('div', { className: 'pad-bottom-l' }, [ - n.elem('div', [ - n.component(new ModelTxt( - model, - m => toTime(opt?.attachEvent, m.start), - { duration: 0 }, - )), - n.text(" – "), - n.component(new Context( - () => ({ timer: null }), - o => clearTimeout(o.timer), - o => new ModelComponent( - model, - new Txt('', { duration: 0 }), - (m, c, change) => { - if (change && !change.hasOwnProperty('end')) return; - - clearTimeout(o.timer); - let endTime = addMin(opt?.attachEvent.time, m.end); - let diff = endTime - (new Date().getTime()); - c.setText(diff > 0 - ? l10n.l('dialogReport.now', "now") - : formatTime(endTime), - ); - if (diff > 0) { - o.timer = setTimeout(() => { - c.setText(formatTime(endTime)); - }, diff); - } - }, - ), - )), - ]), - n.component(new NoUiSlider({ - start: [ model.start, model.end ], - step: 5, - range: { min: [ -60 ], '50%': [ 0, 5 ], max: [ 60 ] }, - connect: [ false, true, false ], - pips: { - mode: 'steps', - density: 60, - filter: v => v == 0 ? 0 : -1, - format: { - to: v => toTime(opt?.attachEvent, v), - from: v => v, - }, - }, - className: 'dialogreport--slider pips-centered', - onUpdate: (c, v, handle, unencoded, tap, positions, slider) => { - let start = parseInt(v[0]); - let end = parseInt(v[1]); - if (start > 0) { - start = 0; - slider.setHandle(0, 0, false); - } - if (end < 0) { - end = 0; - slider.setHandle(1, 0, false); - } - model.set({ start, end }); - }, - })), - ])), - { - className: 'common--sectionpadding small', - noToggle: true, - popupTip: l10n.l('dialogReport.logIntevalInfo', "Time interval of log events to include in the attachment."), - }, - ) - : null, - ); - }, - )), - ])); - } - this.dialog = new Dialog({ title: l10n.l('dialogReport.reportCharacter', "Report character"), className: 'dialogreport', @@ -190,7 +98,95 @@ class DialogReport { popupTip: l10n.l('dialogReport.messageInfo', "Describe what you wish to report. This will only be seen by moderators."), }, )), - n.component(logAttachComponent), + n.elem('div', [ + n.component(new LabelToggleBox(l10n.l('dialogReport.attachLog', "Attach log to report"), model.attachLog, { + className: 'common--sectionpadding', + onChange: (v, c) => { + model.set({ attachLog: v }); + }, + popupTip: l10n.l('dialogReport.attachLogInfo', "Attaches a section of your character's log to the report.\nIt will only be seen by moderators."), + popupTipClassName: 'popuptip--width-m', + })), + n.component(new ModelCollapser(model, [{ + condition: m => m.attachLog, + factory: m => new PanelSection( + l10n.l('dialogReport.logInterval', "Log interval"), + new Elem(n => n.elem('div', { className: 'pad-bottom-l' }, [ + n.elem('div', [ + n.component(new ModelTxt( + model, + m => toTime(m.time, m.start), + { duration: 0 }, + )), + n.text(" – "), + n.component(new Context( + () => ({ timer: null }), + o => clearTimeout(o.timer), + o => new ModelComponent( + model, + new Txt('', { duration: 0 }), + (m, c, change) => { + if (change && !change.hasOwnProperty('end')) return; + + clearTimeout(o.timer); + let endTime = addMin(m.time, m.end); + let diff = endTime - Date.now(); + c.setText(diff > 0 + ? l10n.l('dialogReport.now', "now") + : formatTime(endTime), + ); + if (diff > 0) { + o.timer = setTimeout(() => { + c.setText(formatTime(endTime)); + }, diff); + } + }, + ), + )), + ]), + n.component(new NoUiSlider({ + start: [ model.start, model.end ], + step: 5, + range: hasEvent + ? { min: [ -60 ], max: [ 60 ] } + : { min: [ -150 ], max: [ 10 ] }, + connect: [ false, true, false ], + pips: { + mode: 'steps', + density: 60, + filter: v => v == 0 ? 0 : -1, + format: { + to: v => toTime(model.time, v), + from: v => v, + }, + }, + className: 'dialogreport--slider pips-centered', + onUpdate: (c, v, handle, unencoded, tap, positions, slider) => { + let start = parseInt(v[0]); + let end = parseInt(v[1]); + // If we have an event, always include it (pos 0). + if (hasEvent) { + if (start > 0) { + start = 0; + slider.setHandle(0, 0, false); + } + if (end < 0) { + end = 0; + slider.setHandle(1, 0, false); + } + } + model.set({ start, end }); + }, + })), + ])), + { + className: 'common--sectionpadding small', + noToggle: true, + popupTip: l10n.l('dialogReport.logIntevalInfo', "Time interval of log events to include in the attachment."), + }, + ), + }])), + ]), n.component(new LabelToggleBox(l10n.l('dialogReport.attachProfile', "Attach character profile to report"), model.attachProfile, { className: 'common--sectionpadding', onChange: (v, c) => { @@ -203,7 +199,7 @@ class DialogReport { n.component('message', new Collapser(null)), n.elem('div', { className: 'pad-top-xl' }, [ n.elem('submit', 'button', { - events: { click: () => this._sendReport(ctrlId, charId, puppeteerId, opt?.attachEvent, model) }, + events: { click: () => this._sendReport(ctrlId, charId, puppeteerId, model) }, className: 'btn primary dialog-btn dialogreport--submit', }, [ n.component(new Txt(l10n.l('dialogReport.sendReport', "Send report"))), @@ -222,7 +218,7 @@ class DialogReport { }); } - _sendReport(ctrlId, charId, puppeteerId, ev, model) { + _sendReport(ctrlId, charId, puppeteerId, model) { if (this.reportPromise) return this.reportPromise; let ctrl = this.module.player.getControlledChar(ctrlId); @@ -235,7 +231,7 @@ class DialogReport { // Attach logs if needed this.reportPromise = (model.attachLog - ? this._getLog(ctrl, addMin(ev.time, model.start).getTime(), addMin(ev.time, model.end).getTime()).then(events => { + ? this._getLog(ctrl, addMin(model.time, model.start).getTime(), addMin(model.time, model.end).getTime()).then(events => { attachments.push({ type: 'log', params: { events }, diff --git a/src/client/modules/main/layout/charPages/CharPagesChar.js b/src/client/modules/main/layout/charPages/CharPagesChar.js index 52b294cd..cde20788 100644 --- a/src/client/modules/main/layout/charPages/CharPagesChar.js +++ b/src/client/modules/main/layout/charPages/CharPagesChar.js @@ -168,7 +168,7 @@ class CharPagesChar { let props = this.char.props; if (this.isSeen) { let c = this.char; - let timestamp = new Date().getTime(); + let timestamp = Date.now(); this.module.charPagesStore.setChar(this.ctrl.id, { id: c.id, image: c.image ? c.image.toJSON() : null, From d38fee2fcf1d35bd25f350adff97cf16e61b8114 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Fri, 4 Oct 2024 09:49:43 +0200 Subject: [PATCH 07/15] GH-308: Created ToolPageCharNote to add Note to CharPage footer. --- .../toolPageCharNote/ToolPageCharNote.js | 65 +++++++++++++++++++ .../ToolPageCharReport.js} | 10 +-- .../modules/main/pages/pageChar/pageChar.scss | 5 ++ 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 src/client/modules/main/addons/toolPageCharNote/ToolPageCharNote.js rename src/client/modules/main/addons/{reportCharProfile/ReportCharProfile.js => toolPageCharReport/ToolPageCharReport.js} (85%) diff --git a/src/client/modules/main/addons/toolPageCharNote/ToolPageCharNote.js b/src/client/modules/main/addons/toolPageCharNote/ToolPageCharNote.js new file mode 100644 index 00000000..f9dac8db --- /dev/null +++ b/src/client/modules/main/addons/toolPageCharNote/ToolPageCharNote.js @@ -0,0 +1,65 @@ +import { Elem, Txt } from 'modapp-base-component'; +import l10n from 'modapp-l10n'; +import FAIcon from 'components/FAIcon'; + +/** + * ToolPageCharNote adds the note tool to PageChar footer. + */ +class ToolPageCharNote { + constructor(app, params) { + this.app = app; + + this.app.require([ + 'api', + 'pageChar', + 'player', + 'dialogReport', + 'dialogEditNote', + ], this._init.bind(this)); + } + + _init(module) { + this.module = Object.assign({ self: this }, module); + + // Add logout tool + this.module.pageChar.addTool({ + id: 'note', + type: 'footer', + sortOrder: 10, + componentFactory: (ctrl, char) => new Elem(n => n.elem('button', { + className: 'btn tiny tinyicon', + events: { + click: () => this.module.dialogEditNote.open(char.id), + }, + }, [ + n.component(new FAIcon('file-text')), + n.component(new Txt(l10n.l('toolPageCharNote.note', "Note"))), + ])), + filter: (ctrl, char) => !this.module.player.ownsChar(char.id), + }); + + } + + /** + * Reports a character profile for char, using your controlled character, + * ctrl, as noteer. + * @param {Model} ctrl Controlled character making the note. + * @param {Model} char Charater whose profile to note. + */ + note(ctrl, char) { + this.module.api.get('core.char.' + char.id).catch((err) => { + console.error("Error getting char: ", err); + return char; + }).then(c => { + this.module.dialogReport.open(ctrl.id, c.id, c.puppeteer?.id, { + attachProfile: true, + }); + }); + } + + dispose() { + this.module.pageChar.addTool('note'); + } +} + +export default ToolPageCharNote; diff --git a/src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js b/src/client/modules/main/addons/toolPageCharReport/ToolPageCharReport.js similarity index 85% rename from src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js rename to src/client/modules/main/addons/toolPageCharReport/ToolPageCharReport.js index eeb93745..ee579da2 100644 --- a/src/client/modules/main/addons/reportCharProfile/ReportCharProfile.js +++ b/src/client/modules/main/addons/toolPageCharReport/ToolPageCharReport.js @@ -3,9 +3,9 @@ import l10n from 'modapp-l10n'; import FAIcon from 'components/FAIcon'; /** - * ReportCharProfile adds the report char profile tool to PageChar. + * ToolPageCharReport adds the report char profile tool to PageChar footer. */ -class ReportCharProfile { +class ToolPageCharReport { constructor(app, params) { this.app = app; @@ -24,7 +24,7 @@ class ReportCharProfile { this.module.pageChar.addTool({ id: 'reportProfile', type: 'footer', - sortOrder: 10, + sortOrder: 20, componentFactory: (ctrl, char) => new Elem(n => n.elem('button', { className: 'btn tiny tinyicon', events: { @@ -32,7 +32,7 @@ class ReportCharProfile { }, }, [ n.component(new FAIcon('flag')), - n.component(new Txt(l10n.l('reportCharProfile.report', "Report"))), + n.component(new Txt(l10n.l('toolPageCharReport.report', "Report"))), ])), filter: (ctrl, char) => !this.module.player.ownsChar(char.id), }); @@ -61,4 +61,4 @@ class ReportCharProfile { } } -export default ReportCharProfile; +export default ToolPageCharReport; diff --git a/src/client/modules/main/pages/pageChar/pageChar.scss b/src/client/modules/main/pages/pageChar/pageChar.scss index 8fadb675..77ff235f 100644 --- a/src/client/modules/main/pages/pageChar/pageChar.scss +++ b/src/client/modules/main/pages/pageChar/pageChar.scss @@ -20,8 +20,13 @@ } &--footertools { + margin: 0 -2px; border-top: 1px solid $color1-dark; padding-top: 8px; + + > div { + padding: 0px 2px; + } } &--image-cont { From 4c3c9bd16be10019c91669abf6d913d28ecbbfb4 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Fri, 4 Oct 2024 15:21:52 +0200 Subject: [PATCH 08/15] GH-312: Added option to filter out private messages in report log attachment. GH-313: Added friendly "Thank you"- disclaimer. --- .../main/dialogs/dialogReport/DialogReport.js | 192 ++++++++++-------- .../dialogs/dialogReport/dialogReport.scss | 10 + 2 files changed, 120 insertions(+), 82 deletions(-) diff --git a/src/client/modules/main/dialogs/dialogReport/DialogReport.js b/src/client/modules/main/dialogs/dialogReport/DialogReport.js index 5b135222..8b6231ea 100644 --- a/src/client/modules/main/dialogs/dialogReport/DialogReport.js +++ b/src/client/modules/main/dialogs/dialogReport/DialogReport.js @@ -9,6 +9,7 @@ import NoUiSlider from 'components/NoUiSlider'; import ModelCollapser from 'components/ModelCollapser'; import Dialog from 'classes/Dialog'; import formatTime from 'utils/formatTime'; +import { isTargeted } from 'utils/charEvent'; import './dialogReport.scss'; function addMin(time, minDiff) { @@ -19,6 +20,22 @@ function toTime(time, diff) { return formatTime(addMin(time, diff)); } +const privateEvents = { + dnd: true, + whisper: true, + mail: true, + message: true, + warn: true, + helpme: true, +}; + +function isPrivateWithOthers(ev, charId, puppeteerId) { + if (!privateEvents[ev.type]) { + return false; + } + return !(ev.char.id == charId && (!puppeteerId || ev.puppeteer?.id == puppeteerId)) && !isTargeted(charId, ev); +} + class DialogReport { constructor(app, params) { this.app = app; @@ -51,12 +68,6 @@ class DialogReport { this.dialog = true; - // let eventComponent = null; - // if (ev) { - // let f = this.module.charLog.getEventComponentFactory(ev.type); - // eventComponent = (f && f(charId, ev, { noCode: true, noButton: true })) || null; - // } - this.module.api.get('core.char.' + charId) .then(char => { let hasEvent = !!opt?.attachEvent; @@ -64,6 +75,7 @@ class DialogReport { msg: opt?.msg || "", attachLog: hasEvent, attachProfile: !!opt?.attachProfile, + excludePrivate: true, time: opt?.attachEvent?.time || Date.now(), start: -20, end: 10, @@ -77,6 +89,7 @@ class DialogReport { title: l10n.l('dialogReport.reportCharacter', "Report character"), className: 'dialogreport', content: new Elem(n => n.elem('div', [ + n.component(new Txt(l10n.l('dialogReport.thanksForReport', "Thanks for helping us keep this place friendly and safe!"), { tagName: 'div', className: 'dialogreport--disclaimer' })), n.component(new ModelTxt(char, m => (m.name + " " + m.surname).trim(), { className: 'dialogreport--fullname flex-1' })), // n.component(eventComponent), n.component('msg', new PanelSection( @@ -90,7 +103,11 @@ class DialogReport { model.set({ msg: v }); }, }, - attributes: { name: 'dialogreport-msg', spellcheck: 'true' }, + attributes: { + name: 'dialogreport-msg', + spellcheck: 'true', + placeholder: l10n.t('dialogReport.messagePlaceholder', "What do you wish for us to look into?"), + }, }), { className: 'common--sectionpadding', @@ -109,82 +126,90 @@ class DialogReport { })), n.component(new ModelCollapser(model, [{ condition: m => m.attachLog, - factory: m => new PanelSection( - l10n.l('dialogReport.logInterval', "Log interval"), - new Elem(n => n.elem('div', { className: 'pad-bottom-l' }, [ - n.elem('div', [ - n.component(new ModelTxt( - model, - m => toTime(m.time, m.start), - { duration: 0 }, - )), - n.text(" – "), - n.component(new Context( - () => ({ timer: null }), - o => clearTimeout(o.timer), - o => new ModelComponent( + factory: m => new Elem(n => n.elem('div', { className: 'common--formsubsection' }, [ + n.component(new LabelToggleBox(l10n.l('dialogReport.excludePrivateWithOthers', "Exclude private communication with others"), model.excludePrivate, { + className: 'common--formmargin small', + onChange: (v, c) => model.set({ excludePrivate: v }), + popupTip: l10n.l('dialogReport.excludePrivateWithOthersdInfo', "Exclude private communication, such as whispers or messages, with other characters. Private communication with the reported character will still be included."), + popupTipClassName: 'popuptip--width-m', + })), + n.component(new PanelSection( + l10n.l('dialogReport.logInterval', "Log interval"), + new Elem(n => n.elem('div', { className: 'pad-bottom-l' }, [ + n.elem('div', [ + n.component(new ModelTxt( model, - new Txt('', { duration: 0 }), - (m, c, change) => { - if (change && !change.hasOwnProperty('end')) return; + m => toTime(m.time, m.start), + { duration: 0 }, + )), + n.text(" – "), + n.component(new Context( + () => ({ timer: null }), + o => clearTimeout(o.timer), + o => new ModelComponent( + model, + new Txt('', { duration: 0 }), + (m, c, change) => { + if (change && !change.hasOwnProperty('end')) return; - clearTimeout(o.timer); - let endTime = addMin(m.time, m.end); - let diff = endTime - Date.now(); - c.setText(diff > 0 - ? l10n.l('dialogReport.now', "now") - : formatTime(endTime), - ); - if (diff > 0) { - o.timer = setTimeout(() => { - c.setText(formatTime(endTime)); - }, diff); - } + clearTimeout(o.timer); + let endTime = addMin(m.time, m.end); + let diff = endTime - Date.now(); + c.setText(diff > 0 + ? l10n.l('dialogReport.now', "now") + : formatTime(endTime), + ); + if (diff > 0) { + o.timer = setTimeout(() => { + c.setText(formatTime(endTime)); + }, diff); + } + }, + ), + )), + ]), + n.component(new NoUiSlider({ + start: [ model.start, model.end ], + step: 5, + range: hasEvent + ? { min: [ -60 ], max: [ 60 ] } + : { min: [ -150 ], max: [ 10 ] }, + connect: [ false, true, false ], + pips: { + mode: 'steps', + density: 60, + filter: v => v == 0 ? 0 : -1, + format: { + to: v => toTime(model.time, v), + from: v => v, }, - ), - )), - ]), - n.component(new NoUiSlider({ - start: [ model.start, model.end ], - step: 5, - range: hasEvent - ? { min: [ -60 ], max: [ 60 ] } - : { min: [ -150 ], max: [ 10 ] }, - connect: [ false, true, false ], - pips: { - mode: 'steps', - density: 60, - filter: v => v == 0 ? 0 : -1, - format: { - to: v => toTime(model.time, v), - from: v => v, }, - }, - className: 'dialogreport--slider pips-centered', - onUpdate: (c, v, handle, unencoded, tap, positions, slider) => { - let start = parseInt(v[0]); - let end = parseInt(v[1]); - // If we have an event, always include it (pos 0). - if (hasEvent) { - if (start > 0) { - start = 0; - slider.setHandle(0, 0, false); - } - if (end < 0) { - end = 0; - slider.setHandle(1, 0, false); + className: 'dialogreport--slider pips-centered', + onUpdate: (c, v, handle, unencoded, tap, positions, slider) => { + let start = parseInt(v[0]); + let end = parseInt(v[1]); + // If we have an event, always include it (pos 0). + if (hasEvent) { + if (start > 0) { + start = 0; + slider.setHandle(0, 0, false); + } + if (end < 0) { + end = 0; + slider.setHandle(1, 0, false); + } } - } - model.set({ start, end }); - }, - })), - ])), - { - className: 'common--sectionpadding small', - noToggle: true, - popupTip: l10n.l('dialogReport.logIntevalInfo', "Time interval of log events to include in the attachment."), - }, - ), + model.set({ start, end }); + }, + })), + ])), + { + className: 'common--sectionpadding small', + noToggle: true, + popupTip: l10n.l('dialogReport.logIntevalInfo', "Time interval of log events to include in the attachment."), + }, + )), + ])), }])), ]), n.component(new LabelToggleBox(l10n.l('dialogReport.attachProfile', "Attach character profile to report"), model.attachProfile, { @@ -228,10 +253,13 @@ class DialogReport { } let attachments = []; + let filter = model.excludePrivate + ? (ev) => !ev.noReport && !isPrivateWithOthers(ev, charId, puppeteerId) + : (ev) => !ev.noReport; // Attach logs if needed this.reportPromise = (model.attachLog - ? this._getLog(ctrl, addMin(model.time, model.start).getTime(), addMin(model.time, model.end).getTime()).then(events => { + ? this._getLog(ctrl, addMin(model.time, model.start).getTime(), addMin(model.time, model.end).getTime(), filter).then(events => { attachments.push({ type: 'log', params: { events }, @@ -285,7 +313,7 @@ class DialogReport { n.setComponent(msg ? new Txt(msg, { className: 'dialog--error' }) : null); } - _getLog(char, startTime, endTime, chunk, log) { + _getLog(char, startTime, endTime, filter, chunk, log) { chunk = chunk || 0; log = log || []; return this.module.charLog.getLog(char, chunk).then(l => { @@ -301,12 +329,12 @@ class DialogReport { end = i; } if (ev.time < startTime) { - return l.slice(i + 1, end).filter(ev => !ev.noReport).concat(log); + return l.slice(i + 1, end).filter(filter).concat(log); } } } - return this._getLog(char, startTime, endTime, chunk + 1, end ? l.slice(0, end).filter(ev => ev.sig).concat(log) : log); + return this._getLog(char, startTime, endTime, filter, chunk + 1, end ? l.slice(0, end).filter(filter).concat(log) : log); }); } diff --git a/src/client/modules/main/dialogs/dialogReport/dialogReport.scss b/src/client/modules/main/dialogs/dialogReport/dialogReport.scss index 5a8aad98..5bdfacce 100644 --- a/src/client/modules/main/dialogs/dialogReport/dialogReport.scss +++ b/src/client/modules/main/dialogs/dialogReport/dialogReport.scss @@ -3,6 +3,12 @@ .dialogreport { max-width: 380px; + &--disclaimer { + padding-bottom: 12px; + font-size: 13px; + font-style: italic; + } + &--fullname { display: block; margin-bottom: 12px; @@ -15,6 +21,10 @@ width: 152px; } + &--attachlog { + padding-left: 8px; + } + &--interval { padding: 0 9px; } From c98cd493f0d5cf76fec987d27cc51bfc804780b6 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Mon, 14 Oct 2024 12:34:46 +0200 Subject: [PATCH 09/15] GH-317: Added Wipe buttons to DialogCharSnapshotAttachment. Added support to Img for error placeholder image. --- .../modules/main/addons/avatar/Avatar.js | 24 ++-- .../main/addons/avatar/AvatarComponent.js | 103 +++++++++------ .../modules/main/addons/avatar/avatar.scss | 2 +- .../PageEditCharProfileComponent.js | 1 - .../PageEditRoomProfileComponent.js | 1 - .../DialogCharSnapshotAttachment.js | 2 + .../DialogCharSnapshotAttachmentSnapshot.js | 121 +++++++++++++----- .../dialogCharSnapshotAttachment.scss | 18 +-- src/client/static/img/area-error-l.png | Bin 0 -> 4598 bytes src/client/static/img/area-l.png | Bin 3985 -> 1271 bytes src/client/static/img/avatar-error-l.png | Bin 0 -> 4328 bytes src/client/static/img/avatar-l.png | Bin 4403 -> 1800 bytes src/client/static/img/room-error-l.png | Bin 0 -> 3728 bytes src/client/static/img/room-l.png | Bin 3383 -> 1210 bytes src/common/components/Img.js | 84 +++++++----- src/common/scss/_flex.scss | 4 + src/common/utils/listenResource.js | 8 +- 17 files changed, 239 insertions(+), 129 deletions(-) create mode 100644 src/client/static/img/area-error-l.png create mode 100644 src/client/static/img/avatar-error-l.png create mode 100644 src/client/static/img/room-error-l.png diff --git a/src/client/modules/main/addons/avatar/Avatar.js b/src/client/modules/main/addons/avatar/Avatar.js index 72d9bfa1..b57366c0 100644 --- a/src/client/modules/main/addons/avatar/Avatar.js +++ b/src/client/modules/main/addons/avatar/Avatar.js @@ -9,9 +9,9 @@ class Avatar { constructor(app, params) { this.app = app; let apiFilePath = app.props.apiFilePath; - this.avatarPattern = apiFilePath + 'core/char/avatar/{0}'; - this.charImgPattern = apiFilePath + 'core/char/img/{0}'; - this.roomImgPattern = apiFilePath + 'core/room/img/{0}'; + this.avatarPattern = (v) => apiFilePath + 'core/char/avatar/' + v; + this.charImgPattern = (v) => apiFilePath + 'core/char/img/' + v; + this.roomImgPattern = (v) => apiFilePath + 'core/room/img/' + v; this.app.require([], this._init.bind(this)); } @@ -25,13 +25,18 @@ class Avatar { * @param {object} [opt.char] Character object used to fetch initials in profile is not a character. * @param {string} [opt.size] Avatar size. May be 'small', 'medium', 'large', or 'xlarge'. * @param {string} [opt.property] Char property to get the image ID. Defaults to 'avatar'. - * @param {string} [opt.resolve] Resolves the image ID from the property. Defaults to v => v. + * @param {boolean} [opt.initials] Use initials if no image is available. Defaults to true. * @param {string} [opt.placeholder] Placeholder image to use instead of initials. May be 'avatar', 'room', or 'area'. * @param {boolean} [opt.modalOnClick] Flag if clicking on the image should show the full image in a modal. + * @param {(v: any) => string} [opt.resolve] Resolves the image href from the image property. * @returns {Component} Avatar component. */ newAvatar(char, opt) { - return new AvatarComponent(char, Object.assign({ pattern: this.avatarPattern }, opt)); + return new AvatarComponent(char, Object.assign({ + resolve: this.avatarPattern, + placeholder: 'avatar', + initials: true, + }, opt)); } /** @@ -41,14 +46,15 @@ class Avatar { * @param {object} [opt.char] Character object used to fetch initials in profile is not a character. * @param {string} [opt.size] Avatar size. May be 'small', 'medium', 'large', or 'xlarge'. * @param {string} [opt.property] Char property to get the image ID. Defaults to 'image'. - * @param {string} [opt.resolve] Resolves the image ID from the property. Defaults to v => v. + * @param {boolean} [opt.initials] Use initials if no image is available. Defaults to false. * @param {string} [opt.placeholder] Placeholder image to use instead of initials. May be 'avatar'. Defaults to 'avatar'. * @param {boolean} [opt.modalOnClick] Flag if clicking on the image should show the full image in a modal. + * @param {(v: any) => string} [opt.resolve] Resolves the image href from the image property. * @returns {Component} Avatar component. */ newCharImg(char, opt) { return new AvatarComponent(char, Object.assign({ - pattern: this.charImgPattern, + resolve: this.charImgPattern, placeholder: 'avatar', property: 'image', }, opt)); @@ -61,14 +67,14 @@ class Avatar { * @param {object} [opt.char] Character object used to fetch initials in profile is not a character. * @param {string} [opt.size] Avatar size. May be 'small', 'medium', 'large', or 'xlarge'. * @param {string} [opt.property] Char property to get the image ID. Defaults to 'room'. - * @param {string} [opt.resolve] Resolves the image ID from the property. Defaults to v => v. * @param {string} [opt.placeholder] Placeholder image to use instead of initials. May be 'avatar'. Defaults to 'avatar'. * @param {boolean} [opt.modalOnClick] Flag if clicking on the image should show the full image in a modal. + * @param {(v: any) => string} [opt.resolve] Resolves the image href from the image property. * @returns {Component} Avatar component. */ newRoomImg(room, opt) { return new AvatarComponent(room, Object.assign({ - pattern: this.roomImgPattern, + resolve: this.roomImgPattern, placeholder: 'room', property: 'image', }, opt)); diff --git a/src/client/modules/main/addons/avatar/AvatarComponent.js b/src/client/modules/main/addons/avatar/AvatarComponent.js index c7e8e779..31afe4e3 100644 --- a/src/client/modules/main/addons/avatar/AvatarComponent.js +++ b/src/client/modules/main/addons/avatar/AvatarComponent.js @@ -2,10 +2,8 @@ import { isResError } from 'resclient'; import { ModelListener, ModelTxt } from 'modapp-resource-component'; import Fader from 'components/Fader'; import Img from 'components/Img'; -import FAIcon from 'components/FAIcon'; import ImgModal from 'classes/ImgModal'; - -let defaultPattern = '/file/core/char/avatar/{0}'; +import { relistenResource } from 'utils/listenResource'; // Get character initials. function getInitials(c) { @@ -21,11 +19,15 @@ const sizeMap = { }; const placeholderMap = { - avatar: '/img/avatar-l.png', - room: '/img/room-l.png', - area: '/img/area-l.png', + avatar: { img: '/img/avatar-l.png', err: '/img/avatar-error-l.png' }, + room: { img: '/img/room-l.png', err: '/img/room-error-l.png' }, + area: { img: '/img/area-l.png', err: '/img/area-error-l.png' }, }; +function getHref(v) { + return v.href; +} + /** * AvatarComponent is a character avatar component. */ @@ -35,11 +37,11 @@ class AvatarComponent extends Fader { * Creates an instance of AvatarComponent * @param {object} profile Character or profile object. * @param {object} [opt] Optional parameters. - * @param {object} [opt.char] Character object used to fetch initials in profile is not a character. + * @param {object} [opt.char] Character object used to fetch initials if profile is not a character. * @param {string} [opt.size] Avatar size. May be 'small', 'medium', or 'large. - * @param {string} [opt.pattern] URL pattern for the avatar. Should contain "{0}" to replace with the avatar ID. - * @param {string} [opt.property] Char property to get the image ID. Defaults to 'avatar'. - * @param {string} [opt.resolve] Resolves the image ID from the property. Defaults to v => v. + * @param {string} [opt.property] Char property to get the image object or ID. Defaults to 'avatar'. + * @param {boolean} [opt.initials] Use initials if no image is available. Defaults to false. + * @param {(v: object) => string} [opt.resolve] Resolves the image href from the image property. Defaults to: (v) => v?.href * @param {string} [opt.placeholder] Placeholder image to use instead of initials. May be 'avatar', 'room', or 'area'. * @param {boolean} [opt.modalOnClick] Flag if clicking on the image should show the full image in a modal. */ @@ -50,16 +52,17 @@ class AvatarComponent extends Fader { this.char = opt.char || profile; this.saturation = opt.saturation || 0.5; this.lightness = opt.lightness || 0.33; - this.pattern = opt.pattern || defaultPattern; this.property = opt.property || 'avatar'; + this.initials = !!opt.initials; this.placeholder = (opt.placeholder && placeholderMap[opt.placeholder]) || null; this.modalOnClick = !!opt.modalOnClick; this.query = sizeMap[opt.size] || sizeMap['medium']; - this.resolve = opt.resolve || (v => this.pattern.replace("{0}", v)); + this.resolve = opt.resolve || getHref; this.isError = isResError(profile); + this.model = null; + this._update = this._update.bind(this); this.ml = new ModelListener(profile, this, this._changeHandler.bind(this)); - this._setHue(this.char); } render(el) { @@ -69,6 +72,7 @@ class AvatarComponent extends Fader { } unrender() { + this.model = relistenResource(this.model, null, this._update); this.ml.onUnrender(); super.unrender(); } @@ -76,39 +80,64 @@ class AvatarComponent extends Fader { setChar(char) { this.char = char; this.ml.setModel(char); - this._setHue(char); return this; } _changeHandler(m, c, change) { - if (change && !change.hasOwnProperty(this.property)) return; - - let imageId = m ? m[this.property] : null; - let src = imageId - ? this.resolve(imageId) + this.query - : this.placeholder; - - c.setComponent(m - ? this.isError - ? new FAIcon('times') - : src - ? new Img(src, this.modalOnClick && imageId ? { - className: 'clickable', - events: { - click: c => { - if (!c.hasClass('placeholder')) { - new ImgModal(this.resolve(imageId)).open(); - } - }, + if (!change || change.hasOwnProperty(this.property)) { + this._update(); + } + } + + _update() { + let src = null; + let isError = this.isError; + let m = this.ml.getModel(); + if (!isError) { + let v = m?.[this.property] || null; + if (v) { + this.model = relistenResource(this.model, v, this._update); + src = this.resolve(v); + isError = !src || v?.deleted; + } + } + + this.setComponent(isError || !(src || this.initials) + ? this.placeholder + ? new Img(isError ? this.placeholder.err : this.placeholder.img) + : null + : src + ? new Img(src + this.query, this.modalOnClick ? { + className: 'clickable', + errorPlaceholder: this.placeholder.err, + errorClassName: 'avatar--error', + events: { + click: c => { + if (!c.hasClass('avatar--error')) { + new ImgModal(src).open(); + } }, - } : null) - : new ModelTxt(this.char || m, m => getInitials(m), { tagName: 'span' }) - : null, + }, + } : { + errorPlaceholder: this.placeholder.err, + }) + : new ModelTxt(this.char || m, m => getInitials(m), { tagName: 'span' }), ); + if (isError || src || !this.initials) { + this._clearHue(); + } else { + this._setHue(this.char); + } + } + + _clearHue() { + if (this.initials) return; + this.setStyle('backgroundColor', null); + this.setStyle('color', null); } _setHue(char) { - if (this.placeholder) return; + if (!this.initials) return; let h = 0; if (!this.isError) { diff --git a/src/client/modules/main/addons/avatar/avatar.scss b/src/client/modules/main/addons/avatar/avatar.scss index e2abe6b3..6bdfc3c8 100644 --- a/src/client/modules/main/addons/avatar/avatar.scss +++ b/src/client/modules/main/addons/avatar/avatar.scss @@ -16,7 +16,7 @@ width: 48px; height: 48px; - &.clickable { + &.clickable:not(.avatar--error) { cursor: pointer; } } diff --git a/src/client/modules/main/pages/pageEditCharProfile/PageEditCharProfileComponent.js b/src/client/modules/main/pages/pageEditCharProfile/PageEditCharProfileComponent.js index 9d330413..944c20a2 100644 --- a/src/client/modules/main/pages/pageEditCharProfile/PageEditCharProfileComponent.js +++ b/src/client/modules/main/pages/pageEditCharProfile/PageEditCharProfileComponent.js @@ -64,7 +64,6 @@ class PageEditCharProfileComponent { n.component(new Txt(l10n.l('pageEditCharProfile.deleteImageBody', "Do you really wish to update the profile with current character image?"), { tagName: 'p' })), n.component(this.module.avatar.newCharImg(this.ctrl, { size: 'xlarge', - resolve: v => v.href, })), ])), confirm: l10n.l('pageEditCharProfile.update', "Update"), diff --git a/src/client/modules/main/pages/pageEditRoomProfile/PageEditRoomProfileComponent.js b/src/client/modules/main/pages/pageEditRoomProfile/PageEditRoomProfileComponent.js index 9ff6e15f..e0789287 100644 --- a/src/client/modules/main/pages/pageEditRoomProfile/PageEditRoomProfileComponent.js +++ b/src/client/modules/main/pages/pageEditRoomProfile/PageEditRoomProfileComponent.js @@ -66,7 +66,6 @@ class PageEditRoomProfileComponent { n.component(new Txt(l10n.l('pageEditRoomProfile.deleteImageBody', "Do you really wish to update the profile with current room image?"), { tagName: 'p' })), n.component(this.module.avatar.newRoomImg(this.room, { size: 'xlarge', - resolve: v => v.href, })), ])), confirm: l10n.l('pageEditRoomProfile.update', "Update"), diff --git a/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachment.js b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachment.js index f5f4c030..40f63d48 100644 --- a/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachment.js +++ b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachment.js @@ -13,6 +13,8 @@ class DialogCharSnapshotAttachment { 'charLog', 'confirm', 'avatar', + 'toaster', + 'player', ], this._init.bind(this)); } diff --git a/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachmentSnapshot.js b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachmentSnapshot.js index b8cab918..8ed375e1 100644 --- a/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachmentSnapshot.js +++ b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/DialogCharSnapshotAttachmentSnapshot.js @@ -4,10 +4,12 @@ import l10n from 'modapp-l10n'; import Collapser from 'components/Collapser'; import PanelSection from 'components/PanelSection'; import FormatTxt from 'components/FormatTxt'; -import Img from 'components/Img'; +// import Img from 'components/Img'; import ModelCollapser from 'components/ModelCollapser'; +import ModelFader from 'components/ModelFader'; import CharTagsList, { hasTags } from 'components/CharTagsList'; -import ImgModal from 'classes/ImgModal'; +import FAIcon from 'components/FAIcon'; +// import ImgModal from 'classes/ImgModal'; import firstLetterUppercase from 'utils/firstLetterUppercase'; import errString from 'utils/errString'; import formatDateTime from 'utils/formatDateTime'; @@ -63,7 +65,11 @@ class DialogCharSnapshotAttachmentSnapshot { n.elem('div', { className: 'flex-row pad12 pad-bottom-l' }, [ n.elem('div', { className: 'flex-auto' }, [ - n.component(this.module.avatar.newAvatar(this.snapshot, { className: 'badge--icon', size: 'large' })), + n.component(this.module.avatar.newAvatar(this.snapshot, { + className: 'dialogcharsnapshotattachment--image badge--icon', + size: 'large', + resolve: (v) => v.href, + })), ]), n.elem('div', { className: 'flex-1' }, [ n.component(new ModelTxt(this.snapshot, m => errString(m, m => (m.name + ' ' + m.surname).trim(), txtUnknown), { tagName: 'div', className: 'dialogcharsnapshotattachment--fullname' })), @@ -95,41 +101,61 @@ class DialogCharSnapshotAttachmentSnapshot { n.component(new ModelTxt(this.snapshot, m => formatDateTime(new Date(m.timestamp)))), ]), ]), + // Wipe avatar button + n.component(new ModelFader(this.snapshot.avatar, [{ + condition: avatar => avatar?.href && !avatar?.deleted, + factory: avatar => new Elem(n => n.elem('div', { className: 'dialogcharsnapshotattachment--imagebtn' }, [ + n.elem('button', { + className: 'btn medium icon-left lighten', + events: { + click: () => this.module.confirm.open(() => this._wipeCharAvatar(), { + title: l10n.l('dialogCharSnapshotAttachment.confirmWipe', "Confirm avatar wipe"), + body: l10n.l('dialogCharSnapshotAttachment.wipeAvatarBody', "Do you really wish to wipe the avatar from the character's profiles?"), + confirm: l10n.l('dialogCharSnapshotAttachment.wipeAvatar', "Wipe avatar"), + }), + }, + }, [ + n.component(new FAIcon('trash')), + n.component(new Txt(l10n.l('dialogCharSnapshotAttachment.wipeAvatar', "Wipe avatar"))), + ]), + ])), + }], { + className: 'flex-auto', + })), ]), n.component(new ModelCollapser(this.snapshot, [{ condition: m => m.image, factory: m => new PanelSection( l10n.l('dialogCharSnapshotAttachment.image', "Image"), - new Elem(n => n.elem('div', { className: 'flex-row flex-stretch pad8' }, [ + new Elem(n => n.elem('div', { className: 'flex-row flex-stretch gap8' }, [ n.elem('div', { className: 'flex-1' }, [ - n.component(new Img(m.image.href + '?thumb=xl', { className: 'dialogcharsnapshotattachment--image', events: { - click: c => { - if (!c.hasClass('placeholder')) { - new ImgModal(m.image.href).open(); - } - }, - }})), + n.component(this.module.avatar.newCharImg(this.snapshot, { + className: 'dialogcharsnapshotattachment--image', + size: 'xlarge', + modalOnClick: true, + resolve: v => v.href, + })), ]), - // n.elem('div', { className: 'dialogcharsnapshotattachment--imagebtn flex-1' }, [ - // n.component(new ModelComponent( - // this.snapshot, - // new Elem(n => n.elem('button', { - // className: 'btn medium icon-left', - // events: { - // click: () => this.module.confirm.open(() => this._deleteCharImage(), { - // title: l10n.l('dialogCharSnapshotAttachment.confirmDelete', "Confirm deletion"), - // body: l10n.l('dialogCharSnapshotAttachment.deleteImageBody', "Do you really wish to delete the image?"), - // confirm: l10n.l('dialogCharSnapshotAttachment.delete', "Delete"), - // }), - // }, - // }, [ - // n.component(new FAIcon('trash')), - // n.component(new Txt(l10n.l('dialogCharSnapshotAttachment.delete', "Delete"))), - // ])), - // (m, c) => c.setProperty('disabled', m.image ? null : 'disabled'), - // )), - // ]), + // Wipe image button + n.component(new ModelFader(m.image, [{ + condition: image => image?.href && !image?.deleted, + factory: image => new Elem(n => n.elem('div', { className: 'dialogcharsnapshotattachment--imagebtn flex-auto' }, [ + n.elem('button', { + className: 'btn medium icon-left lighten', + events: { + click: () => this.module.confirm.open(() => this._wipeCharImage(), { + title: l10n.l('dialogCharSnapshotAttachment.confirmWipe', "Confirm image wipe"), + body: l10n.l('dialogCharSnapshotAttachment.wipeImageBody', "Do you really wish to wipe the image from the character's profiles?"), + confirm: l10n.l('dialogCharSnapshotAttachment.wipeImage', "Wipe image"), + }), + }, + }, [ + n.component(new FAIcon('trash')), + n.component(new Txt(l10n.l('dialogCharSnapshotAttachment.wipeImage', "Wipe image"))), + ]), + ])), + }])), ])), { className: 'common--sectionpadding', @@ -251,6 +277,41 @@ class DialogCharSnapshotAttachmentSnapshot { : null, ); } + + _wipeCharAvatar() { + let imgRidParts = this.snapshot.avatar.getResourceId().split('.'); + + return this.module.player.getPlayer().call('wipeCharAvatar', { + charId: this.snapshot.id, + avatar: imgRidParts[imgRidParts.length - 1], + }).then(() => this.module.toaster.open({ + title: l10n.l('dialogCharSnapshotAttachment.avatarWiped', "Avatar wiped"), + content: new Txt(l10n.l('dialogCloseTicket.avatarWipedInfo', "The avatar was wiped from the character.")), + closeOn: 'click', + type: 'success', + autoclose: true, + })).catch(err => this.module.toaster.openError(err, { + title: l10n.l('dialogCharSnapshotAttachment.failedToWipeAvatar', "Failed to wipe avatar"), + })); + + } + + _wipeCharImage() { + let imgRidParts = this.snapshot.image.getResourceId().split('.'); + + return this.module.player.getPlayer().call('wipeCharImage', { + charId: this.snapshot.id, + image: imgRidParts[imgRidParts.length - 1], + }).then(() => this.module.toaster.open({ + title: l10n.l('dialogCharSnapshotAttachment.imageWiped', "Image wiped"), + content: new Txt(l10n.l('dialogCloseTicket.imageWipedInfo', "The image was wiped from the character.")), + closeOn: 'click', + type: 'success', + autoclose: true, + })).catch(err => this.module.toaster.openError(err, { + title: l10n.l('dialogCharSnapshotAttachment.failedToWipeImage', "Failed to wipe image"), + })); + } } export default DialogCharSnapshotAttachmentSnapshot; diff --git a/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/dialogCharSnapshotAttachment.scss b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/dialogCharSnapshotAttachment.scss index be4f6d31..f2f3e608 100644 --- a/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/dialogCharSnapshotAttachment.scss +++ b/src/client/modules/moderator/moderatorDialogs/dialogCharSnapshotAttachment/dialogCharSnapshotAttachment.scss @@ -33,24 +33,16 @@ line-height: 20px; } - &--image { - display: block; - border-radius: 10px; - width: 140px; - height: 140px; + &--image, &--avatar { background: $color1-lighter; + } - &:not(.placeholder) { - cursor: pointer; + &--imagebtn { + > .btn + .btn { + margin-top: 8px; } } - // &--imagebtn { - // > .btn + .btn { - // margin-top: 8px; - // } - // } - &--lfrp-placeholder { display: block; font-style: italic; diff --git a/src/client/static/img/area-error-l.png b/src/client/static/img/area-error-l.png new file mode 100644 index 0000000000000000000000000000000000000000..73fc9400823b25a18a74bd1a536811e9b6113cda GIT binary patch literal 4598 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 literal 0 HcmV?d00001 diff --git a/src/client/static/img/area-l.png b/src/client/static/img/area-l.png index 85ef557dc01b187786d5f5ec58e0a008e755bf83..0c015c1cace20b32f91c7b14db58acbfbb92b0c6 100644 GIT binary patch literal 1271 zcmeAS@N?(olHy`uVBq!ia0vp^Js`}%3?ygDZRBBKV2lay32_B-#U#~65iLR>hUsz^ zFno(kg8YIR7@3$^**G}4xOw>a1q6kJrKDwKRn^orGSDA4w>(KoRD z$TklShR`Kh!Mh^@UkI(b5UD!**Sq=09QIo`C&+!^{UG&AI=RR2n8aoO>LXc~BfCR( zoo0Ng(rzwi*l!!KPQ-HVTVefB-|Fy2-b%THf0lVV#uT!j)|jE4C!lQSCt%0@!>_im z^@L_Xa-365a!b7RFHaLo=2bH?YJP6W{;uoWS}1Y+`+|;_NuEX_%Z?gpM>{E{WqF-}#90=UxBZ{_fy|1>5Hpzl#+Vye|FY)? zs;|zT`|hy6JHl`;>rVdi0Ct9FscEkrG}u1eJMwd5S{VbM@!y@z9y}LTxfB-3-+o~5 z_;$F1%ChjVh!^~3dW>Ag+a59(8U!x9A8*a9;q&dS<%0%Jr|oG6nK^t6boeB+ORB{f zb}YBu@I$!Z_>G0?&%ZM^RLl%9(D9S7{-R#ma9Cs?6AxR))b?Fd{`j%0Jgbz}VY^{6 zO;bL>Q2T-5k356*2a>bunLZy*5Ic|@_Frt-H~+n7>iY!UB$n#-7#z~bO>7Z#%j8z> zxfJGmh-2}V(8(adt13MPUeOYXErGWVaRBAS+-5wu|J5d~_4tp;Y@3fJU#N3SF8dt& zA>d@PVRb7@XQqwsw-YnHCLfd$3FhQ0u6TRMHS;C?#Je#Yge zi9r6^r`6jJ7(ZR6{Vd5gn}O%qpS!W*ZmZSxB>w+(21@^H-zf0MW2vNg@mI}s2A*Ym zZ3PdWpR&hd#<_RO%nJgYhtqb$<#!_&7{qCwGr74iddB_JMNiWeq;CYLFm2bhC`n3O{rdBs7D){T zuhyeK-zo7tVT^G5o|KRva9UIE)8vE&JQ|S!8BFU}8cKIe+c+g%Lc@T|?VyGP>*55F eo~A$pE(WQ%stI@I@yG*n7=x#)pUXO@geCy)v$nqg literal 3985 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 xXuQ2{tqM$Y~ug` diff --git a/src/client/static/img/avatar-error-l.png b/src/client/static/img/avatar-error-l.png new file mode 100644 index 0000000000000000000000000000000000000000..799cb3d87c4a7fdaf61dfd558feaf942ca307962 GIT binary patch literal 4328 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 Wf`klviQDJ^00000|6@# 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 diff --git a/src/client/static/img/room-error-l.png b/src/client/static/img/room-error-l.png new file mode 100644 index 0000000000000000000000000000000000000000..453b2be907ce7bc7eba073dabd9672a588e3e553 GIT binary patch literal 3728 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 { let im = new Image(); let clear = () => im = im.onerror = im.onabort = im.onload = null; - im.onload = ev => { - clear(); - resolve(ev); - }; - im.onerror = ev => { + let onErr = ev => { clear(); - reject(ev); + resolve({ src, url: opt?.errorPlaceholder || '', cl: opt?.errorClassName || null }); }; - im.onabort = ev => { + im.onload = ev => { clear(); - reject(ev); + resolve({ src, url: src, cl: null }); }; + im.onerror = onErr; + im.onabort = onErr; im.src = src; }) - : Promise.reject("No image"); + : Promise.resolve({ src, url: opt?.placeholder || '', cl: opt?.placeholderClassName || null }); } /** @@ -34,7 +32,10 @@ class Img extends RootElem { * @param {object} [opt] Optional parameters. * @param {string} [opt.className] Class name * @param {object} [opt.attributes] Key/value attributes object - * @param {object} [opt.events] Key/value events object, where the key is the event name, and value is the callback. + * @param {object} [opt.placeholder] Placeholder image to use if no src is set. + * @param {object} [opt.errorPlaceholder] Placeholder image to use on error. + * @param {string} [opt.placeholderClassName] ClassName to add when using placeholder. + * @param {string} [opt.errorClassName] ClassName to add on error. */ constructor(src, opt) { opt = Object.assign({}, opt); @@ -43,6 +44,8 @@ class Img extends RootElem { this.animId = null; this.current = null; this.loadPromise = null; + this.loaded = null; + this.opt = opt; this.setSrc(src); } @@ -59,11 +62,11 @@ class Img extends RootElem { // Start loading image this.src = src; - this.loaded = false; - this.loadPromise = whenImageLoaded(src).catch(() => {}).then(() => { + this.loaded = null; + this.loadPromise = whenImageLoaded(src, this.opt).then(result => { // Make sure the image src hasn't changed while loading. if (src === this.src) { - this.loaded = true; + this.loaded = result; } }); @@ -72,15 +75,17 @@ class Img extends RootElem { anim.stop(this.animId); - if (this.current === src && src) { + // Same src as currently showing? Fade it to visibility again. + if (this.current?.src === src && this.current?.url) { this.animId = anim.fade(el, 1); return this; } this.animId = anim.fade(el, 0, { callback: () => { - if (this.src !== src) return; - this._setCurrent(); + if (this.src === src) { + this._setCurrent(); + } }, }); return this; @@ -96,9 +101,8 @@ class Img extends RootElem { render(el) { let e = super.render(el); - if (this.loaded) { - this.current = this.src; - this._setSrcAttr(this.src); + if (this.loaded && this.loaded?.src === this.src) { + this._setSrcAttr(this.loaded); } else { e.style.opacity = 0; this._setCurrent(); @@ -108,6 +112,7 @@ class Img extends RootElem { unrender() { anim.stop(this.animId); super.unrender(); + this.current = null; } /** @@ -115,26 +120,37 @@ class Img extends RootElem { * It assumes opacity is 0 when called. */ _setCurrent() { - let src = this.src; - this._setSrcAttr(src); - this.current = src; - if (src) { - this.loadPromise.then(() => { - let el = super.getElement(); - if (!el || this.current !== src || this.src !== src) { - return; - } + this.loadPromise.then(() => { + let el = super.getElement(); + // Assert we the element is rendered and that the loaded data is for + // this source. + if (!el || this.src !== this.loaded?.src) { + return; + } + this._setSrcAttr(this.loaded); + // Show if we have something to show + if (this.loaded?.url) { this.animId = anim.fade(el, 1); - }); - } + } + }); } - _setSrcAttr(src) { - if (src) { - this._rootElem.setAttribute('src', src); + _setSrcAttr(next) { + let prev = this.current; + if (next?.url) { + this._rootElem.setAttribute('src', next.url); } else { this._rootElem.removeAttribute('src'); } + if (prev?.cl != next?.cl) { + if (prev?.cl) { + this._rootElem.removeClass(prev.cl); + } + if (next?.cl) { + this._rootElem.addClass(next.cl); + } + } + this.current = next; } } diff --git a/src/common/scss/_flex.scss b/src/common/scss/_flex.scss index 86166d6f..6759509a 100644 --- a/src/common/scss/_flex.scss +++ b/src/common/scss/_flex.scss @@ -53,6 +53,10 @@ } } + &.gap8 { + gap: 8px; + } + } .flex-col { diff --git a/src/common/utils/listenResource.js b/src/common/utils/listenResource.js index 4a436f96..c4f3c055 100644 --- a/src/common/utils/listenResource.js +++ b/src/common/utils/listenResource.js @@ -8,7 +8,7 @@ * @returns {Model|Collection} Resource being listened to. */ export default function listenResource(resource, on, onEvent, event) { - if (resource) { + if (resource && typeof resource == 'object') { let method = on || typeof on == 'undefined' ? 'on' : 'off'; if (typeof resource[method] == 'function') { if (onEvent) { @@ -33,7 +33,9 @@ export default function listenResource(resource, on, onEvent, event) { */ export function relistenResource(oldResource, newResource, onEvent, event) { newResource = newResource || null; - listenResource(oldResource, false, onEvent, event); - listenResource(newResource, true, onEvent, event); + if (oldResource !== newResource) { + listenResource(oldResource, false, onEvent, event); + listenResource(newResource, true, onEvent, event); + } return newResource; } From 3eaff174634cc9b149f4d2f308b77d3f045c5b62 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Tue, 15 Oct 2024 12:20:52 +0200 Subject: [PATCH 10/15] GH-317: Added PlayerEventImageWipe module to display imageWipe notice. --- .../PlayerEventImageWipe.js | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js diff --git a/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js b/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js new file mode 100644 index 00000000..cad33721 --- /dev/null +++ b/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js @@ -0,0 +1,61 @@ +import { Elem, Txt } from 'modapp-base-component'; +import l10n from 'modapp-l10n'; + +const imageTxt = { + title: l10n.l('playerEventImageWipe.imageRemoved', "Image removed"), + default: l10n.l('playerEventImageWipe.imageRemovedInfo', "An image was removed:"), + target: { + char: l10n.l('playerEventImageWipe.charImageRemovedInfo', "An image was removed from your character:"), + room: l10n.l('playerEventImageWipe.roomImageRemovedInfo', "An image was removed from your room:"), + area: l10n.l('playerEventImageWipe.areaImageRemovedInfo', "An image was removed from your area:"), + }, +}; +const avatarTxt = { + title: l10n.l('playerEventImageWipe.avatarRemoved', "Avatar removed"), + default: l10n.l('playerEventImageWipe.avatarRemovedInfo', "An avatar was removed:"), + target: { + char: l10n.l('playerEventImageWipe.charAvatarRemovedInfo', "An avatar was removed from your character:"), + room: l10n.l('playerEventImageWipe.roomAvatarRemovedInfo', "An avatar was removed from your room:"), + area: l10n.l('playerEventImageWipe.areaAvatarRemovedInfo', "An avatar was removed from your area:"), + }, +}; + +/** + * PlayerEventImageWipe registers the imageWipe playerEvent handler. + */ +class PlayerEventImageWipe { + constructor(app, params) { + this.app = app; + + // Bind callbacks + this._handleEvent = this._handleEvent.bind(this); + + this.app.require([ 'playerEvent', 'toaster' ], this._init.bind(this)); + } + + _init(module) { + this.module = module; + + this.module.playerEvent.addHandler('imageWipe', this._handleEvent); + } + + _handleEvent(ev, onClose) { + let txt = ev.avatar ? avatarTxt : imageTxt; + let info = txt.target[ev.target] || txt.default; + return this.module.toaster.open({ + title: txt.title, + content: close => new Elem(n => n.elem('div', [ + n.component(new Txt(info, { tagName: 'p' })), + n.component(new Txt(ev.name, { tagName: 'p', className: 'dialog--strong dialog--large' })), + ])), + closeOn: 'click', + onClose, + }); + } + + dispose() { + this.module.playerEvent.removeHandler('imageWipe'); + } +} + +export default PlayerEventImageWipe; From 44f74f9e91d263c0a943822732f9713a32218616 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Tue, 15 Oct 2024 12:49:48 +0200 Subject: [PATCH 11/15] GH-317: Added ReportAttachmentWipeCharImageAvatar to format wipeCharImage and wipeCharAvatar moderator actions. --- .../ReportAttachmentWipeCharImageAvatar.js | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/client/modules/moderator/moderatorPages/pageReports/reportAttachmentWipeCharImageAvatar/ReportAttachmentWipeCharImageAvatar.js diff --git a/src/client/modules/moderator/moderatorPages/pageReports/reportAttachmentWipeCharImageAvatar/ReportAttachmentWipeCharImageAvatar.js b/src/client/modules/moderator/moderatorPages/pageReports/reportAttachmentWipeCharImageAvatar/ReportAttachmentWipeCharImageAvatar.js new file mode 100644 index 00000000..a6f46a6a --- /dev/null +++ b/src/client/modules/moderator/moderatorPages/pageReports/reportAttachmentWipeCharImageAvatar/ReportAttachmentWipeCharImageAvatar.js @@ -0,0 +1,65 @@ +import { Elem, Txt } from 'modapp-base-component'; +import { ModelTxt } from 'modapp-resource-component'; +import l10n from 'modapp-l10n'; + +const txtType = l10n.l('reportAttachmentWipeCharImageAvatar.type', "Type"); +const txtWipeCharImage = l10n.l('reportAttachmentWipeCharImageAvatar.wipeImage', "Wipe image"); +const txtWipeCharAvatar = l10n.l('reportAttachmentWipeCharImageAvatar.wipeAvatar', "Wipe avatar"); + +function fromTxt(inUse, inProfile) { + return inUse + ? inProfile + ? l10n.l('reportAttachmentWipeCharImageAvatar.currentAndStoredProfile', "Found in current and stored profile.") + : l10n.l('reportAttachmentWipeCharImageAvatar.currentProfile', "Found in current profile.") + : inProfile + ? l10n.l('reportAttachmentWipeCharImageAvatar.storedProfile', "Found in stored profile.") + : l10n.l('reportAttachmentWipeCharImageAvatar.notFoundInProfile', "Wiped but not found in profile."); +} + +/** + * ReportAttachmentWipeCharImageAvatar adds the wipeCharImage and wipeCharAvatar + * action report attachment type. + */ +class ReportAttachmentWipeCharImageAvatar { + constructor(app, params) { + this.app = app; + + this.app.require([ + 'pageReports', + ], this._init.bind(this)); + } + + _init(module) { + this.module = Object.assign({ self: this }, module); + + // Register wipeCharImage + this.module.pageReports.addAttachmentType({ + id: 'wipeCharImage', + componentFactory: (info, reporter) => this._factory(info, reporter, false), + }); + + this.module.pageReports.addAttachmentType({ + id: 'wipeCharAvatar', + componentFactory: (info, reporter) => this._factory(info, reporter, true), + }); + } + + _factory(info, reporter, isAvatar) { + return new Elem(n => n.elem('div', [ + n.elem('div', { className: 'flex-row' }, [ + n.component(new Txt(txtType, { className: 'badge--iconcol badge--subtitle' })), + n.component(new Txt(isAvatar ? txtWipeCharAvatar : txtWipeCharImage, { className: 'badge--info badge--text' })), + ]), + n.elem('div', { className: 'badge--text' }, [ + n.component(new ModelTxt(info, m => fromTxt(m.inUse, m.inProfile), { className: 'common--formattext' })), + ]), + ])); + } + + dispose() { + this.module.pageReports.removeAttachmentType('wipeCharImage'); + this.module.pageReports.removeAttachmentType('wipeCharAvatar'); + } +} + +export default ReportAttachmentWipeCharImageAvatar; From 5bc26b85806b94da9e990776f549c75643c93f9c Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Sat, 19 Oct 2024 15:47:52 +0200 Subject: [PATCH 12/15] GH-317: Added WipeChar command. Updated moderator command help with info about moderation actions. --- .../modules/main/addons/avatar/Avatar.js | 6 +- .../PlayerEventImageWipe.js | 16 +-- .../PageEditCharProfileComponent.js | 1 + .../moderatorCommands/banPlayer/BanPlayer.js | 1 + .../moderatorCommands/compare/Compare.js | 2 +- .../moderatorCommands/suspend/Suspend.js | 1 + .../unbanPlayer/UnbanPlayer.js | 1 + .../moderatorCommands/unsuspend/Unsuspend.js | 1 + .../moderator/moderatorCommands/warn/Warn.js | 1 + .../moderatorCommands/wipeChar/WipeChar.js | 122 ++++++++++++++++++ 10 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 src/client/modules/moderator/moderatorCommands/wipeChar/WipeChar.js diff --git a/src/client/modules/main/addons/avatar/Avatar.js b/src/client/modules/main/addons/avatar/Avatar.js index b57366c0..e3928ecf 100644 --- a/src/client/modules/main/addons/avatar/Avatar.js +++ b/src/client/modules/main/addons/avatar/Avatar.js @@ -81,15 +81,15 @@ class Avatar { } charImgHref(char) { - return char.image ? this.charImgPattern.replace("{0}", char.image) : null; + return char.image ? this.charImgPattern(char.image) : null; } avatarHref(char) { - return char.avatar ? this.avatarPattern.replace("{0}", char.avatar) : null; + return char.avatar ? this.avatarPattern(char.avatar) : null; } roomImgHref(room) { - return room.image ? this.roomImgPattern.replace("{0}", room.image) : null; + return room.image ? this.roomImgPattern(room.image) : null; } } diff --git a/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js b/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js index cad33721..8d11a063 100644 --- a/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js +++ b/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js @@ -3,20 +3,20 @@ import l10n from 'modapp-l10n'; const imageTxt = { title: l10n.l('playerEventImageWipe.imageRemoved', "Image removed"), - default: l10n.l('playerEventImageWipe.imageRemovedInfo', "An image was removed:"), + default: l10n.l('playerEventImageWipe.imageRemovedInfo', "An image has been removed:"), target: { - char: l10n.l('playerEventImageWipe.charImageRemovedInfo', "An image was removed from your character:"), - room: l10n.l('playerEventImageWipe.roomImageRemovedInfo', "An image was removed from your room:"), - area: l10n.l('playerEventImageWipe.areaImageRemovedInfo', "An image was removed from your area:"), + char: l10n.l('playerEventImageWipe.charImageRemovedInfo', "An image has been removed from your character:"), + room: l10n.l('playerEventImageWipe.roomImageRemovedInfo', "An image has been removed from your room:"), + area: l10n.l('playerEventImageWipe.areaImageRemovedInfo', "An image has been removed from your area:"), }, }; const avatarTxt = { title: l10n.l('playerEventImageWipe.avatarRemoved', "Avatar removed"), - default: l10n.l('playerEventImageWipe.avatarRemovedInfo', "An avatar was removed:"), + default: l10n.l('playerEventImageWipe.avatarRemovedInfo', "An avatar has been removed:"), target: { - char: l10n.l('playerEventImageWipe.charAvatarRemovedInfo', "An avatar was removed from your character:"), - room: l10n.l('playerEventImageWipe.roomAvatarRemovedInfo', "An avatar was removed from your room:"), - area: l10n.l('playerEventImageWipe.areaAvatarRemovedInfo', "An avatar was removed from your area:"), + char: l10n.l('playerEventImageWipe.charAvatarRemovedInfo', "An avatar has been removed from your character:"), + // room: l10n.l('playerEventImageWipe.roomAvatarRemovedInfo', "An avatar has been removed from your room:"), + // area: l10n.l('playerEventImageWipe.areaAvatarRemovedInfo', "An avatar has been removed from your area:"), }, }; diff --git a/src/client/modules/main/pages/pageEditCharProfile/PageEditCharProfileComponent.js b/src/client/modules/main/pages/pageEditCharProfile/PageEditCharProfileComponent.js index 944c20a2..9d330413 100644 --- a/src/client/modules/main/pages/pageEditCharProfile/PageEditCharProfileComponent.js +++ b/src/client/modules/main/pages/pageEditCharProfile/PageEditCharProfileComponent.js @@ -64,6 +64,7 @@ class PageEditCharProfileComponent { n.component(new Txt(l10n.l('pageEditCharProfile.deleteImageBody', "Do you really wish to update the profile with current character image?"), { tagName: 'p' })), n.component(this.module.avatar.newCharImg(this.ctrl, { size: 'xlarge', + resolve: v => v.href, })), ])), confirm: l10n.l('pageEditCharProfile.update', "Update"), diff --git a/src/client/modules/moderator/moderatorCommands/banPlayer/BanPlayer.js b/src/client/modules/moderator/moderatorCommands/banPlayer/BanPlayer.js index 9a1920b3..869bfb6f 100644 --- a/src/client/modules/moderator/moderatorCommands/banPlayer/BanPlayer.js +++ b/src/client/modules/moderator/moderatorCommands/banPlayer/BanPlayer.js @@ -13,6 +13,7 @@ const usageText = 'ban player @UsernameBan a player from the realm. Everything the player owns will remain, but the player can no longer connect to the realm.

+

A moderator action will be added to an existing report. If a report doesn't exist for the character, one will be created and assigned to you.

@Username is the username of the player. Admins only.

Character is the name of a character owned by the player.

`; const examples = [ diff --git a/src/client/modules/moderator/moderatorCommands/compare/Compare.js b/src/client/modules/moderator/moderatorCommands/compare/Compare.js index f1a2b6e6..27c97c25 100644 --- a/src/client/modules/moderator/moderatorCommands/compare/Compare.js +++ b/src/client/modules/moderator/moderatorCommands/compare/Compare.js @@ -10,7 +10,7 @@ const usageText = 'compare Character/< const shortDesc = 'Compare two characters to see if their players match'; const helpText = `

Compare a reported character with another character to see if their players match by account, email address, or IPs used.

-

If a report doesn't exist for the character, one will be created and assigned to you.

+

A moderator action will be added to an existing report. If a report doesn't exist for the character, one will be created and assigned to you.

Character is the name of the reported characters.

#Character ID is the ID of the reported character.

Compare Character is the name of the character to compare with.

diff --git a/src/client/modules/moderator/moderatorCommands/suspend/Suspend.js b/src/client/modules/moderator/moderatorCommands/suspend/Suspend.js index 910bafac..462dda1d 100644 --- a/src/client/modules/moderator/moderatorCommands/suspend/Suspend.js +++ b/src/client/modules/moderator/moderatorCommands/suspend/Suspend.js @@ -9,6 +9,7 @@ const shortDesc = 'Suspend a character'; const helpText = `

Suspend a character by putting it to sleep and disabling the option of waking them up again for a period of time.

The duration is automatically set for an hour, a day, or a week, depending on previous suspensions. The penalty period that increases suspend duration is reset after a maximum of 2 weeks.

+

A moderator action will be added to an existing report. If a report doesn't exist for the character, one will be created and assigned to you.

Character is the full name of the character to suspend.

Reason is the reason why the character was suspended, as shown to that player.

Example: suspend John Doe = Being abusive by calling another player an idiot.

`; diff --git a/src/client/modules/moderator/moderatorCommands/unbanPlayer/UnbanPlayer.js b/src/client/modules/moderator/moderatorCommands/unbanPlayer/UnbanPlayer.js index 1f754699..0f92521a 100644 --- a/src/client/modules/moderator/moderatorCommands/unbanPlayer/UnbanPlayer.js +++ b/src/client/modules/moderator/moderatorCommands/unbanPlayer/UnbanPlayer.js @@ -8,6 +8,7 @@ const usageText = 'unban player @Username@Username is the username of the player. Admins only.

Character is the name of a character owned by the player.

Example: unban player Jane Innocent

`; diff --git a/src/client/modules/moderator/moderatorCommands/unsuspend/Unsuspend.js b/src/client/modules/moderator/moderatorCommands/unsuspend/Unsuspend.js index eceadd15..2b98f263 100644 --- a/src/client/modules/moderator/moderatorCommands/unsuspend/Unsuspend.js +++ b/src/client/modules/moderator/moderatorCommands/unsuspend/Unsuspend.js @@ -6,6 +6,7 @@ const shortDesc = 'Unsuspend a currently suspended character'; const helpText = `

Unsuspend a currently suspended character.

The command also reduces the penalty period which increments the duration a character is suspended.

+

A moderator action will be added to an existing report. If a report doesn't exist for the character, one will be created and assigned to you.

Character is the full name of the character to unsuspend.

`; /** diff --git a/src/client/modules/moderator/moderatorCommands/warn/Warn.js b/src/client/modules/moderator/moderatorCommands/warn/Warn.js index 12ad5b66..c97c5541 100644 --- a/src/client/modules/moderator/moderatorCommands/warn/Warn.js +++ b/src/client/modules/moderator/moderatorCommands/warn/Warn.js @@ -12,6 +12,7 @@ const helpText = `

Send a warning message to one or more awake characters in the realm. The warning is shown as a red colored message.
A warning is always considered an out of character (OOC) message.
If the message starts with : (colon), the message will be treated in the same way as a pose action rather than something being texted.

+

A moderator action will be added to an existing report. If a report doesn't exist for the character, one will be created and assigned to you.

Character is the name of the character to warn. If omitted, it defaults to the characters last warned.

Message is the warning text. It may be formatted and span multiple paragraphs.

`; const examples = [ diff --git a/src/client/modules/moderator/moderatorCommands/wipeChar/WipeChar.js b/src/client/modules/moderator/moderatorCommands/wipeChar/WipeChar.js new file mode 100644 index 00000000..6f5344e2 --- /dev/null +++ b/src/client/modules/moderator/moderatorCommands/wipeChar/WipeChar.js @@ -0,0 +1,122 @@ +import { Elem, Txt } from 'modapp-base-component'; +import { ModelTxt } from 'modapp-resource-component'; +import l10n from 'modapp-l10n'; +import FAIcon from 'components/FAIcon'; +import DelimStep from 'classes/DelimStep'; +import ListStep from 'classes/ListStep'; +import ItemList from 'classes/ItemList'; +import Err from 'classes/Err'; + +const usageText = 'wipe char Character : Attribute'; +const shortDesc = 'Wipe an image or avatar from a character'; +const helpText = +`

Wipe the currently used image or avatar from a character.

+

A moderator action will be added to an existing report. If a report doesn't exist for the character, one will be created and assigned to you.

+

Character is the name of the character to wipe from.

+

Attribute is the attribute to wipe. May be image or avatar.

`; + +const attrOpt = { + image: { + method: 'wipeCharImage', + confirm: l10n.l('wipeChar.wipeCharImageBody', "Do you really wish to wipe the current image of the character?"), + success: l10n.l('wipeChar.successfullyWipedImage', "Successfully wiped image from {charName}."), + }, + avatar: { + method: 'wipeCharAvatar', + confirm: l10n.l('wipeChar.wipeCharAvatarBody', "Do you really wish to wipe the current avatar of the character?"), + success: l10n.l('wipeChar.successfullyWipedAvatar', "Successfully wiped avatar from {charName}."), + }, +}; + +/** + * WipeChar adds command to wipe character attributes. + */ +class WipeChar { + constructor(app) { + this.app = app; + + this.app.require([ + 'cmd', + 'cmdSteps', + 'charLog', + 'info', + 'helpModerate', + 'confirm', + ], this._init.bind(this)); + } + + _init(module) { + this.module = module; + this.helpTopic = null; + + this.module.cmd.addPrefixCmd('wipe', { + key: 'char', + next: [ + this.module.cmdSteps.newAnyCharStep({ + errRequired: step => new Err('transferChar.characterRequired', "What character do you wish to wipe images from?"), + sortOrder: [ 'awake', 'watch', 'room' ], + }), + new DelimStep(":", { errRequired: null }), + new ListStep('attr', new ItemList({ + items: [ + { key: 'image' }, + { key: 'avatar' }, + ], + }), { + name: "character attribute", + token: 'attr', + }), + ], + value: (ctx, p) => this.wipeCharAttribute(ctx.player, ctx.char, p), + }); + + this.module.helpModerate.addTopic({ + id: 'wipeChar', + cmd: 'wipe char', + usage: l10n.l('wipeChar.usage', usageText), + shortDesc: l10n.l('wipeChar.shortDesc', shortDesc), + desc: l10n.t('wipeChar.helpText', helpText), + sortOrder: 230, + }); + } + + /** + * Wipes an image or avatar from a character. + * @param {Model} player Player model + * @param {Model} ctrl Controlled character model + * @param {object} params Params + * @param {string} [params.charId] ID of character to wipe from. + * @param {string} [params.charName] Name of character to wipe from. Ignored if charId is set. + * @param {string} [params.attr] Attribute to wipe. Either "image" or "avatar". + * @returns Promise + */ + wipeCharAttribute(player, ctrl, params) { + let o = attrOpt[params.attr]; + if (!o) { + throw ("Invalid attribute: " + params.attr); + } + + return player.call('getChar', params.charId + ? { charId: params.charId } + : { charName: params.charName }, + ).then(char => { + this.module.confirm.open(() => player.call(o.method, { charId: char.id }).then(() => { + this.module.charLog.logInfo(ctrl, l10n.l(o.success, { charName: (char.name + " " + char.surname).trim() })); + }), { + title: l10n.l('wipeChar.confirmWipe', "Confirm wipe"), + body: new Elem(n => n.elem('div', [ + n.component(new Txt(o.confirm, { tagName: 'p' })), + n.elem('p', [ n.component(new ModelTxt(char, m => (m.name + " " + m.surname).trim(), { className: 'dialog--strong' })) ]), + n.elem('p', { className: 'dialog--error' }, [ + n.component(new FAIcon('exclamation-triangle')), + n.html("  "), + n.component(new Txt(l10n.l('wipeChar.wipeWarning', "A wipe cannot be undone."))), + ]), + ])), + confirm: l10n.l('wipeChar.delete', "Wipe"), + }); + }); + } +} + +export default WipeChar; From bd4110f12c9a141df61d8162885f96bc97f7dc58 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Sat, 19 Oct 2024 16:21:28 +0200 Subject: [PATCH 13/15] GH-317: Added info on contacting moderator team in PlayerEventImageWipe. --- .../playerEventImageWipe/PlayerEventImageWipe.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js b/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js index 8d11a063..10c6d679 100644 --- a/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js +++ b/src/client/modules/main/addons/playerEvent/playerEventImageWipe/PlayerEventImageWipe.js @@ -1,4 +1,4 @@ -import { Elem, Txt } from 'modapp-base-component'; +import { Elem, Txt, Html } from 'modapp-base-component'; import l10n from 'modapp-l10n'; const imageTxt = { @@ -20,6 +20,9 @@ const avatarTxt = { }, }; +const moreInfoTxt = l10n.l('playerEventImageWipe.moreInfo', `
If you have questions or objections, get in contact with the moderator team. Type:
` + + `
help helpme
`); + /** * PlayerEventImageWipe registers the imageWipe playerEvent handler. */ @@ -47,6 +50,7 @@ class PlayerEventImageWipe { content: close => new Elem(n => n.elem('div', [ n.component(new Txt(info, { tagName: 'p' })), n.component(new Txt(ev.name, { tagName: 'p', className: 'dialog--strong dialog--large' })), + n.component(new Html(moreInfoTxt, { tagName: 'div', className: 'common--sectionpadding' })), ])), closeOn: 'click', onClose, From 754544e0996490ff4022e5856052d078be99ca67 Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Fri, 25 Oct 2024 16:04:11 +0200 Subject: [PATCH 14/15] GH-319: Increased line-height for charlog--ev to 1.4em, and increased padding between events. --- .../main/addons/exportLog/htmlTemplate.js | 2 +- .../modules/main/layout/charLog/charLog.scss | 3 ++- .../main/layout/charLog/charLogEvent.scss | 25 +++++++++++-------- .../layout/charLog/rollEvent/rollEvent.scss | 2 +- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/client/modules/main/addons/exportLog/htmlTemplate.js b/src/client/modules/main/addons/exportLog/htmlTemplate.js index 1e3b65db..432db6be 100644 --- a/src/client/modules/main/addons/exportLog/htmlTemplate.js +++ b/src/client/modules/main/addons/exportLog/htmlTemplate.js @@ -10,7 +10,7 @@ export const bodyEnd = ``; export const htmlEnd = ``; export const style = ` -body { font-family: "Open Sans", sans-serif; font-size: 16px; color: #7d818c; background-color: #161926; margin: 0; line-height: 125%; padding: 8px 16px; } +body { font-family: "Open Sans", sans-serif; font-size: 16px; color: #7d818c; background-color: #161926; margin: 0; line-height: 1.4em; padding: 8px 16px; } .cont { width: 100%; max-width: 720px; diff --git a/src/client/modules/main/layout/charLog/charLog.scss b/src/client/modules/main/layout/charLog/charLog.scss index 22c7c4ed..d857c5c1 100644 --- a/src/client/modules/main/layout/charLog/charLog.scss +++ b/src/client/modules/main/layout/charLog/charLog.scss @@ -24,6 +24,7 @@ border-left: 4px solid transparent; overflow-wrap: break-word; padding: 2px 0 2px 6px; + line-height: 1.4em; pre { white-space: pre-wrap; @@ -96,7 +97,7 @@ &--fieldset { position: relative; border: 1px solid $color4-dark; - padding: 6px 10px; + padding: 5px 10px; border-radius: 8px; } diff --git a/src/client/modules/main/layout/charLog/charLogEvent.scss b/src/client/modules/main/layout/charLog/charLogEvent.scss index cf9d2fd8..efffa95e 100644 --- a/src/client/modules/main/layout/charLog/charLogEvent.scss +++ b/src/client/modules/main/layout/charLog/charLogEvent.scss @@ -1,6 +1,9 @@ @import '~scss/variables'; @import '~scss/mixins'; +$charlog-textpad: 6px; +$charlog-labelpad: 8px; + .charlog-event { &--tooltip { font-style: normal; @@ -16,54 +19,54 @@ font-size: $font-size-small; white-space: pre-wrap; // color: $color4; - padding: 4px 0 0 0; + padding-top: $charlog-textpad; } &error, &errorComponent { color: $log-error; font-style: italic; - padding-top: 4px; + padding-top: $charlog-textpad; } &info { // color: $color4-dark; font-style: italic; - padding-top: 4px; + padding-top: $charlog-textpad; } &whisper, &message, &mail { white-space: pre-wrap; // color: $color4-lightest; - padding: 8px 0 0 0; + padding-top: $charlog-labelpad; } &dnd { white-space: pre-wrap; // color: $color4; - padding: 8px 0 0 0; + padding-top: $charlog-labelpad; } &say, &pose { white-space: pre-wrap; - padding: 2px 0 0 0; + padding-top: $charlog-textpad; } &ooc, &address { white-space: pre-wrap; // color: $color4-lightest; - padding: 7px 0 0 0; + padding-top: $charlog-labelpad; } &msg, &wakeup, &sleep, &leave, &arrive, &travel, &action { // color: $color4; - padding: 7px 0 0 0; + padding-top: $charlog-labelpad; font-style: italic; } &warn { white-space: pre-wrap; // color: $log-error; - padding: 8px 0 0 0; + padding-top: $charlog-labelpad; .charlog--fieldset { border-color: $log-error; @@ -76,7 +79,7 @@ &summon, &join, &leadRequest, &followRequest, &follow, &stopFollow, &stopLead, &controlRequest { // color: $color4; - padding: 8px 0 0 0; + padding-top: $charlog-labelpad; font-style: italic; } @@ -84,7 +87,7 @@ font-size: $font-size-small; white-space: pre-wrap; // color: $color4; - padding: 4px 0 0 0; + padding-top: $charlog-textpad; } &travel { diff --git a/src/client/modules/main/layout/charLog/rollEvent/rollEvent.scss b/src/client/modules/main/layout/charLog/rollEvent/rollEvent.scss index 54fef7c3..8b3de7c3 100644 --- a/src/client/modules/main/layout/charLog/rollEvent/rollEvent.scss +++ b/src/client/modules/main/layout/charLog/rollEvent/rollEvent.scss @@ -3,7 +3,7 @@ .ev-roll { white-space: pre-wrap; - padding: 4px 0 0 0; + padding: 8px 0 0 0; sub { font-size: 75%; From 34625bb0bb1778303a537a2ade054a923463c04c Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Fri, 25 Oct 2024 16:14:52 +0200 Subject: [PATCH 15/15] Prepare release v1.61.0 --- package-lock.json | 4 ++-- package.json | 2 +- src/common/static/policies/payment.json | 2 +- src/common/static/policies/privacy.json | 2 +- src/common/static/policies/terms.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 032e73cf..b298397a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mucklet-client", - "version": "1.60.1", + "version": "1.61.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mucklet-client", - "version": "1.60.1", + "version": "1.61.0", "license": "Apache-2.0", "dependencies": { "@codemirror/commands": "^6.3.0", diff --git a/package.json b/package.json index 7922ab49..f0ff59be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mucklet-client", - "version": "1.60.1", + "version": "1.61.0", "description": "A web client for Mucklet.", "repository": { "type": "git", diff --git a/src/common/static/policies/payment.json b/src/common/static/policies/payment.json index 2a470f42..791ca5b1 100644 --- a/src/common/static/policies/payment.json +++ b/src/common/static/policies/payment.json @@ -1,6 +1,6 @@ { "title": "Mucklet's Payment Terms", "slug": "payment", - "created": 1688049624253, + "created": 1729936800000, "body": "

Thank you for supporting Mucklet! These Payment Terms (or just \"Terms\") are a\nsupplement to the Terms of\nService, and describes the terms and conditions for payments made to\nMucklet.

\n

By making a Payment or setting up a payment subscription using our purchase\nflow, you agree to these Terms. Of course, if you don't agree with them, you can\nenjoy the game without making any payments.

\n\n

Payment processors

\n

We use third-party Payment Processors (such as Stripe, PayPal, or others) to\nprocess payments. These Payment Processors may require you to agree to their own\nterms and conditions in order to use them.

\n

By providing a payment method, you agree that you are authorized to use that\npayment method, and that the information you provide is true and accurate.

\n\n

Personal data

\n

While the Payment Processors may gather and store additional information\nabout you and your purchase, in accordance to their terms, Mucklet will not\nhandle or store any personal information gathered by the Payment Processor. But\nwe will store partial details of your payment method (such as the last four\ndigits and expiry date of your card), enough to provide you with hints about\nwhich method you used for a payment or subscription. We will also store\ngeographical information shared by the Payment Processor and derived from your\nIP address, sufficient enough to be able to comply with global tax laws.

\n\n

Taxes and fees

\n

Any tax that we are required by applicable law to collect, such as sales\ntax, VAT, GST, or similar, will be included in the amount displayed to you. You\nare responsible for all other applicable taxes, data plans, internet fees, and\nother fees associated with your use of the services.

\n\n

Subscriptions

\n

You may set up recurring payments, also called Subscriptions. By doing so,\nyou agree to let us periodically charge your payment method with the amount and\nfrequency that you have given us mandate to when setting up a subscription. You\ncan cancel the subscription at any time.

\n\n

Billing interval

\n

Once the first payment is made on a subscription, any recurring payment will\nbe made on the same time and day of the month as the initial payment. If a month\ndoesn't have the same day, the payment will be made on the last day of that\nmonth. For example, a monthly subscription with its initial payment being made on\nJanuary 31 will recur on February 28 (or February 29 in a leap year), then\nMarch 31, April 30, and so on.

\n

If you set up a new subscription while you have days remaining from previous\npayments, the first payment will be scheduled to the date when the remaining\ndays expire. You will never lose any remaining days by making an additional\npayment, or setting up a subscription.

\n\n

Cancel subscriptions

\n

You may cancel a subscription at any time. To cancel a subscription, go to Mucklet - Account\noverview, click on the active subscription, click Unsubscribe, and\nconfirm. Canceling a subscription will stop any new payments to be made on that\nsubscription, but you will keep access to all the perks and features for the\nremaining days of the subscription period.

\n\n

Forced cancelation

\n

We reserve the right to cancel your subscription at any time and for any\nreason. If you, as a result of us canceling your subscription, also lose access\nto the perks and features that you have paid for, we will refund you\nproportionally for the remaining days of the subscription period.

\n

If your account is suspended or banned due to violation of the Terms of\nService or deleted on your own request, your subscription will be canceled\nautomatically, but no refund will be made for any remaining days of the\nsubscription period.

\n\n

Failed payments

\n

If we fail to charge your payment method at the end of a subscription period,\nwe will retry once a day for two consequtive days following the failed attempt.\nIf all three attempts fail, your subscription will be canceled automatically.\nYou will keep any perks and features associated with the subscription during the\nretry period. The retry period ends when a payment is successful, or the\nsubscription is canceled.

\n\n

Changes in subscription cost

\n

We reserve the right to change the cost associated with a subscription. For\nraised subscription fees, we may, at our sole discretion, continue to charge the\nprior subscription fee. Any subsequent cancellations and re-subscriptions will\nuse the new current rate for the subscription. If we decide to start charging\nyou a higher fee for your subscription, you will be notified about the change\nand given the choice to agree to continue the subscription at the new rate. If\nyou do not agree, your subscription will be automatically canceled prior to any\npayment being made using the higher fee. If we lower the fee of the offer that\nyou are subscribing to, we will charge you the lower fee, starting with your\nnext pending payment.

\n\n

Refunds

\n

We offer refunds in case we have charged you too much, charge you too often,\nor in other ways failed to comply with what is stated in these Terms and in the\ninformation provided to you during the payment or subscription setup process. We\nwill also offer refunds for other reasons not stated, or contradictory to these\nTerms, if required by applicable law.

\n

While we try to provide a fun and friendly service that is available at all\ntimes, we do not offer refunds if you regret making a payment, or if you no\nlonger can access or enjoy the service. This includes, but is not limited to,\nour servers being inaccessible, the client not loading on your device, or the\nservice being blocked in your country. Additionally, if your account is\nsuspended or banned due to violation of the Terms of Service or in-game rules,\nwe are not required to compensate you for any loss or make any refunds to\nyou.

\n

Submit refund requests to support@mucklet.com. Each\nrequest will be reviewed by us on a case-by-case basis and eligibility is\ndetermined at our sole discretion.

\n\n

Changes in offer

\n

We may at any time change the content of an offer that you have paid for or\nare subscribing to, by adding or removing perks and features included in the\noffer.

\n\n

Expiration

\n

When your paid period expires, and you do not have an active subscription\nthat will renew the period, the functionality and perks included in the offer\nwill no longer be accessible to your account. Any content that has not been\ndeleted but can no longer be fully utilized or accessed due to the withdrawn\nfunctionality, will remain downloadable, exportable, or in other ways\nretrievable. By starting a new paid period, functionality and content will\nreturn to same accessible state again.

" } diff --git a/src/common/static/policies/privacy.json b/src/common/static/policies/privacy.json index 6ee5bbe0..75b62247 100644 --- a/src/common/static/policies/privacy.json +++ b/src/common/static/policies/privacy.json @@ -1,6 +1,6 @@ { "title": "Mucklet's Privacy Policy", "slug": "privacy", - "created": 1688049624253, + "created": 1729936800000, "body": "

This service is hosted by Mucklet AB, which is the \"we\", \"us\" and \"our\" in this Privacy Policy.

\n

We don't really want your data. But we need some of it in order to make all of\nthis work, to provide a fun, friendly, and safe place to hang out. And to help\nus make it even better.

\n\n

What information we collect

\n

This is an online service. This means we store the information you give to us, such as username, email address, game settings, character information, and more. That is how it works.

\n

We may also automatically gather information such as IP-address, device/browser being used, or the browser's language settings, to help us improve and protect the service.

\n

In case of payments, we also store required geographical information, such as country and postal code, to be able to comply with global tax laws.

\n\n

How the information is collected

\n

We store it when you visit the website, register your user account, and when you send us the information. It is pretty straight forward.

\n\n

How the information is used

\n

We use the information to run the game. We don't sell any personal information of our users. We don't analyze any information or communication to target ads. We don't even run ads. We use the information for the purpose you have given it to us.

\n

Email, primarily used for restoring lost passwords, may also be used to send information on events directly related to the game. But you can opt out of this under the in-game settings.

\n\n

How passwords are stored

\n

If you use password login, instead of an OpenID service such as Google, we need to store your password. But we ensure your password is stored securely. In fact, we also pre-hash the password in your browser before sending it to us, so that we will never handle your actual password.

\n\n

How content is stored

\n

Content that you create or upload to the game, such as characters, rooms, images, and more, is stored as part of the game realm. In case you decide to delete your account, your personal information will be deleted but the created content will remain.

\n\n

How communication is stored

\n

Communication within the game is not persisted by default, and therefore not part of any backups - with the exception of in-game mail which by nature requires storage. In case a user makes a report against another player's character, communication available to the reporter may be stored to allow moderators to assess the report.

\n

Your browser may also be configured to store communication for your own personal use. This information is not shared with us.

\n\n

Where the information is stored

\n

Mucklet AB is located in Sweden, which is part of EU. All the information will be processed and stored within EU.

\n\n

Cookies

\n

We use cookies. But the cookie that we store locally on your webbrowser does not contain any identifying information; it is only used to keep you logged in.

\n\n

Disclosure of information

\n

We are not into the business of selling your information. We don't want to disclose your information. There are, however, some circumstances in which we may still have to do it:

\n\n
    \n
  • Consent - If you give us the consent to transfer your information.
  • \n
  • Agents, Consultants, and Related Third Parties - If we hire other companies to help build the service, or perform other business-related functions - such as hosting the servers, or processing payments.
  • \n
  • Legal requirements - If we have good faith belief that the law requires such disclosure. We hope it never comes to that.
  • \n
  • Aggregated or Non-identifiable Data - We may share aggregated and non-personally identifiable information with others - such as how many unique players are using the service each month.
  • \n
\n\n

Children or minors

\n

We don't allow users under the age of 18 to register. We do not knowingly collect personal information from minors.\n\n

Contacting us

\n

If you have any questions regarding this Privacy Policy or how your information is handled, feel free to ask us by email: privacy@mucklet.com

\n\n

Changes to this privacy policy

\n

This Privacy Policy may be updated at any time.

" } diff --git a/src/common/static/policies/terms.json b/src/common/static/policies/terms.json index 96cdc718..172930df 100644 --- a/src/common/static/policies/terms.json +++ b/src/common/static/policies/terms.json @@ -1,6 +1,6 @@ { "title": "Mucklet's Terms of Service", "slug": "terms", - "created": 1688049624253, + "created": 1729936800000, "body": "

These Terms of Service (or just \"Terms\") let you know the rules that govern\nour relationship with you as a user of our game and forum, which is collectively\nreferred to as the \"Service\". These Terms form a contract between you and\nMucklet AB (\"we\", \"us\", \"our\"), to let you know what we give you rights to use\nthis Service for, and also what rights you agree to give to us.

\n\n

We reserves the right to update these Terms for reasons including, but not\nlimited to, complying with changes to the law or reflecting enhancements to the\nService. If the changes affect your usage of the Service or your legal rights,\nwe will notify you at least seven days before the changes take effect.

\n\n

By using our Service, you agree to these Terms. Of course, if you don't agree\nwith them, don't use the Service.

\n\n

Who can use the Service

\n

No one under 18 is allowed to create an account or use the Service. This is\nbecause we cannot guarantee that the content is suitable for minors, since much\nof the content is produced by other users. We would also not be able to properly\nsafeguard minors against grooming, or other illicit behavior.

\n\n

Rights we grant you

\n

We own this Service, but we give you the right to use it and enjoy it, in a\nway that these Terms and the in-game rules allow. We may revoke this right at\nany time for reasons including, but not limited to, you using the Service in a\nway not authorized by these Terms (including helping others to do so), or if you\ndo not comply with the in-game rules.

\n\n

Rights you grant us

\n

Our Service allows you to create, send, receive, upload, and store\ninformation; also called \"content\". When you do that, you retain whatever\nownership right in that content you had to begin with. But you grant us the\nright to use that content for the purpose it was provided, even if you decide to\nstop using the Service or delete your account.

\n

You also give us the right to access, review, and delete any content that we\nthink violates these Terms or the in-game rules. You alone, though, remain\nresponsible for the content you provide.

\n\n

Privacy

\n

Our Privacy Policy describes\nhow we store and handle the information you provide to us.

\n\n

The content of others

\n

Just like you keep whatever ownership you had for the content you provide,\nothers will also do so for their content. Whether that content is made public or\nsent to you in private, you may only use that content for your own private\npurpose, unless you get the rights from the owners.

\n

Also, just like you are responsible for the content you provide, others are\nresponsible for the content they provide. Although we reserve the right to\nreview content, we do not necessarily review it all. So we cannot - and do not -\nguarantee that other users' content will comply with these Terms and the in-game\nrules.

\n\n

Prohibited content

\n

You may not upload content that contains:

\n
    \n
  • sexually suggestive or explicit photos of real people;
  • \n
  • photos depicting graphic violence, humiliation, or gore;
  • \n
  • sexually suggestive or explicit images of characters with childlike appearance;
  • \n
\n

If an image is not a photo, but may be mistaken for one, it will be regarded\nas a photo.

\n\n

Respecting others' rights

\n

You may not provide content that infringes on someone elses intellectual\nproperty. If you use an image, or add content based on someone elses work,\nmake sure you have the rights to do so. And give credits where credit is\ndue.

\n\n

Payments

\n

Our services are free to use, but you may be able to pay for additional\nfeatures and perks. Our Payment\nTerms describe the terms and conditions for payments made to us.\n\n

Permitted use

\n

You must only use the Service in a way permitted by these Terms. This means,\namong other things, you may not do, attempt to do, enable, or encourage anyone\nelse to do, any of the following:

\n
    \n
  • use the Service in a way not permitted by law;
  • \n
  • use the Service in a way that could interfere, disrupt, negatively affect, or inhibit other users from fully enjoying the Service;
  • \n
  • access or use the Service in any manner that could damage, disable, overburden, or impair the function of the Service;
  • \n
  • attempt to gain unauthorized access to the Service including, but not limited to, other accounts, other users' content, or server infrastructure;
  • \n
  • create or use bots accessing the Service for any other purpose than improving game play;
  • \n
  • create a new account if we have disabled your account;
  • \n
\n\n

Disclaimers

\n

We provide this service to you \"as is\" and \"as available\", without warranty\nof any kind. We want to keep it a fun and safe place to hang out, but we cannot\npromise that it will always be available or that it will be fit for any\nparticular purpose.

\n\n

Limitation of Liability

\n

To the extent permitted by law, we don't take responsibility for any negative\nconsequences, direct or indirect, related to using our Service, or inability to\naccess the Service. Such negative consequences includes, but is not limited to,\nloss of data, loss of profit, loss of goodwill, or other intangible losses. And\nyou agree to not hold us, or anyone associated with making this Service\navailable, liable for any such negative consequences.

\n\n

Mucklet AB or its proprietors may not be held accountable for content\nprovided by the users of the Service. We will take reasonable actions to\nmoderate this content in accordance with these Terms.

\n\n

Governing Law

\n

This Agreement shall be governed by and construed in accordance with the laws\nof Sweden. The Swedish court of general jurisdiction, and, in first instance\nthe Örebro District Court (Sw. Örebro tingsrätt), have exclusive jurisdiction to\nsettle any dispute arising out of or in connection with this Agreement.

" }