mirror of
https://github.com/maunium/stickerpicker.git
synced 2025-08-28 19:01:44 +02:00
Compare commits
36 Commits
server
...
398fc952b6
Author | SHA1 | Date | |
---|---|---|---|
|
398fc952b6 | ||
|
91eca68c7b | ||
|
182cafe13a | ||
|
c4588f19a7 | ||
|
2e1b333cbb | ||
|
64b7b1507f | ||
|
0897ce6c20 | ||
|
380a070e71 | ||
|
59bf7ef252 | ||
|
8c4291d266 | ||
|
cfd0b8e292 | ||
|
f59406a47a | ||
|
99ced8878a | ||
|
046779d102 | ||
|
ef844a0ff8 | ||
|
502d91fc75 | ||
|
591137ccb3 | ||
|
f29c165357 | ||
|
7939793351 | ||
|
e0d895f22a | ||
|
5d3c7d1e2f | ||
|
ec8eeeeaf5 | ||
|
57fde6fcad | ||
|
9443e79e97 | ||
|
85813b22e5 | ||
|
569d9815c6 | ||
|
0f7b678f57 | ||
|
b884a9c387 | ||
|
ba0096275c | ||
|
3916ade97b | ||
|
dab2420cd4 | ||
|
601d2acc32 | ||
|
21d4f5cce6 | ||
|
9350d5f27b | ||
|
add27513fe | ||
|
66d5b90ea1 |
@@ -1,19 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
|
||||||
|
|
||||||
[*.py]
|
|
||||||
max_line_length = 99
|
|
||||||
|
|
||||||
[*.js]
|
|
||||||
max_line_length = 100
|
|
||||||
indent_style = tab
|
|
||||||
|
|
||||||
[*.{json,sass}]
|
|
||||||
indent_size = 2
|
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,5 +12,3 @@ web/lib/import-map.json
|
|||||||
*.session
|
*.session
|
||||||
/*.json
|
/*.json
|
||||||
*.bak
|
*.bak
|
||||||
*.log
|
|
||||||
config.yaml
|
|
||||||
|
@@ -1,4 +0,0 @@
|
|||||||
include README.md
|
|
||||||
include LICENSE
|
|
||||||
include requirements.txt
|
|
||||||
include optional-requirements.txt
|
|
@@ -11,8 +11,14 @@ For setup and usage instructions, please visit the [wiki](https://github.com/mau
|
|||||||
* [Enabling the widget](https://github.com/maunium/stickerpicker/wiki/Enabling-the-widget)
|
* [Enabling the widget](https://github.com/maunium/stickerpicker/wiki/Enabling-the-widget)
|
||||||
* [Hosting on GitHub pages](https://github.com/maunium/stickerpicker/wiki/Hosting-on-GitHub-pages)
|
* [Hosting on GitHub pages](https://github.com/maunium/stickerpicker/wiki/Hosting-on-GitHub-pages)
|
||||||
|
|
||||||
|
If you prefer video tutorials, [Brodie Robertson](https://www.youtube.com/c/BrodieRobertson) has made a great video on setting up the picker and creating some packs: https://youtu.be/Yz3H6KJTEI0.
|
||||||
|
|
||||||
## Comparison with other sticker pickers
|
## Comparison with other sticker pickers
|
||||||
|
|
||||||
|
* Scalar is the default integration manager in Element, which can't be self-hosted and only supports predefined sticker packs.
|
||||||
|
* [Dimension](https://github.com/turt2live/matrix-dimension) is an alternate integration manager. It can be self-hosted, but it's more difficult than Maunium sticker picker.
|
||||||
|
* Maunium sticker picker is just a sticker picker rather than a full integration manager. It's much simpler than integration managers, but currently has to be set up manually per-user.
|
||||||
|
|
||||||
| Feature | Scalar | Dimension | Maunium sticker picker |
|
| Feature | Scalar | Dimension | Maunium sticker picker |
|
||||||
|---------------------------------|--------|-----------|------------------------|
|
|---------------------------------|--------|-----------|------------------------|
|
||||||
| Free software | ❌ | ✔️ | ✔️ |
|
| Free software | ❌ | ✔️ | ✔️ |
|
||||||
|
@@ -1,11 +0,0 @@
|
|||||||
# Format: #/name defines a new extras_require group called name
|
|
||||||
# Uncommented lines after the group definition insert things into that group.
|
|
||||||
|
|
||||||
#/server
|
|
||||||
mautrix==0.8.0rc4
|
|
||||||
asyncpg>=0.20,<0.22
|
|
||||||
attrs
|
|
||||||
setuptools
|
|
||||||
aiodns
|
|
||||||
ruamel.yaml
|
|
||||||
jsonschema
|
|
@@ -1,6 +1,6 @@
|
|||||||
aiohttp>=3,<4
|
aiohttp
|
||||||
yarl>=1,<2
|
yarl
|
||||||
pillow
|
pillow
|
||||||
telethon>=1.16
|
telethon
|
||||||
cryptg
|
cryptg
|
||||||
python-magic
|
python-magic
|
||||||
|
23
setup.py
23
setup.py
@@ -5,19 +5,6 @@ from sticker.get_version import git_tag, git_revision, version, linkified_versio
|
|||||||
with open("requirements.txt") as reqs:
|
with open("requirements.txt") as reqs:
|
||||||
install_requires = reqs.read().splitlines()
|
install_requires = reqs.read().splitlines()
|
||||||
|
|
||||||
with open("optional-requirements.txt") as reqs:
|
|
||||||
extras_require = {}
|
|
||||||
current = []
|
|
||||||
for line in reqs.read().splitlines():
|
|
||||||
if line.startswith("#/"):
|
|
||||||
extras_require[line[2:]] = current = []
|
|
||||||
elif not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
current.append(line)
|
|
||||||
|
|
||||||
extras_require["all"] = list({dep for deps in extras_require.values() for dep in deps})
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
long_desc = open("README.md").read()
|
long_desc = open("README.md").read()
|
||||||
except IOError:
|
except IOError:
|
||||||
@@ -47,7 +34,6 @@ setuptools.setup(
|
|||||||
packages=setuptools.find_packages(),
|
packages=setuptools.find_packages(),
|
||||||
|
|
||||||
install_requires=install_requires,
|
install_requires=install_requires,
|
||||||
extras_require=extras_require,
|
|
||||||
python_requires="~=3.6",
|
python_requires="~=3.6",
|
||||||
|
|
||||||
classifiers=[
|
classifiers=[
|
||||||
@@ -62,14 +48,7 @@ setuptools.setup(
|
|||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
],
|
],
|
||||||
entry_points={"console_scripts": [
|
entry_points={"console_scripts": [
|
||||||
"sticker-import=sticker.import:cmd",
|
"sticker-import=sticker.stickerimport:cmd",
|
||||||
"sticker-pack=sticker.pack:cmd",
|
"sticker-pack=sticker.pack:cmd",
|
||||||
"sticker-server=sticker.server:cmd",
|
|
||||||
]},
|
]},
|
||||||
package_data={"sticker.server": [
|
|
||||||
"example-config.yaml",
|
|
||||||
|
|
||||||
"frontend/index.html", "frontend/setup/index.html",
|
|
||||||
"frontend/src/*", "frontend/lib/*/*.js", "frontend/res/*", "frontend/style/*.css",
|
|
||||||
], "sticker.server.api": ["pack.schema.json"]}
|
|
||||||
)
|
)
|
||||||
|
@@ -42,6 +42,7 @@ if TYPE_CHECKING:
|
|||||||
url: str
|
url: str
|
||||||
info: MediaInfo
|
info: MediaInfo
|
||||||
id: str
|
id: str
|
||||||
|
msgtype: str
|
||||||
else:
|
else:
|
||||||
MediaInfo = None
|
MediaInfo = None
|
||||||
StickerInfo = None
|
StickerInfo = None
|
||||||
@@ -54,17 +55,22 @@ async def load_config(path: str) -> None:
|
|||||||
config = json.load(config_file)
|
config = json.load(config_file)
|
||||||
homeserver_url = config["homeserver"]
|
homeserver_url = config["homeserver"]
|
||||||
access_token = config["access_token"]
|
access_token = config["access_token"]
|
||||||
|
giphy_api_key = config["giphy_api_key"]
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print("Matrix config file not found. Please enter your homeserver and access token.")
|
print("Matrix config file not found. Please enter your homeserver and access token. Enter the Giphy API token if required, leave blank to disable the gif picker.")
|
||||||
homeserver_url = input("Homeserver URL: ")
|
homeserver_url = input("Homeserver URL: ")
|
||||||
access_token = input("Access token: ")
|
access_token = input("Access token: ")
|
||||||
|
giphy_api_key = input("Giphy API key: ")
|
||||||
whoami_url = URL(homeserver_url) / "_matrix" / "client" / "r0" / "account" / "whoami"
|
whoami_url = URL(homeserver_url) / "_matrix" / "client" / "r0" / "account" / "whoami"
|
||||||
|
if whoami_url.scheme not in ("https", "http"):
|
||||||
|
whoami_url = whoami_url.with_scheme("https")
|
||||||
user_id = await whoami(whoami_url, access_token)
|
user_id = await whoami(whoami_url, access_token)
|
||||||
with open(path, "w") as config_file:
|
with open(path, "w") as config_file:
|
||||||
json.dump({
|
json.dump({
|
||||||
"homeserver": homeserver_url,
|
"homeserver": homeserver_url,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"access_token": access_token
|
"access_token": access_token,
|
||||||
|
"giphy_api_key": giphy_api_key
|
||||||
}, config_file)
|
}, config_file)
|
||||||
print(f"Wrote config to {path}")
|
print(f"Wrote config to {path}")
|
||||||
|
|
||||||
|
@@ -13,6 +13,7 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
from functools import partial
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import os.path
|
import os.path
|
||||||
import json
|
import json
|
||||||
@@ -21,6 +22,7 @@ from PIL import Image
|
|||||||
|
|
||||||
from . import matrix
|
from . import matrix
|
||||||
|
|
||||||
|
open_utf8 = partial(open, encoding='UTF-8')
|
||||||
|
|
||||||
def convert_image(data: bytes) -> (bytes, int, int):
|
def convert_image(data: bytes) -> (bytes, int, int):
|
||||||
image: Image.Image = Image.open(BytesIO(data)).convert("RGBA")
|
image: Image.Image = Image.open(BytesIO(data)).convert("RGBA")
|
||||||
@@ -41,7 +43,7 @@ def convert_image(data: bytes) -> (bytes, int, int):
|
|||||||
def add_to_index(name: str, output_dir: str) -> None:
|
def add_to_index(name: str, output_dir: str) -> None:
|
||||||
index_path = os.path.join(output_dir, "index.json")
|
index_path = os.path.join(output_dir, "index.json")
|
||||||
try:
|
try:
|
||||||
with open(index_path) as index_file:
|
with open_utf8(index_path) as index_file:
|
||||||
index_data = json.load(index_file)
|
index_data = json.load(index_file)
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
index_data = {"packs": []}
|
index_data = {"packs": []}
|
||||||
@@ -49,7 +51,7 @@ def add_to_index(name: str, output_dir: str) -> None:
|
|||||||
index_data["homeserver_url"] = matrix.homeserver_url
|
index_data["homeserver_url"] = matrix.homeserver_url
|
||||||
if name not in index_data["packs"]:
|
if name not in index_data["packs"]:
|
||||||
index_data["packs"].append(name)
|
index_data["packs"].append(name)
|
||||||
with open(index_path, "w") as index_file:
|
with open_utf8(index_path, "w") as index_file:
|
||||||
json.dump(index_data, index_file, indent=" ")
|
json.dump(index_data, index_file, indent=" ")
|
||||||
print(f"Added {name} to {index_path}")
|
print(f"Added {name} to {index_path}")
|
||||||
|
|
||||||
@@ -74,4 +76,5 @@ def make_sticker(mxc: str, width: int, height: int, size: int,
|
|||||||
"mimetype": "image/png",
|
"mimetype": "image/png",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"msgtype": "m.sticker",
|
||||||
}
|
}
|
||||||
|
@@ -93,7 +93,7 @@ async def main(args: argparse.Namespace) -> None:
|
|||||||
dirname = os.path.basename(os.path.abspath(args.path))
|
dirname = os.path.basename(os.path.abspath(args.path))
|
||||||
meta_path = os.path.join(args.path, "pack.json")
|
meta_path = os.path.join(args.path, "pack.json")
|
||||||
try:
|
try:
|
||||||
with open(meta_path) as pack_file:
|
with util.open_utf8(meta_path) as pack_file:
|
||||||
pack = json.load(pack_file)
|
pack = json.load(pack_file)
|
||||||
print(f"Loaded existing pack meta from {meta_path}")
|
print(f"Loaded existing pack meta from {meta_path}")
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -112,14 +112,14 @@ async def main(args: argparse.Namespace) -> None:
|
|||||||
if sticker:
|
if sticker:
|
||||||
pack["stickers"].append(sticker)
|
pack["stickers"].append(sticker)
|
||||||
|
|
||||||
with open(meta_path, "w") as pack_file:
|
with util.open_utf8(meta_path, "w") as pack_file:
|
||||||
json.dump(pack, pack_file)
|
json.dump(pack, pack_file)
|
||||||
print(f"Wrote pack to {meta_path}")
|
print(f"Wrote pack to {meta_path}")
|
||||||
|
|
||||||
if args.add_to_index:
|
if args.add_to_index:
|
||||||
picker_file_name = f"{pack['id']}.json"
|
picker_file_name = f"{pack['id']}.json"
|
||||||
picker_pack_path = os.path.join(args.add_to_index, picker_file_name)
|
picker_pack_path = os.path.join(args.add_to_index, picker_file_name)
|
||||||
with open(picker_pack_path, "w") as pack_file:
|
with util.open_utf8(picker_pack_path, "w") as pack_file:
|
||||||
json.dump(pack, pack_file)
|
json.dump(pack, pack_file)
|
||||||
print(f"Copied pack to {picker_pack_path}")
|
print(f"Copied pack to {picker_pack_path}")
|
||||||
util.add_to_index(picker_file_name, args.add_to_index)
|
util.add_to_index(picker_file_name, args.add_to_index)
|
||||||
|
@@ -19,12 +19,12 @@ import json
|
|||||||
index_path = "../web/packs/index.json"
|
index_path = "../web/packs/index.json"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(index_path) as index_file:
|
with util.open_utf8(index_path) as index_file:
|
||||||
index_data = json.load(index_file)
|
index_data = json.load(index_file)
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
index_data = {"packs": []}
|
index_data = {"packs": []}
|
||||||
|
|
||||||
with open(sys.argv[-1]) as file:
|
with util.open_utf8(sys.argv[-1]) as file:
|
||||||
data = json.load(file)
|
data = json.load(file)
|
||||||
|
|
||||||
for pack in data["assets"]:
|
for pack in data["assets"]:
|
||||||
@@ -45,12 +45,12 @@ for pack in data["assets"]:
|
|||||||
}
|
}
|
||||||
filename = f"scalar-{pack['name'].replace(' ', '_')}.json"
|
filename = f"scalar-{pack['name'].replace(' ', '_')}.json"
|
||||||
pack_path = f"web/packs/{filename}"
|
pack_path = f"web/packs/{filename}"
|
||||||
with open(pack_path, "w") as pack_file:
|
with util.open_utf8(pack_path, "w") as pack_file:
|
||||||
json.dump(pack_data, pack_file)
|
json.dump(pack_data, pack_file)
|
||||||
print(f"Wrote {title} to {pack_path}")
|
print(f"Wrote {title} to {pack_path}")
|
||||||
if filename not in index_data["packs"]:
|
if filename not in index_data["packs"]:
|
||||||
index_data["packs"].append(filename)
|
index_data["packs"].append(filename)
|
||||||
|
|
||||||
with open(index_path, "w") as index_file:
|
with util.open_utf8(index_path, "w") as index_file:
|
||||||
json.dump(index_data, index_file, indent=" ")
|
json.dump(index_data, index_file, indent=" ")
|
||||||
print(f"Updated {index_path}")
|
print(f"Updated {index_path}")
|
||||||
|
@@ -1,53 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.program import Program
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from .config import Config
|
|
||||||
from .server import Server
|
|
||||||
from .database import upgrade_table, Base
|
|
||||||
from ..version import version
|
|
||||||
|
|
||||||
|
|
||||||
class StickerServer(Program):
|
|
||||||
module = "sticker.server"
|
|
||||||
name = "maunium-stickerpicker server"
|
|
||||||
version = version
|
|
||||||
command = "python -m sticker.server"
|
|
||||||
description = "Server for maunium-stickerpicker"
|
|
||||||
|
|
||||||
config_class = Config
|
|
||||||
|
|
||||||
config: Config
|
|
||||||
server: Server
|
|
||||||
database: Database
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
self.database = Database(url=self.config["database"], upgrade_table=upgrade_table)
|
|
||||||
Base.db = self.database
|
|
||||||
self.server = Server(self.config)
|
|
||||||
|
|
||||||
await self.database.start()
|
|
||||||
await self.server.start()
|
|
||||||
|
|
||||||
await super().start()
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
await super().stop()
|
|
||||||
await self.server.stop()
|
|
||||||
|
|
||||||
|
|
||||||
StickerServer().run()
|
|
@@ -1,38 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
from ..config import Config
|
|
||||||
from .auth import (routes as auth_routes, init as auth_init,
|
|
||||||
token_middleware, widget_secret_middleware)
|
|
||||||
from .fed_connector import init as init_fed_connector
|
|
||||||
from .packs import routes as packs_routes, init as packs_init
|
|
||||||
from .setup import routes as setup_routes
|
|
||||||
|
|
||||||
integrations_app = web.Application()
|
|
||||||
integrations_app.add_routes(auth_routes)
|
|
||||||
|
|
||||||
packs_app = web.Application(middlewares=[widget_secret_middleware])
|
|
||||||
packs_app.add_routes(packs_routes)
|
|
||||||
|
|
||||||
setup_app = web.Application(middlewares=[token_middleware])
|
|
||||||
setup_app.add_routes(setup_routes)
|
|
||||||
|
|
||||||
|
|
||||||
def init(config: Config) -> None:
|
|
||||||
init_fed_connector()
|
|
||||||
auth_init(config)
|
|
||||||
packs_init(config)
|
|
@@ -1,216 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Tuple, Callable, Awaitable, Optional, TYPE_CHECKING
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
|
|
||||||
from mautrix.client import Client
|
|
||||||
from mautrix.types import UserID
|
|
||||||
from mautrix.util.logging import TraceLogger
|
|
||||||
from aiohttp import web, hdrs, ClientError, ClientSession
|
|
||||||
from yarl import URL
|
|
||||||
|
|
||||||
from ..database import AccessToken, User
|
|
||||||
from ..config import Config
|
|
||||||
from .errors import Error
|
|
||||||
from . import fed_connector
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
|
|
||||||
class OpenIDPayload(TypedDict):
|
|
||||||
access_token: str
|
|
||||||
token_type: str
|
|
||||||
matrix_server_name: str
|
|
||||||
expires_in: int
|
|
||||||
|
|
||||||
|
|
||||||
class OpenIDResponse(TypedDict):
|
|
||||||
sub: str
|
|
||||||
|
|
||||||
Handler = Callable[[web.Request], Awaitable[web.Response]]
|
|
||||||
|
|
||||||
log: TraceLogger = logging.getLogger("mau.api.auth")
|
|
||||||
routes = web.RouteTableDef()
|
|
||||||
config: Config
|
|
||||||
|
|
||||||
|
|
||||||
def get_ip(request: web.Request) -> str:
|
|
||||||
if config["server.trust_forward_headers"]:
|
|
||||||
try:
|
|
||||||
return request.headers["X-Forwarded-For"]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
return request.remote
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth_header(request: web.Request) -> str:
|
|
||||||
try:
|
|
||||||
auth = request.headers["Authorization"]
|
|
||||||
if not auth.startswith("Bearer "):
|
|
||||||
raise Error.invalid_auth_header
|
|
||||||
return auth[len("Bearer "):]
|
|
||||||
except KeyError:
|
|
||||||
raise Error.missing_auth_header
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user(request: web.Request) -> Tuple[User, AccessToken]:
|
|
||||||
auth = get_auth_header(request)
|
|
||||||
try:
|
|
||||||
token_id, token_val = auth.split(":")
|
|
||||||
token_id = int(token_id)
|
|
||||||
except ValueError:
|
|
||||||
raise Error.invalid_auth_token
|
|
||||||
token = await AccessToken.get(token_id)
|
|
||||||
if not token or not token.check(token_val):
|
|
||||||
raise Error.invalid_auth_token
|
|
||||||
elif token.expired:
|
|
||||||
raise Error.auth_token_expired
|
|
||||||
await token.update_ip(get_ip(request))
|
|
||||||
return await User.get(token.user_id), token
|
|
||||||
|
|
||||||
|
|
||||||
@web.middleware
|
|
||||||
async def token_middleware(request: web.Request, handler: Handler) -> web.Response:
|
|
||||||
if request.method == hdrs.METH_OPTIONS:
|
|
||||||
return await handler(request)
|
|
||||||
user, token = await get_user(request)
|
|
||||||
request["user"] = user
|
|
||||||
request["token"] = token
|
|
||||||
return await handler(request)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_widget_user(request: web.Request) -> User:
|
|
||||||
try:
|
|
||||||
user_id = UserID(request.headers["X-Matrix-User-ID"])
|
|
||||||
except KeyError:
|
|
||||||
raise Error.missing_user_id_header
|
|
||||||
user = await User.get(user_id)
|
|
||||||
if user is None:
|
|
||||||
raise Error.user_not_found
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@web.middleware
|
|
||||||
async def widget_secret_middleware(request: web.Request, handler: Handler) -> web.Response:
|
|
||||||
if request.method == hdrs.METH_OPTIONS:
|
|
||||||
return await handler(request)
|
|
||||||
user = await get_widget_user(request)
|
|
||||||
request["user"] = user
|
|
||||||
return await handler(request)
|
|
||||||
|
|
||||||
|
|
||||||
account_cors_headers = {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "OPTIONS, GET, POST",
|
|
||||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/account")
|
|
||||||
async def get_auth(request: web.Request) -> web.Response:
|
|
||||||
user, token = await get_user(request)
|
|
||||||
return web.json_response({"user_id": token.user_id}, headers=account_cors_headers)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_openid_token(homeserver: str, token: str) -> Optional[UserID]:
|
|
||||||
server_info = await fed_connector.resolve_server_name(homeserver)
|
|
||||||
headers = {"Host": server_info.host_header}
|
|
||||||
userinfo_url = URL.build(scheme="https", host=server_info.host, port=server_info.port,
|
|
||||||
path="/_matrix/federation/v1/openid/userinfo",
|
|
||||||
query={"access_token": token})
|
|
||||||
try:
|
|
||||||
async with fed_connector.http.get(userinfo_url, headers=headers) as resp:
|
|
||||||
data: 'OpenIDResponse' = await resp.json()
|
|
||||||
return UserID(data["sub"])
|
|
||||||
except (ClientError, json.JSONDecodeError, KeyError, ValueError) as e:
|
|
||||||
log.debug(f"Failed to check OpenID token from {homeserver}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@routes.route(hdrs.METH_OPTIONS, "/account/register")
|
|
||||||
@routes.route(hdrs.METH_OPTIONS, "/account/logout")
|
|
||||||
@routes.route(hdrs.METH_OPTIONS, "/account")
|
|
||||||
async def cors_token(_: web.Request) -> web.Response:
|
|
||||||
return web.Response(status=200, headers=account_cors_headers)
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_client_well_known(server_name: str) -> str:
|
|
||||||
url = URL.build(scheme="https", host=server_name, port=443, path="/.well-known/matrix/client")
|
|
||||||
async with ClientSession() as sess, sess.get(url) as resp:
|
|
||||||
data = await resp.json()
|
|
||||||
return data["m.homeserver"]["base_url"]
|
|
||||||
|
|
||||||
|
|
||||||
@routes.post("/account/register")
|
|
||||||
async def exchange_token(request: web.Request) -> web.Response:
|
|
||||||
try:
|
|
||||||
data: 'OpenIDPayload' = await request.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise Error.request_not_json
|
|
||||||
try:
|
|
||||||
matrix_server_name = data["matrix_server_name"]
|
|
||||||
access_token = data["access_token"]
|
|
||||||
except KeyError:
|
|
||||||
raise Error.invalid_openid_payload
|
|
||||||
log.trace(f"Validating OpenID token from {matrix_server_name}")
|
|
||||||
user_id = await check_openid_token(matrix_server_name, access_token)
|
|
||||||
if user_id is None:
|
|
||||||
raise Error.invalid_openid_token
|
|
||||||
_, homeserver = Client.parse_user_id(user_id)
|
|
||||||
if homeserver != data["matrix_server_name"]:
|
|
||||||
raise Error.homeserver_mismatch
|
|
||||||
|
|
||||||
permissions = config.get_permissions(user_id)
|
|
||||||
if not permissions.access:
|
|
||||||
raise Error.no_access
|
|
||||||
|
|
||||||
try:
|
|
||||||
log.trace(f"Trying to resolve {matrix_server_name}'s client .well-known")
|
|
||||||
homeserver_url = await resolve_client_well_known(matrix_server_name)
|
|
||||||
log.trace(f"Got {homeserver_url} from {matrix_server_name}'s client .well-known")
|
|
||||||
except (ClientError, json.JSONDecodeError, KeyError, ValueError, TypeError):
|
|
||||||
log.trace(f"Failed to resolve {matrix_server_name}'s client .well-known", exc_info=True)
|
|
||||||
raise Error.client_well_known_error
|
|
||||||
|
|
||||||
user = await User.get(user_id)
|
|
||||||
if user is None:
|
|
||||||
log.debug(f"Creating user {user_id} with homeserver client URL {homeserver_url}")
|
|
||||||
user = User.new(user_id, homeserver_url=homeserver_url)
|
|
||||||
await user.insert()
|
|
||||||
elif user.homeserver_url != homeserver_url:
|
|
||||||
log.debug(f"Updating {user_id}'s homeserver client URL from {user.homeserver_url} "
|
|
||||||
f"to {homeserver_url}")
|
|
||||||
await user.set_homeserver_url(homeserver_url)
|
|
||||||
token = await user.new_access_token(get_ip(request))
|
|
||||||
return web.json_response({
|
|
||||||
"user_id": user_id,
|
|
||||||
"token": token,
|
|
||||||
"permissions": permissions._asdict(),
|
|
||||||
}, headers=account_cors_headers)
|
|
||||||
|
|
||||||
|
|
||||||
@routes.post("/account/logout")
|
|
||||||
async def logout(request: web.Request) -> web.Response:
|
|
||||||
user, token = await get_user(request)
|
|
||||||
await token.delete()
|
|
||||||
return web.json_response({}, status=204, headers=account_cors_headers)
|
|
||||||
|
|
||||||
|
|
||||||
def init(cfg: Config) -> None:
|
|
||||||
global config
|
|
||||||
config = cfg
|
|
@@ -1,119 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Dict, Optional
|
|
||||||
from collections import deque
|
|
||||||
import json
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
|
|
||||||
class _ErrorMeta:
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _make_error(errcode: str, error: str) -> Dict[str, str]:
|
|
||||||
return {
|
|
||||||
"body": json.dumps({
|
|
||||||
"error": error,
|
|
||||||
"errcode": errcode,
|
|
||||||
}).encode("utf-8"),
|
|
||||||
"content_type": "application/json",
|
|
||||||
"headers": {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "OPTIONS, GET, POST, PUT, DELETE, HEAD",
|
|
||||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def request_not_json(self) -> web.HTTPException:
|
|
||||||
return web.HTTPBadRequest(**self._make_error("M_NOT_JSON",
|
|
||||||
"Request body is not valid JSON"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def missing_auth_header(self) -> web.HTTPException:
|
|
||||||
return web.HTTPForbidden(**self._make_error("M_MISSING_TOKEN",
|
|
||||||
"Missing authorization header"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def missing_user_id_header(self) -> web.HTTPException:
|
|
||||||
return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_MISSING_USER_ID",
|
|
||||||
"Missing user ID header"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_not_found(self) -> web.HTTPException:
|
|
||||||
return web.HTTPNotFound(**self._make_error("NET.MAUNIUM_USER_NOT_FOUND",
|
|
||||||
"User not found"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def invalid_auth_header(self) -> web.HTTPException:
|
|
||||||
return web.HTTPForbidden(**self._make_error("M_UNKNOWN_TOKEN",
|
|
||||||
"Invalid authorization header"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def invalid_auth_token(self) -> web.HTTPException:
|
|
||||||
return web.HTTPForbidden(**self._make_error("M_UNKNOWN_TOKEN",
|
|
||||||
"Invalid authorization token"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def auth_token_expired(self) -> web.HTTPException:
|
|
||||||
return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_TOKEN_EXPIRED",
|
|
||||||
"Authorization token has expired"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def invalid_openid_payload(self) -> web.HTTPException:
|
|
||||||
return web.HTTPBadRequest(**self._make_error("M_BAD_JSON", "Missing one or more "
|
|
||||||
"fields in OpenID payload"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def invalid_openid_token(self) -> web.HTTPException:
|
|
||||||
return web.HTTPForbidden(**self._make_error("M_UNKNOWN_TOKEN",
|
|
||||||
"Invalid OpenID token"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def no_access(self) -> web.HTTPException:
|
|
||||||
return web.HTTPUnauthorized(**self._make_error(
|
|
||||||
"M_UNAUTHORIZED",
|
|
||||||
"You are not authorized to access this maunium-stickerpicker instance"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def homeserver_mismatch(self) -> web.HTTPException:
|
|
||||||
return web.HTTPUnauthorized(**self._make_error(
|
|
||||||
"M_UNAUTHORIZED", "Request matrix_server_name and OpenID sub homeserver don't match"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pack_not_found(self) -> web.HTTPException:
|
|
||||||
return web.HTTPNotFound(**self._make_error("NET.MAUNIUM_PACK_NOT_FOUND",
|
|
||||||
"Sticker pack not found"))
|
|
||||||
|
|
||||||
def schema_error(self, message: str, path: Optional[deque] = None) -> web.HTTPException:
|
|
||||||
if path:
|
|
||||||
path_str = "in " + " → ".join(str(part) for part in path)
|
|
||||||
else:
|
|
||||||
path_str = "at top level"
|
|
||||||
return web.HTTPBadRequest(**self._make_error(
|
|
||||||
"M_BAD_REQUEST", f"Schema validation error {path_str}: {message}"))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def client_well_known_error(self) -> web.HTTPException:
|
|
||||||
return web.HTTPForbidden(**self._make_error("NET.MAUNIUM_CLIENT_WELL_KNOWN_ERROR",
|
|
||||||
"Failed to resolve homeserver URL "
|
|
||||||
"from client .well-known"))
|
|
||||||
|
|
||||||
|
|
||||||
class Error(metaclass=_ErrorMeta):
|
|
||||||
pass
|
|
@@ -1,110 +0,0 @@
|
|||||||
from typing import Tuple, Any, NamedTuple, Dict, Optional
|
|
||||||
from time import time
|
|
||||||
import ipaddress
|
|
||||||
import logging
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
|
|
||||||
from mautrix.util.logging import TraceLogger
|
|
||||||
from aiohttp import ClientRequest, TCPConnector, ClientSession, ClientTimeout, ClientError
|
|
||||||
from aiohttp.client_proto import ResponseHandler
|
|
||||||
from yarl import URL
|
|
||||||
import aiodns
|
|
||||||
|
|
||||||
log: TraceLogger = logging.getLogger("mau.federation")
|
|
||||||
|
|
||||||
|
|
||||||
class ResolvedServerName(NamedTuple):
|
|
||||||
host_header: str
|
|
||||||
host: str
|
|
||||||
port: int
|
|
||||||
expire: int
|
|
||||||
|
|
||||||
|
|
||||||
class ServerNameSplit(NamedTuple):
|
|
||||||
host: str
|
|
||||||
port: Optional[int]
|
|
||||||
is_ip: bool
|
|
||||||
|
|
||||||
|
|
||||||
dns_resolver: aiodns.DNSResolver
|
|
||||||
http: ClientSession
|
|
||||||
server_name_cache: Dict[str, ResolvedServerName] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixFederationTCPConnector(TCPConnector):
|
|
||||||
"""An extension to aiohttp's TCPConnector that correctly sets the TLS SNI for Matrix federation
|
|
||||||
requests, where the TCP host may not match the SNI/Host header."""
|
|
||||||
|
|
||||||
async def _wrap_create_connection(self, *args: Any, server_hostname: str, req: ClientRequest,
|
|
||||||
**kwargs: Any) -> Tuple[asyncio.Transport, ResponseHandler]:
|
|
||||||
split = parse_server_name(req.headers["Host"])
|
|
||||||
return await super()._wrap_create_connection(*args, server_hostname=split.host,
|
|
||||||
req=req, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_server_name(name: str) -> ServerNameSplit:
|
|
||||||
port_split = name.rsplit(":", 1)
|
|
||||||
if len(port_split) == 2 and port_split[1].isdecimal():
|
|
||||||
name, port = port_split
|
|
||||||
else:
|
|
||||||
port = None
|
|
||||||
try:
|
|
||||||
ipaddress.ip_address(name)
|
|
||||||
is_ip = True
|
|
||||||
except ValueError:
|
|
||||||
is_ip = False
|
|
||||||
res = ServerNameSplit(host=name, port=port, is_ip=is_ip)
|
|
||||||
log.trace(f"Parsed server name {name} into {res}")
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_server_name(server_name: str) -> ResolvedServerName:
|
|
||||||
try:
|
|
||||||
cached = server_name_cache[server_name]
|
|
||||||
if cached.expire > int(time()):
|
|
||||||
log.trace(f"Using cached server name resolution for {server_name}: {cached}")
|
|
||||||
return cached
|
|
||||||
except KeyError:
|
|
||||||
log.trace(f"No cached server name resolution for {server_name}")
|
|
||||||
|
|
||||||
host_header = server_name
|
|
||||||
hostname, port, is_ip = parse_server_name(host_header)
|
|
||||||
ttl = 86400
|
|
||||||
if port is None and not is_ip:
|
|
||||||
well_known_url = URL.build(scheme="https", host=host_header, port=443,
|
|
||||||
path="/.well-known/matrix/server")
|
|
||||||
try:
|
|
||||||
log.trace(f"Requesting {well_known_url} to resolve {server_name}'s .well-known")
|
|
||||||
async with http.get(well_known_url) as resp:
|
|
||||||
if resp.status == 200:
|
|
||||||
well_known_data = await resp.json()
|
|
||||||
host_header = well_known_data["m.server"]
|
|
||||||
log.debug(f"Got {host_header} from {server_name}'s .well-known")
|
|
||||||
hostname, port, is_ip = parse_server_name(host_header)
|
|
||||||
else:
|
|
||||||
log.trace(f"Got non-200 status {resp.status} from {server_name}'s .well-known")
|
|
||||||
except (ClientError, json.JSONDecodeError, KeyError, ValueError) as e:
|
|
||||||
log.debug(f"Failed to fetch .well-known for {server_name}: {e}")
|
|
||||||
if port is None and not is_ip:
|
|
||||||
log.trace(f"Querying SRV at _matrix._tcp.{host_header}")
|
|
||||||
res = await dns_resolver.query(f"_matrix._tcp.{host_header}", "SRV")
|
|
||||||
if res:
|
|
||||||
hostname = res[0].host
|
|
||||||
port = res[0].port
|
|
||||||
ttl = max(res[0].ttl, 300)
|
|
||||||
log.debug(f"Got {hostname}:{port} from {host_header}'s Matrix SRV record")
|
|
||||||
else:
|
|
||||||
log.trace(f"No SRV records found at _matrix._tcp.{host_header}")
|
|
||||||
result = ResolvedServerName(host_header=host_header, host=hostname, port=port or 8448,
|
|
||||||
expire=int(time()) + ttl)
|
|
||||||
server_name_cache[server_name] = result
|
|
||||||
log.debug(f"Resolved server name {server_name} -> {result}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def init():
|
|
||||||
global http, dns_resolver
|
|
||||||
dns_resolver = aiodns.DNSResolver(loop=asyncio.get_running_loop())
|
|
||||||
http = ClientSession(timeout=ClientTimeout(total=10),
|
|
||||||
connector=MatrixFederationTCPConnector())
|
|
@@ -1,126 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
|
||||||
"description": "A sticker pack compatible with maunium-stickerpicker",
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "An unique identifier for the sticker pack",
|
|
||||||
"readOnly": true
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The title of the sticker pack"
|
|
||||||
},
|
|
||||||
"stickers": {
|
|
||||||
"type": "array",
|
|
||||||
"description": "The stickers in the pack",
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "A single sticker",
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "An unique identifier for the sticker"
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The Matrix content URI to the sticker",
|
|
||||||
"pattern": "mxc://.+?/.+"
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The description text for the sticker"
|
|
||||||
},
|
|
||||||
"info": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Matrix media info",
|
|
||||||
"properties": {
|
|
||||||
"w": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "The intended display width of the sticker"
|
|
||||||
},
|
|
||||||
"h": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "The intended display height of the sticker"
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "The size of the sticker image in bytes"
|
|
||||||
},
|
|
||||||
"mimetype": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The mime type of the sticker image"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": true,
|
|
||||||
"required": [
|
|
||||||
"w",
|
|
||||||
"h",
|
|
||||||
"size",
|
|
||||||
"mimetype"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"net.maunium.telegram.sticker": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Telegram metadata about the sticker",
|
|
||||||
"properties": {
|
|
||||||
"pack": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Information about the pack the sticker is in",
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The ID of the sticker pack"
|
|
||||||
},
|
|
||||||
"short_name": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The short name of the Telegram sticker pack from t.me/addstickers/<shortname>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The ID of the sticker document"
|
|
||||||
},
|
|
||||||
"emoticons": {
|
|
||||||
"type": "array",
|
|
||||||
"description": "Emojis that are associated with the sticker",
|
|
||||||
"items": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "A single unicode emoji"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"url",
|
|
||||||
"body",
|
|
||||||
"info"
|
|
||||||
],
|
|
||||||
"additionalProperties": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"net.maunium.telegram.pack": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Telegram metadata about the pack",
|
|
||||||
"properties": {
|
|
||||||
"short_name": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The short name of the Telegram sticker pack from t.me/addstickers/<shortname>"
|
|
||||||
},
|
|
||||||
"hash": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The Telegram-specified hash of the stickerpack that can be used to quickly check if it has changed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": true,
|
|
||||||
"required": [
|
|
||||||
"title",
|
|
||||||
"stickers"
|
|
||||||
]
|
|
||||||
}
|
|
@@ -1,52 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
from ..database import User
|
|
||||||
from ..config import Config
|
|
||||||
from .errors import Error
|
|
||||||
|
|
||||||
routes = web.RouteTableDef()
|
|
||||||
config: Config
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/index.json")
|
|
||||||
async def get_packs(req: web.Request) -> web.Response:
|
|
||||||
user: User = req["user"]
|
|
||||||
packs = await user.get_packs()
|
|
||||||
return web.json_response({
|
|
||||||
"homeserver_url": user.homeserver_url,
|
|
||||||
"is_sticker_server": True,
|
|
||||||
"packs": [f"{pack.id}.json" for pack in packs],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/{pack_id}.json")
|
|
||||||
async def get_pack(req: web.Request) -> web.Response:
|
|
||||||
user: User = req["user"]
|
|
||||||
pack = await user.get_pack(req.match_info["pack_id"])
|
|
||||||
if pack is None:
|
|
||||||
raise Error.pack_not_found
|
|
||||||
stickers = await pack.get_stickers()
|
|
||||||
return web.json_response({
|
|
||||||
**pack.to_dict(),
|
|
||||||
"stickers": [sticker.to_dict() for sticker in stickers],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def init(cfg: Config) -> None:
|
|
||||||
global config
|
|
||||||
config = cfg
|
|
@@ -1,107 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Any
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import json
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
from pkg_resources import resource_stream
|
|
||||||
import jsonschema
|
|
||||||
|
|
||||||
from ..database import User, AccessToken, Pack, Sticker
|
|
||||||
from .errors import Error
|
|
||||||
|
|
||||||
routes = web.RouteTableDef()
|
|
||||||
|
|
||||||
pack_schema = json.load(resource_stream("sticker.server.api", "pack.schema.json"))
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/whoami")
|
|
||||||
async def whoami(req: web.Request) -> web.Response:
|
|
||||||
user: User = req["user"]
|
|
||||||
token: AccessToken = req["token"]
|
|
||||||
return web.json_response({
|
|
||||||
"id": user.id,
|
|
||||||
"widget_secret": user.widget_secret,
|
|
||||||
"homeserver_url": user.homeserver_url,
|
|
||||||
"last_seen": int(token.last_seen_date.timestamp() / 60) * 60,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/packs")
|
|
||||||
async def packs(req: web.Request) -> web.Response:
|
|
||||||
user: User = req["user"]
|
|
||||||
packs = await user.get_packs()
|
|
||||||
return web.json_response([pack.to_dict() for pack in packs])
|
|
||||||
|
|
||||||
|
|
||||||
async def get_json(req: web.Request, schema: str) -> Any:
|
|
||||||
try:
|
|
||||||
data = await req.json()
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
raise Error.request_not_json
|
|
||||||
try:
|
|
||||||
jsonschema.validate(data, schema)
|
|
||||||
except jsonschema.ValidationError as e:
|
|
||||||
raise Error.schema_error(e.message, e.path)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@routes.post("/packs/create")
|
|
||||||
async def upload_pack(req: web.Request) -> web.Response:
|
|
||||||
data = await get_json(req, pack_schema)
|
|
||||||
user: User = req["user"]
|
|
||||||
title = data.pop("title")
|
|
||||||
raw_stickers = data.pop("stickers")
|
|
||||||
pack_id_suffix = data.pop("id", "".join(random.choices(string.ascii_lowercase, k=12)))
|
|
||||||
pack = Pack(id=f"{user.id}_{pack_id_suffix}", owner=user.id, title=title, meta=data)
|
|
||||||
stickers = [Sticker(pack_id=pack.id, id=sticker.pop("id"), url=sticker.pop("url"),
|
|
||||||
body=sticker.pop("body"), meta=sticker) for sticker in raw_stickers]
|
|
||||||
await pack.insert()
|
|
||||||
await pack.set_stickers(stickers)
|
|
||||||
await user.add_pack(pack)
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
**pack.to_dict(),
|
|
||||||
"stickers": [sticker.to_dict() for sticker in stickers],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.get("/pack/{pack_id}")
|
|
||||||
async def get_pack(req: web.Request) -> web.Response:
|
|
||||||
user: User = req["user"]
|
|
||||||
pack = await user.get_pack(req.match_info["pack_id"])
|
|
||||||
if pack is None:
|
|
||||||
raise Error.pack_not_found
|
|
||||||
return web.json_response({
|
|
||||||
**pack.to_dict(),
|
|
||||||
"stickers": [sticker.to_dict() for sticker in await pack.get_stickers()],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@routes.delete("/pack/{pack_id}")
|
|
||||||
async def delete_pack(req: web.Request) -> web.Response:
|
|
||||||
user: User = req["user"]
|
|
||||||
pack = await user.get_pack(req.match_info["pack_id"])
|
|
||||||
if pack is None:
|
|
||||||
raise Error.pack_not_found
|
|
||||||
|
|
||||||
if pack.owner != user.id:
|
|
||||||
await user.remove_pack(pack)
|
|
||||||
else:
|
|
||||||
await pack.delete()
|
|
||||||
return web.Response(status=204)
|
|
@@ -1,55 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
from mautrix.util.config import BaseFileConfig, ConfigUpdateHelper
|
|
||||||
from mautrix.types import UserID
|
|
||||||
from mautrix.client import Client
|
|
||||||
|
|
||||||
|
|
||||||
class Permissions(NamedTuple):
|
|
||||||
access: bool = False
|
|
||||||
create_packs: bool = False
|
|
||||||
telegram_import: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseFileConfig):
|
|
||||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
|
||||||
copy = helper.copy
|
|
||||||
|
|
||||||
copy("database")
|
|
||||||
|
|
||||||
copy("server.host")
|
|
||||||
copy("server.port")
|
|
||||||
copy("server.public_url")
|
|
||||||
copy("server.override_resource_path")
|
|
||||||
copy("server.trust_forward_headers")
|
|
||||||
|
|
||||||
copy("telegram_import.bot_token")
|
|
||||||
copy("telegram_import.homeserver.address")
|
|
||||||
copy("telegram_import.homeserver.access_token")
|
|
||||||
|
|
||||||
copy("permissions")
|
|
||||||
|
|
||||||
copy("logging")
|
|
||||||
|
|
||||||
def get_permissions(self, mxid: UserID) -> Permissions:
|
|
||||||
_, homeserver = Client.parse_user_id(mxid)
|
|
||||||
return Permissions(**{
|
|
||||||
**self["permissions"].get("*", {}),
|
|
||||||
**self["permissions"].get(homeserver, {}),
|
|
||||||
**self["permissions"].get(mxid, {}),
|
|
||||||
})
|
|
@@ -1,6 +0,0 @@
|
|||||||
from .base import Base
|
|
||||||
from .upgrade import upgrade_table
|
|
||||||
from .sticker import Sticker
|
|
||||||
from .pack import Pack
|
|
||||||
from .access_token import AccessToken
|
|
||||||
from .user import User
|
|
@@ -1,72 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional, ClassVar
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from attr import dataclass
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
from mautrix.types import UserID
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
|
||||||
class AccessToken(Base):
|
|
||||||
token_expiry: ClassVar[timedelta] = timedelta(days=1)
|
|
||||||
|
|
||||||
user_id: UserID
|
|
||||||
token_id: int
|
|
||||||
token_hash: bytes
|
|
||||||
last_seen_ip: str
|
|
||||||
last_seen_date: datetime
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get(cls, token_id: int) -> Optional['AccessToken']:
|
|
||||||
q = ("SELECT user_id, token_hash, last_seen_ip, last_seen_date "
|
|
||||||
"FROM access_token WHERE token_id=$1")
|
|
||||||
row: asyncpg.Record = await cls.db.fetchrow(q, token_id)
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return cls(**row, token_id=token_id)
|
|
||||||
|
|
||||||
async def update_ip(self, ip: str) -> None:
|
|
||||||
if self.last_seen_ip == ip and (self.last_seen_date.replace(second=0, microsecond=0)
|
|
||||||
== datetime.now().replace(second=0, microsecond=0)):
|
|
||||||
# Same IP and last seen on this minute, skip update
|
|
||||||
return
|
|
||||||
q = ("UPDATE access_token SET last_seen_ip=$2, last_seen_date=current_timestamp "
|
|
||||||
"WHERE token_id=$1 RETURNING last_seen_date")
|
|
||||||
self.last_seen_date = await self.db.fetchval(q, self.token_id, ip)
|
|
||||||
self.last_seen_ip = ip
|
|
||||||
|
|
||||||
def check(self, token: str) -> bool:
|
|
||||||
return self.token_hash == hashlib.sha256(token.encode("utf-8")).digest()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def expired(self) -> bool:
|
|
||||||
return self.last_seen_date + self.token_expiry < datetime.now()
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
await self.db.execute("DELETE FROM access_token WHERE token_id=$1", self.token_id)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def insert(cls, user_id: UserID, token: str, ip: str) -> int:
|
|
||||||
q = ("INSERT INTO access_token (user_id, token_hash, last_seen_ip, last_seen_date) "
|
|
||||||
"VALUES ($1, $2, $3, current_timestamp) RETURNING token_id")
|
|
||||||
hashed = hashlib.sha256(token.encode("utf-8")).digest()
|
|
||||||
return await cls.db.fetchval(q, user_id, hashed, ip)
|
|
@@ -1,9 +0,0 @@
|
|||||||
from typing import ClassVar, TYPE_CHECKING
|
|
||||||
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
fake_db = Database("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
class Base:
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
@@ -1,64 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import List, Dict, Any
|
|
||||||
import json
|
|
||||||
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.types import UserID
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
from .sticker import Sticker
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
|
||||||
class Pack(Base):
|
|
||||||
id: str
|
|
||||||
owner: UserID
|
|
||||||
title: str
|
|
||||||
meta: Dict[str, Any]
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
await self.db.execute("DELETE FROM pack WHERE id=$1", self.id)
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
await self.db.execute("INSERT INTO pack (id, owner, title, meta) VALUES ($1, $2, $3, $4)",
|
|
||||||
self.id, self.owner, self.title, json.dumps(self.meta))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_data(cls, **data: Any) -> 'Pack':
|
|
||||||
meta = json.loads(data.pop("meta"))
|
|
||||||
return cls(**data, meta=meta)
|
|
||||||
|
|
||||||
async def get_stickers(self) -> List[Sticker]:
|
|
||||||
res = await self.db.fetch('SELECT id, url, body, meta, "order" '
|
|
||||||
'FROM sticker WHERE pack_id=$1 ORDER BY "order"', self.id)
|
|
||||||
return [Sticker.from_data(**row, pack_id=self.id) for row in res]
|
|
||||||
|
|
||||||
async def set_stickers(self, stickers: List[Sticker]) -> None:
|
|
||||||
data = ((sticker.id, self.id, sticker.url, sticker.body, json.dumps(sticker.meta), order)
|
|
||||||
for order, sticker in enumerate(stickers))
|
|
||||||
columns = ["id", "pack_id", "url", "body", "meta", "order"]
|
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
|
||||||
await conn.execute("DELETE FROM sticker WHERE pack_id=$1", self.id)
|
|
||||||
await conn.copy_records_to_table("sticker", records=data, columns=columns)
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
**self.meta,
|
|
||||||
"title": self.title,
|
|
||||||
"id": self.id,
|
|
||||||
}
|
|
@@ -1,47 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Dict, Any
|
|
||||||
import json
|
|
||||||
|
|
||||||
from attr import dataclass
|
|
||||||
import attr
|
|
||||||
|
|
||||||
from mautrix.types import ContentURI
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
|
||||||
class Sticker(Base):
|
|
||||||
pack_id: str
|
|
||||||
order: int = 0
|
|
||||||
id: str
|
|
||||||
url: ContentURI = attr.ib(order=False)
|
|
||||||
body: str = attr.ib(order=False)
|
|
||||||
meta: Dict[str, Any] = attr.ib(order=False)
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
**self.meta,
|
|
||||||
"body": self.body,
|
|
||||||
"url": self.url,
|
|
||||||
"id": self.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_data(cls, **data: Any) -> 'Sticker':
|
|
||||||
meta = json.loads(data.pop("meta"))
|
|
||||||
return cls(**data, meta=meta)
|
|
@@ -1,57 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from asyncpg import Connection
|
|
||||||
|
|
||||||
from mautrix.util.async_db.upgrade import UpgradeTable
|
|
||||||
|
|
||||||
upgrade_table = UpgradeTable()
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Initial revision")
|
|
||||||
async def upgrade_v1(conn: Connection) -> None:
|
|
||||||
await conn.execute("""CREATE TABLE "user" (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
widget_secret TEXT NOT NULL,
|
|
||||||
homeserver_url TEXT NOT NULL
|
|
||||||
)""")
|
|
||||||
await conn.execute("""CREATE TABLE access_token (
|
|
||||||
token_id SERIAL PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
token_hash BYTEA NOT NULL,
|
|
||||||
last_seen_ip TEXT,
|
|
||||||
last_seen_date TIMESTAMP
|
|
||||||
)""")
|
|
||||||
await conn.execute("""CREATE TABLE pack (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
owner TEXT REFERENCES "user"(id) ON DELETE SET NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
meta JSONB NOT NULL
|
|
||||||
)""")
|
|
||||||
await conn.execute("""CREATE TABLE user_pack (
|
|
||||||
user_id TEXT REFERENCES "user"(id) ON DELETE CASCADE,
|
|
||||||
pack_id TEXT REFERENCES pack(id) ON DELETE CASCADE,
|
|
||||||
"order" INT NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (user_id, pack_id)
|
|
||||||
)""")
|
|
||||||
await conn.execute("""CREATE TABLE sticker (
|
|
||||||
id TEXT,
|
|
||||||
pack_id TEXT REFERENCES pack(id) ON DELETE CASCADE,
|
|
||||||
url TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
meta JSONB NOT NULL,
|
|
||||||
"order" INT NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (id, pack_id)
|
|
||||||
)""")
|
|
@@ -1,104 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Optional, List, ClassVar
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import time
|
|
||||||
|
|
||||||
from attr import dataclass
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
from mautrix.types import UserID
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
from .pack import Pack
|
|
||||||
from .access_token import AccessToken
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
|
||||||
class User(Base):
|
|
||||||
token_charset: ClassVar[str] = string.ascii_letters + string.digits
|
|
||||||
|
|
||||||
id: UserID
|
|
||||||
widget_secret: str
|
|
||||||
homeserver_url: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _random_token(cls) -> str:
|
|
||||||
return "".join(random.choices(cls.token_charset, k=64))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def new(cls, id: UserID, homeserver_url: str) -> 'User':
|
|
||||||
return User(id=id, widget_secret=cls._random_token(), homeserver_url=homeserver_url)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get(cls, id: UserID) -> Optional['User']:
|
|
||||||
q = 'SELECT id, widget_secret, homeserver_url FROM "user" WHERE id=$1'
|
|
||||||
row: asyncpg.Record = await cls.db.fetchrow(q, id)
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
async def regenerate_widget_secret(self) -> None:
|
|
||||||
self.widget_secret = self._random_token()
|
|
||||||
await self.db.execute('UPDATE "user" SET widget_secret=$1 WHERE id=$2',
|
|
||||||
self.widget_secret, self.id)
|
|
||||||
|
|
||||||
async def set_homeserver_url(self, url: str) -> None:
|
|
||||||
self.homeserver_url = url
|
|
||||||
await self.db.execute('UPDATE "user" SET homeserver_url=$1 WHERE id=$2', url, self.id)
|
|
||||||
|
|
||||||
async def new_access_token(self, ip: str) -> str:
|
|
||||||
token = self._random_token()
|
|
||||||
token_id = await AccessToken.insert(self.id, token, ip)
|
|
||||||
return f"{token_id}:{token}"
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
await self.db.execute('DELETE FROM "user" WHERE id=$1', self.id)
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = 'INSERT INTO "user" (id, widget_secret, homeserver_url) VALUES ($1, $2, $3)'
|
|
||||||
await self.db.execute(q, self.id, self.widget_secret, self.homeserver_url)
|
|
||||||
|
|
||||||
async def get_packs(self) -> List[Pack]:
|
|
||||||
res = await self.db.fetch("SELECT id, owner, title, meta FROM user_pack "
|
|
||||||
"LEFT JOIN pack ON pack.id=user_pack.pack_id "
|
|
||||||
'WHERE user_id=$1 ORDER BY "order"', self.id)
|
|
||||||
return [Pack.from_data(**row) for row in res]
|
|
||||||
|
|
||||||
async def get_pack(self, pack_id: str) -> Optional[Pack]:
|
|
||||||
row = await self.db.fetchrow("SELECT id, owner, title, meta FROM user_pack "
|
|
||||||
"LEFT JOIN pack ON pack.id=user_pack.pack_id "
|
|
||||||
"WHERE user_id=$1 AND pack_id=$2", self.id, pack_id)
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return Pack.from_data(**row)
|
|
||||||
|
|
||||||
async def set_packs(self, packs: List[Pack]) -> None:
|
|
||||||
data = ((self.id, pack.id, order)
|
|
||||||
for order, pack in enumerate(packs))
|
|
||||||
columns = ["user_id", "pack_id", "order"]
|
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
|
||||||
await conn.execute("DELETE FROM user_pack WHERE user_id=$1", self.id)
|
|
||||||
await conn.copy_records_to_table("user_pack", records=data, columns=columns)
|
|
||||||
|
|
||||||
async def add_pack(self, pack: Pack) -> None:
|
|
||||||
q = 'INSERT INTO user_pack (user_id, pack_id, "order") VALUES ($1, $2, $3)'
|
|
||||||
await self.db.execute(q, self.id, pack.id, int(time.time()))
|
|
||||||
|
|
||||||
async def remove_pack(self, pack: Pack) -> None:
|
|
||||||
q = "DELETE FROM user_pack WHERE user_id=$1 AND pack_id=$2"
|
|
||||||
await self.db.execute(q, self.id, pack.id)
|
|
@@ -1,74 +0,0 @@
|
|||||||
# Postgres database URL for storing sticker packs and other things.
|
|
||||||
database: postgres://username:password@hostname/dbname
|
|
||||||
|
|
||||||
# Settings for the actual HTTP server
|
|
||||||
server:
|
|
||||||
# The IP and port to listen to.
|
|
||||||
hostname: 0.0.0.0
|
|
||||||
port: 29329
|
|
||||||
# Public base URL where the server is visible.
|
|
||||||
public_url: https://example.com
|
|
||||||
# Override path from where to load UI resources.
|
|
||||||
# Set to false to using pkg_resources to find the path.
|
|
||||||
override_resource_path: false
|
|
||||||
# Whether or not to trust X-Forwarded-For headers for determining the request IP.
|
|
||||||
trust_forward_headers: false
|
|
||||||
|
|
||||||
# Telegram configuration for downloading sticker packs. In the future, this will be client-side and
|
|
||||||
# none of this configuration will be necessary.
|
|
||||||
telegram_import:
|
|
||||||
# Create your own bot at https://t.me/BotFather
|
|
||||||
bot_token: null
|
|
||||||
|
|
||||||
# Matrix homeserver access details. This is only used for uploading Telegram-imported stickers.
|
|
||||||
homeserver:
|
|
||||||
address: https://example.com
|
|
||||||
access_token: null
|
|
||||||
|
|
||||||
# Permissions for who is allowed to use the sticker picker.
|
|
||||||
#
|
|
||||||
# Values are objects that should contain boolean values each permission:
|
|
||||||
# access - Access the sticker picker and use existing packs.
|
|
||||||
# create_packs - Create packs by uploading images.
|
|
||||||
# telegram_import - Create packs by importing from Telegram. Images are stored on
|
|
||||||
#
|
|
||||||
# Permission keys may be user IDs, server names or "*". If a server name or user ID permission
|
|
||||||
# doesn't specify some keys, they'll be inherited from the higher level.
|
|
||||||
permissions:
|
|
||||||
"*":
|
|
||||||
access: true
|
|
||||||
create_packs: true
|
|
||||||
telegram_import: false
|
|
||||||
"example.com":
|
|
||||||
telegram_import: true
|
|
||||||
|
|
||||||
# Python logging configuration.
|
|
||||||
#
|
|
||||||
# See Configuration dictionary schema in the Python documentation for more info:
|
|
||||||
# https://docs.python.org/3.9/library/logging.config.html#configuration-dictionary-schema
|
|
||||||
logging:
|
|
||||||
version: 1
|
|
||||||
formatters:
|
|
||||||
colored:
|
|
||||||
(): mautrix.util.logging.ColorFormatter
|
|
||||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
|
||||||
normal:
|
|
||||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
|
||||||
handlers:
|
|
||||||
file:
|
|
||||||
class: logging.handlers.RotatingFileHandler
|
|
||||||
formatter: normal
|
|
||||||
filename: ./sticker.server.log
|
|
||||||
maxBytes: 10485760
|
|
||||||
backupCount: 10
|
|
||||||
console:
|
|
||||||
class: logging.StreamHandler
|
|
||||||
formatter: colored
|
|
||||||
loggers:
|
|
||||||
mau:
|
|
||||||
level: DEBUG
|
|
||||||
aiohttp:
|
|
||||||
level: INFO
|
|
||||||
root:
|
|
||||||
level: DEBUG
|
|
||||||
handlers: [file, console]
|
|
@@ -1 +0,0 @@
|
|||||||
../../web/
|
|
@@ -1,50 +0,0 @@
|
|||||||
# maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
# Copyright (C) 2020 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from pkg_resources import resource_filename
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
from .api import packs_app, setup_app, integrations_app, init as init_api
|
|
||||||
from .static import StaticResource
|
|
||||||
from .config import Config
|
|
||||||
|
|
||||||
|
|
||||||
class Server:
|
|
||||||
config: Config
|
|
||||||
runner: web.AppRunner
|
|
||||||
app: web.Application
|
|
||||||
site: web.TCPSite
|
|
||||||
|
|
||||||
def __init__(self, config: Config) -> None:
|
|
||||||
init_api(config)
|
|
||||||
self.config = config
|
|
||||||
self.app = web.Application()
|
|
||||||
self.app.add_subapp("/_matrix/integrations/v1", integrations_app)
|
|
||||||
self.app.add_subapp("/setup/api", setup_app)
|
|
||||||
self.app.add_subapp("/packs", packs_app)
|
|
||||||
|
|
||||||
resource_path = (config["server.override_resource_path"]
|
|
||||||
or resource_filename("sticker.server", "frontend"))
|
|
||||||
self.app.router.register_resource(StaticResource("/", resource_path, name="frontend"))
|
|
||||||
self.runner = web.AppRunner(self.app)
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
await self.runner.setup()
|
|
||||||
self.site = web.TCPSite(self.runner, self.config["server.host"],
|
|
||||||
self.config["server.port"])
|
|
||||||
await self.site.start()
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
await self.runner.cleanup()
|
|
@@ -1,106 +0,0 @@
|
|||||||
# Simplified version of aiohttp's StaticResource with support for index.html
|
|
||||||
# https://github.com/aio-libs/aiohttp/blob/v3.6.2/aiohttp/web_urldispatcher.py#L496-L678
|
|
||||||
# Licensed under Apache 2.0
|
|
||||||
from typing import Callable, Awaitable, Tuple, Optional, Union, Dict, Set, Iterator, Any
|
|
||||||
from pathlib import Path, PurePath
|
|
||||||
|
|
||||||
from aiohttp.web import (Request, StreamResponse, FileResponse, ResourceRoute, AbstractResource,
|
|
||||||
AbstractRoute, UrlMappingMatchInfo, HTTPNotFound, HTTPForbidden)
|
|
||||||
from aiohttp.abc import AbstractMatchInfo
|
|
||||||
from yarl import URL
|
|
||||||
|
|
||||||
Handler = Callable[[Request], Awaitable[StreamResponse]]
|
|
||||||
|
|
||||||
|
|
||||||
class StaticResource(AbstractResource):
|
|
||||||
def __init__(self, prefix: str, directory: Union[str, PurePath], *, name: Optional[str] = None,
|
|
||||||
error_path: Optional[str] = "index.html", chunk_size: int = 256 * 1024) -> None:
|
|
||||||
super().__init__(name=name)
|
|
||||||
try:
|
|
||||||
directory = Path(directory).resolve()
|
|
||||||
if not directory.is_dir():
|
|
||||||
raise ValueError("Not a directory")
|
|
||||||
except (FileNotFoundError, ValueError) as error:
|
|
||||||
raise ValueError(f"No directory exists at '{directory}'") from error
|
|
||||||
self._directory = directory
|
|
||||||
self._chunk_size = chunk_size
|
|
||||||
self._prefix = prefix
|
|
||||||
self._error_file = (directory / error_path) if error_path else None
|
|
||||||
|
|
||||||
self._routes = {
|
|
||||||
"GET": ResourceRoute("GET", self._handle, self),
|
|
||||||
"HEAD": ResourceRoute("HEAD", self._handle, self),
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def canonical(self) -> str:
|
|
||||||
return self._prefix
|
|
||||||
|
|
||||||
def add_prefix(self, prefix: str) -> None:
|
|
||||||
assert prefix.startswith("/")
|
|
||||||
assert not prefix.endswith("/")
|
|
||||||
assert len(prefix) > 1
|
|
||||||
self._prefix = prefix + self._prefix
|
|
||||||
|
|
||||||
def raw_match(self, prefix: str) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def url_for(self, *, filename: Union[str, Path]) -> URL:
|
|
||||||
if isinstance(filename, Path):
|
|
||||||
filename = str(filename)
|
|
||||||
while filename.startswith("/"):
|
|
||||||
filename = filename[1:]
|
|
||||||
return URL.build(path=f"{self._prefix}/{filename}")
|
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"directory": self._directory,
|
|
||||||
"prefix": self._prefix,
|
|
||||||
}
|
|
||||||
|
|
||||||
def set_options_route(self, handler: Handler) -> None:
|
|
||||||
if "OPTIONS" in self._routes:
|
|
||||||
raise RuntimeError("OPTIONS route was set already")
|
|
||||||
self._routes["OPTIONS"] = ResourceRoute("OPTIONS", handler, self)
|
|
||||||
|
|
||||||
async def resolve(self, request: Request) -> Tuple[Optional[AbstractMatchInfo], Set[str]]:
|
|
||||||
path = request.rel_url.raw_path
|
|
||||||
method = request.method
|
|
||||||
allowed_methods = set(self._routes)
|
|
||||||
if not path.startswith(self._prefix):
|
|
||||||
return None, set()
|
|
||||||
|
|
||||||
if method not in allowed_methods:
|
|
||||||
return None, allowed_methods
|
|
||||||
|
|
||||||
return UrlMappingMatchInfo({
|
|
||||||
"filename": URL.build(path=path[len(self._prefix):], encoded=True).path
|
|
||||||
}, self._routes[method]), allowed_methods
|
|
||||||
|
|
||||||
def __len__(self) -> int:
|
|
||||||
return len(self._routes)
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[AbstractRoute]:
|
|
||||||
return iter(self._routes.values())
|
|
||||||
|
|
||||||
async def _handle(self, request: Request) -> StreamResponse:
|
|
||||||
try:
|
|
||||||
filename = Path(request.match_info["filename"])
|
|
||||||
if not filename.anchor:
|
|
||||||
filepath = (self._directory / filename).resolve()
|
|
||||||
if filepath.is_file():
|
|
||||||
return FileResponse(filepath, chunk_size=self._chunk_size)
|
|
||||||
index_path = (self._directory / filename / "index.html").resolve()
|
|
||||||
if index_path.is_file():
|
|
||||||
return FileResponse(index_path, chunk_size=self._chunk_size)
|
|
||||||
except (ValueError, FileNotFoundError) as error:
|
|
||||||
raise HTTPNotFound() from error
|
|
||||||
except HTTPForbidden:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
request.app.logger.exception("Error while trying to serve static file")
|
|
||||||
raise HTTPNotFound() from error
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
name = f"'{self.name}'" if self.name is not None else ""
|
|
||||||
return f"<StaticResource {name} {self._prefix} -> {self._directory!r}>"
|
|
@@ -71,7 +71,7 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir
|
|||||||
|
|
||||||
already_uploaded = {}
|
already_uploaded = {}
|
||||||
try:
|
try:
|
||||||
with open(pack_path) as pack_file:
|
with util.open_utf8(pack_path) as pack_file:
|
||||||
existing_pack = json.load(pack_file)
|
existing_pack = json.load(pack_file)
|
||||||
already_uploaded = {int(sticker["net.maunium.telegram.sticker"]["id"]): sticker
|
already_uploaded = {int(sticker["net.maunium.telegram.sticker"]["id"]): sticker
|
||||||
for sticker in existing_pack["stickers"]}
|
for sticker in existing_pack["stickers"]}
|
||||||
@@ -99,7 +99,7 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir
|
|||||||
doc["body"] = sticker.emoticon
|
doc["body"] = sticker.emoticon
|
||||||
doc["net.maunium.telegram.sticker"]["emoticons"].append(sticker.emoticon)
|
doc["net.maunium.telegram.sticker"]["emoticons"].append(sticker.emoticon)
|
||||||
|
|
||||||
with open(pack_path, "w") as pack_file:
|
with util.open_utf8(pack_path, "w") as pack_file:
|
||||||
json.dump({
|
json.dump({
|
||||||
"title": pack.set.title,
|
"title": pack.set.title,
|
||||||
"id": f"tg-{pack.set.id}",
|
"id": f"tg-{pack.set.id}",
|
||||||
@@ -117,8 +117,6 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir
|
|||||||
pack_url_regex = re.compile(r"^(?:(?:https?://)?(?:t|telegram)\.(?:me|dog)/addstickers/)?"
|
pack_url_regex = re.compile(r"^(?:(?:https?://)?(?:t|telegram)\.(?:me|dog)/addstickers/)?"
|
||||||
r"([A-Za-z0-9-_]+)"
|
r"([A-Za-z0-9-_]+)"
|
||||||
r"(?:\.json)?$")
|
r"(?:\.json)?$")
|
||||||
api_id = 298751
|
|
||||||
api_hash = "cb676d6bae20553c9996996a8f52b4d7"
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
|
|
||||||
@@ -134,17 +132,18 @@ parser.add_argument("pack", help="Sticker pack URLs to import", action="append",
|
|||||||
|
|
||||||
async def main(args: argparse.Namespace) -> None:
|
async def main(args: argparse.Namespace) -> None:
|
||||||
await matrix.load_config(args.config)
|
await matrix.load_config(args.config)
|
||||||
client = TelegramClient(args.session, api_id, api_hash)
|
client = TelegramClient(args.session, 298751, "cb676d6bae20553c9996996a8f52b4d7")
|
||||||
await client.start()
|
await client.start()
|
||||||
|
|
||||||
if args.list:
|
if args.list:
|
||||||
stickers: AllStickers = await client(GetAllStickersRequest(hash=0))
|
stickers: AllStickers = await client(GetAllStickersRequest(hash=0))
|
||||||
index = 1
|
index = 1
|
||||||
width = len(str(stickers.sets))
|
width = len(str(len(stickers.sets)))
|
||||||
print("Your saved sticker packs:")
|
print("Your saved sticker packs:")
|
||||||
for saved_pack in stickers.sets:
|
for saved_pack in stickers.sets:
|
||||||
print(f"{index:>{width}}. {saved_pack.title} "
|
print(f"{index:>{width}}. {saved_pack.title} "
|
||||||
f"(t.me/addstickers/{saved_pack.short_name})")
|
f"(t.me/addstickers/{saved_pack.short_name})")
|
||||||
|
index += 1
|
||||||
elif args.pack[0]:
|
elif args.pack[0]:
|
||||||
input_packs = []
|
input_packs = []
|
||||||
for pack_url in args.pack[0]:
|
for pack_url in args.pack[0]:
|
||||||
@@ -154,7 +153,7 @@ async def main(args: argparse.Namespace) -> None:
|
|||||||
return
|
return
|
||||||
input_packs.append(InputStickerSetShortName(short_name=match.group(1)))
|
input_packs.append(InputStickerSetShortName(short_name=match.group(1)))
|
||||||
for input_pack in input_packs:
|
for input_pack in input_packs:
|
||||||
pack: StickerSetFull = await client(GetStickerSetRequest(input_pack))
|
pack: StickerSetFull = await client(GetStickerSetRequest(input_pack, hash=0))
|
||||||
await reupload_pack(client, pack, args.output_dir)
|
await reupload_pack(client, pack, args.output_dir)
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
@@ -1 +1,6 @@
|
|||||||
from .get_version import git_tag, git_revision, version, linkified_version
|
# Generated in setup.py
|
||||||
|
|
||||||
|
git_tag = None
|
||||||
|
git_revision = 'f59406a4'
|
||||||
|
version = '0.1.0+dev.f59406a4'
|
||||||
|
linkified_version = '0.1.0+dev.[f59406a4](https://github.com/maunium/stickerpicker/commit/f59406a47a6778cd402e656ffb64f667335f665a)'
|
||||||
|
@@ -1,196 +0,0 @@
|
|||||||
{
|
|
||||||
"env": {
|
|
||||||
"es6": true,
|
|
||||||
"browser": true
|
|
||||||
},
|
|
||||||
"extends": [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:import/errors",
|
|
||||||
"plugin:import/warnings"
|
|
||||||
],
|
|
||||||
"parser": "@babel/eslint-parser",
|
|
||||||
"parserOptions": {
|
|
||||||
"sourceType": "module",
|
|
||||||
"requireConfigFile": false
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
"import",
|
|
||||||
"react-hooks"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"indent": [
|
|
||||||
"error",
|
|
||||||
"tab"
|
|
||||||
],
|
|
||||||
"linebreak-style": [
|
|
||||||
"error",
|
|
||||||
"unix"
|
|
||||||
],
|
|
||||||
"quotes": [
|
|
||||||
"error",
|
|
||||||
"double",
|
|
||||||
{
|
|
||||||
"avoidEscape": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"semi": [
|
|
||||||
"error",
|
|
||||||
"never"
|
|
||||||
],
|
|
||||||
"comma-dangle": [
|
|
||||||
"error",
|
|
||||||
"only-multiline"
|
|
||||||
],
|
|
||||||
"comma-spacing": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"after": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"eol-last": [
|
|
||||||
"error",
|
|
||||||
"always"
|
|
||||||
],
|
|
||||||
"no-trailing-spaces": [
|
|
||||||
"error"
|
|
||||||
],
|
|
||||||
"camelcase": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"properties": "always"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"import/no-unresolved": "off",
|
|
||||||
"import/named": "error",
|
|
||||||
"import/namespace": "error",
|
|
||||||
"import/default": "error",
|
|
||||||
"import/export": "error",
|
|
||||||
"import/order": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"newlines-between": "always",
|
|
||||||
"pathGroups": [
|
|
||||||
{
|
|
||||||
"pattern": "{.,..,../..,../../..,../../../..}/lib/**",
|
|
||||||
"group": "external"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"groups": [
|
|
||||||
"builtin",
|
|
||||||
"external",
|
|
||||||
[
|
|
||||||
"internal",
|
|
||||||
"sibling",
|
|
||||||
"parent"
|
|
||||||
],
|
|
||||||
"index"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"max-len": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"code": 100
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"prefer-const": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"destructuring": "all",
|
|
||||||
"ignoreReadBeforeAssign": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"arrow-spacing": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"before": true,
|
|
||||||
"after": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"space-before-blocks": [
|
|
||||||
"error",
|
|
||||||
"always"
|
|
||||||
],
|
|
||||||
"object-curly-spacing": [
|
|
||||||
"error",
|
|
||||||
"always"
|
|
||||||
],
|
|
||||||
"array-bracket-spacing": [
|
|
||||||
"error",
|
|
||||||
"never"
|
|
||||||
],
|
|
||||||
"space-in-parens": [
|
|
||||||
"error",
|
|
||||||
"never"
|
|
||||||
],
|
|
||||||
"keyword-spacing": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"before": true,
|
|
||||||
"after": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"key-spacing": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"afterColon": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"template-curly-spacing": [
|
|
||||||
"error",
|
|
||||||
"never"
|
|
||||||
],
|
|
||||||
"no-empty": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"allowEmptyCatch": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"arrow-body-style": [
|
|
||||||
"error",
|
|
||||||
"as-needed"
|
|
||||||
],
|
|
||||||
"no-multiple-empty-lines": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"max": 1,
|
|
||||||
"maxBOF": 0,
|
|
||||||
"maxEOF": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"no-prototype-builtins": "off",
|
|
||||||
"dot-notation": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"allowKeywords": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"quote-props": [
|
|
||||||
"error",
|
|
||||||
"as-needed"
|
|
||||||
],
|
|
||||||
"no-multi-spaces": [
|
|
||||||
"error"
|
|
||||||
],
|
|
||||||
"space-infix-ops": [
|
|
||||||
"error"
|
|
||||||
],
|
|
||||||
"object-curly-newline": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"multiline": false,
|
|
||||||
"consistent": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"no-mixed-operators": [
|
|
||||||
"error"
|
|
||||||
],
|
|
||||||
"no-extra-parens": [
|
|
||||||
"error",
|
|
||||||
"all",
|
|
||||||
{
|
|
||||||
"nestedBinaryExpressions": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
23
web/esinstall.js
Normal file
23
web/esinstall.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const { install, printStats } = require("esinstall")
|
||||||
|
|
||||||
|
install(
|
||||||
|
[{
|
||||||
|
specifier: "htm/preact",
|
||||||
|
all: false,
|
||||||
|
default: false,
|
||||||
|
namespace: false,
|
||||||
|
named: ["html", "render", "Component"],
|
||||||
|
}],
|
||||||
|
{
|
||||||
|
dest: "./lib",
|
||||||
|
sourceMap: false,
|
||||||
|
treeshake: true,
|
||||||
|
verbose: true,
|
||||||
|
}
|
||||||
|
).then(data => {
|
||||||
|
const oldPrefix = "web_modules/"
|
||||||
|
const newPrefix = "lib/"
|
||||||
|
const spaces = " ".repeat(oldPrefix.length - newPrefix.length)
|
||||||
|
console.log("Installation complete")
|
||||||
|
console.log(printStats(data.stats).replace(oldPrefix, newPrefix + spaces))
|
||||||
|
})
|
@@ -4,16 +4,16 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
|
||||||
<title>Maunium sticker picker</title>
|
<title>Maunium sticker picker</title>
|
||||||
|
<script src="https://unpkg.com/matrix-widget-api@1.6.0/dist/api.js"></script>
|
||||||
<link rel="modulepreload" href="src/widget/widget-api.js"/>
|
<script type="module" src="src/widget-api.js"></script>
|
||||||
<link rel="modulepreload" href="src/widget/frequently-used.js"/>
|
<link rel="modulepreload" href="src/frequently-used.js"/>
|
||||||
<link rel="modulepreload" href="src/Spinner.js"/>
|
<link rel="modulepreload" href="src/spinner.js"/>
|
||||||
<link rel="modulepreload" href="lib/htm/preact.js"/>
|
<link rel="modulepreload" href="lib/htm/preact.js"/>
|
||||||
<link rel="preload" href="packs/index.json" as="fetch" type="application/json" crossorigin/>
|
<link rel="preload" href="packs/index.json" as="fetch" type="application/json" crossorigin/>
|
||||||
|
|
||||||
<link rel="stylesheet" href="style/widget.css"/>
|
<link rel="stylesheet" href="style/index.css"/>
|
||||||
<link rel="stylesheet" href="style/spinner.css"/>
|
<link rel="stylesheet" href="style/spinner.css"/>
|
||||||
<script src="src/widget/index.js" type="module"></script>
|
<script src="src/index.js" type="module"></script>
|
||||||
<script nomodule>document.body.innerText = "This sticker picker requires modern JavaScript"</script>
|
<script nomodule>document.body.innerText = "This sticker picker requires modern JavaScript"</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +0,0 @@
|
|||||||
import { n } from '../common/preact.module-9c264606.js';
|
|
||||||
|
|
||||||
var t,u,r,o=0,i=[],c=n.__r,f=n.diffed,e=n.__c,a=n.unmount;function v(t,r){n.__h&&n.__h(u,t,o||r),o=0;var i=u.__H||(u.__H={__:[],__h:[]});return t>=i.__.length&&i.__.push({}),i.__[t]}function m(n){return o=1,p(k,n)}function p(n,r,o){var i=v(t++,2);return i.t=n,i.__c||(i.__=[o?o(r):k(void 0,r),function(n){var t=i.t(i.__[0],n);i.__[0]!==t&&(i.__=[t,i.__[1]],i.__c.setState({}));}],i.__c=u),i.__}function y(r,o){var i=v(t++,3);!n.__s&&j(i.__H,o)&&(i.__=r,i.__H=o,u.__H.__h.push(i));}function l(r,o){var i=v(t++,4);!n.__s&&j(i.__H,o)&&(i.__=r,i.__H=o,u.__h.push(i));}function h(n){return o=5,_(function(){return {current:n}},[])}function s(n,t,u){o=6,l(function(){"function"==typeof n?n(t()):n&&(n.current=t());},null==u?u:u.concat(n));}function _(n,u){var r=v(t++,7);return j(r.__H,u)&&(r.__=n(),r.__H=u,r.__h=n),r.__}function A(n,t){return o=8,_(function(){return n},t)}function F(n){var r=u.context[n.__c],o=v(t++,9);return o.__c=n,r?(null==o.__&&(o.__=!0,r.sub(u)),r.props.value):n.__}function T(t,u){n.useDebugValue&&n.useDebugValue(u?u(t):t);}function d(n){var r=v(t++,10),o=m();return r.__=n,u.componentDidCatch||(u.componentDidCatch=function(n){r.__&&r.__(n),o[1](n);}),[o[0],function(){o[1](void 0);}]}function q(){i.forEach(function(t){if(t.__P)try{t.__H.__h.forEach(b),t.__H.__h.forEach(g),t.__H.__h=[];}catch(u){t.__H.__h=[],n.__e(u,t.__v);}}),i=[];}n.__r=function(n){c&&c(n),t=0;var r=(u=n.__c).__H;r&&(r.__h.forEach(b),r.__h.forEach(g),r.__h=[]);},n.diffed=function(t){f&&f(t);var u=t.__c;u&&u.__H&&u.__H.__h.length&&(1!==i.push(u)&&r===n.requestAnimationFrame||((r=n.requestAnimationFrame)||function(n){var t,u=function(){clearTimeout(r),x&&cancelAnimationFrame(t),setTimeout(n);},r=setTimeout(u,100);x&&(t=requestAnimationFrame(u));})(q));},n.__c=function(t,u){u.some(function(t){try{t.__h.forEach(b),t.__h=t.__h.filter(function(n){return !n.__||g(n)});}catch(r){u.some(function(n){n.__h&&(n.__h=[]);}),u=[],n.__e(r,t.__v);}}),e&&e(t,u);},n.unmount=function(t){a&&a(t);var u=t.__c;if(u&&u.__H)try{u.__H.__.forEach(b);}catch(t){n.__e(t,u.__v);}};var x="function"==typeof requestAnimationFrame;function b(n){"function"==typeof n.__c&&n.__c();}function g(n){n.__c=n.__();}function j(n,t){return !n||n.length!==t.length||t.some(function(t,u){return t!==n[u]})}function k(n,t){return "function"==typeof t?t(n):t}
|
|
||||||
|
|
||||||
export { A as useCallback, F as useContext, T as useDebugValue, y as useEffect, d as useErrorBoundary, s as useImperativeHandle, l as useLayoutEffect, _ as useMemo, p as useReducer, h as useRef, m as useState };
|
|
@@ -4,34 +4,16 @@
|
|||||||
"description": "A fast and simple Matrix sticker picker widget",
|
"description": "A fast and simple Matrix sticker picker widget",
|
||||||
"repository": "https://github.com/maunium/stickerpicker",
|
"repository": "https://github.com/maunium/stickerpicker",
|
||||||
"author": "Tulir Asokan <tulir@maunium.net>",
|
"author": "Tulir Asokan <tulir@maunium.net>",
|
||||||
"license": "MPL-2.0",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"snowpack": "snowpack",
|
"esinstall": "node ./esinstall.js",
|
||||||
"sass": "node-sass -o style style/*.sass --output-style compressed"
|
"sass": "sass --no-source-map --style=compressed style/"
|
||||||
},
|
|
||||||
"snowpack": {
|
|
||||||
"install": [
|
|
||||||
"htm/preact",
|
|
||||||
"preact/hooks"
|
|
||||||
],
|
|
||||||
"installOptions": {
|
|
||||||
"sourceMap": false,
|
|
||||||
"dest": "lib",
|
|
||||||
"treeshake": true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"htm": "^3.0.4",
|
"htm": "^3.1.0",
|
||||||
"preact": "^10.5.5",
|
"preact": "^10.5.14",
|
||||||
"snowpack": "^2.16.1"
|
"esinstall": "^1.1.7",
|
||||||
},
|
"sass": "^1.42.1"
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.12.3",
|
|
||||||
"@babel/eslint-parser": "^7.12.1",
|
|
||||||
"eslint": "^7.12.1",
|
|
||||||
"eslint-plugin-import": "^2.22.1",
|
|
||||||
"eslint-plugin-react-hooks": "^4.2.0",
|
|
||||||
"node-sass": "^5.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
16
web/packs/index.json
Normal file
16
web/packs/index.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"packs": [
|
||||||
|
"scalar-isabella.json",
|
||||||
|
"scalar-privacy_pam.json",
|
||||||
|
"scalar-sheltie.json",
|
||||||
|
"scalar-stakey.json",
|
||||||
|
"scalar-videoplasty.json",
|
||||||
|
"scalar-geeko.json",
|
||||||
|
"scalar-loading_artist.json",
|
||||||
|
"scalar-rabbit.json",
|
||||||
|
"scalar-smilies.json",
|
||||||
|
"scalar-stickman.json"
|
||||||
|
],
|
||||||
|
"homeserver_url": "https://matrix.intothematrix.in",
|
||||||
|
"giphy_api_key": "Gc7131jiJuvI7IdN0HZ1D7nh0ow5BU6g"
|
||||||
|
}
|
1
web/packs/scalar-geeko.json
Normal file
1
web/packs/scalar-geeko.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"title": "Geeko", "id": "scalar-191580", "stickers": [{"body": "Geeko with a suitcase, wearing a suit", "info": {"h": 256, "mimetype": "image/png", "size": 41241, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 41241, "w": 256}, "thumbnail_url": "mxc://matrix.org/GWyQoBKgXAoIXhBcCMCxmkxd", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/GWyQoBKgXAoIXhBcCMCxmkxd", "id": "GWyQoBKgXAoIXhBcCMCxmkxd"}, {"body": "Geeko driving away in a car waving", "info": {"h": 256, "mimetype": "image/png", "size": 51387, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 51387, "w": 256}, "thumbnail_url": "mxc://matrix.org/JkAPbbuIuOMMbWqhKTNnGETu", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/JkAPbbuIuOMMbWqhKTNnGETu", "id": "JkAPbbuIuOMMbWqhKTNnGETu"}, {"body": "Geeko enjoying his time in an armchair with a cup of tea", "info": {"h": 256, "mimetype": "image/png", "size": 44188, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 44188, "w": 256}, "thumbnail_url": "mxc://matrix.org/AdkCycKHKGfgpyOCrlcSihDK", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/AdkCycKHKGfgpyOCrlcSihDK", "id": "AdkCycKHKGfgpyOCrlcSihDK"}, {"body": "Sick Geeko wrapped in a blanket with a tray of refreshing bevarage", "info": {"h": 256, "mimetype": "image/png", "size": 36364, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 36364, "w": 256}, "thumbnail_url": "mxc://matrix.org/SNkQWaqBkKwIEHQyJmAuhnIL", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/SNkQWaqBkKwIEHQyJmAuhnIL", "id": "SNkQWaqBkKwIEHQyJmAuhnIL"}, {"body": "Geeko in a balerina skirt inviting to dance with them", "info": {"h": 256, "mimetype": "image/png", "size": 34716, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 34716, "w": 256}, "thumbnail_url": "mxc://matrix.org/WLjGVDOlqAPtMaggqcgmjZsB", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/WLjGVDOlqAPtMaggqcgmjZsB", "id": "WLjGVDOlqAPtMaggqcgmjZsB"}, {"body": "Geeko laying on a cloud", "info": {"h": 256, "mimetype": "image/png", "size": 39189, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 39189, "w": 256}, "thumbnail_url": "mxc://matrix.org/ouaitVRUeqIJCuMuKLNtzOPl", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/ouaitVRUeqIJCuMuKLNtzOPl", "id": "ouaitVRUeqIJCuMuKLNtzOPl"}, {"body": "Geeko stretching", "info": {"h": 256, "mimetype": "image/png", "size": 34670, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 34670, "w": 256}, "thumbnail_url": "mxc://matrix.org/DXEyavdgypGEPLeWdmaAkOAG", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/DXEyavdgypGEPLeWdmaAkOAG", "id": "DXEyavdgypGEPLeWdmaAkOAG"}, {"body": "Geeko aproaching Nirvana", "info": {"h": 256, "mimetype": "image/png", "size": 42035, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 42035, "w": 256}, "thumbnail_url": "mxc://matrix.org/DRCkchiNQgASUCFTxFDDfhvi", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/DRCkchiNQgASUCFTxFDDfhvi", "id": "DRCkchiNQgASUCFTxFDDfhvi"}, {"body": "Geeko dressed in a paper airplane, running", "info": {"h": 256, "mimetype": "image/png", "size": 49568, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 49568, "w": 256}, "thumbnail_url": "mxc://matrix.org/YrJQDfZpESKIYdUzvhMDXBLU", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/YrJQDfZpESKIYdUzvhMDXBLU", "id": "YrJQDfZpESKIYdUzvhMDXBLU"}, {"body": "Geeko drawing an animal", "info": {"h": 256, "mimetype": "image/png", "size": 54112, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 54112, "w": 256}, "thumbnail_url": "mxc://matrix.org/bHmdDCDjmolEjrxAFDGuHoJa", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/bHmdDCDjmolEjrxAFDGuHoJa", "id": "bHmdDCDjmolEjrxAFDGuHoJa"}]}
|
1
web/packs/scalar-isabella.json
Normal file
1
web/packs/scalar-isabella.json
Normal file
File diff suppressed because one or more lines are too long
1
web/packs/scalar-loading_artist.json
Normal file
1
web/packs/scalar-loading_artist.json
Normal file
File diff suppressed because one or more lines are too long
1
web/packs/scalar-privacy_pam.json
Normal file
1
web/packs/scalar-privacy_pam.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"title": "Privacy Pam", "id": "scalar-191583", "stickers": [{"body": "Privacy Pam is Angry", "info": {"h": 256, "mimetype": "image/png", "size": 184861, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 184861, "w": 256}, "thumbnail_url": "mxc://matrix.org/WYZLGkpAXwgOftafRtSQVNYF", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/WYZLGkpAXwgOftafRtSQVNYF", "id": "WYZLGkpAXwgOftafRtSQVNYF"}, {"body": "Privacy Pam Cries", "info": {"h": 256, "mimetype": "image/png", "size": 164740, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 164740, "w": 256}, "thumbnail_url": "mxc://matrix.org/aVOIYKvTRBiKqZbxomKeuwYD", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/aVOIYKvTRBiKqZbxomKeuwYD", "id": "aVOIYKvTRBiKqZbxomKeuwYD"}, {"body": "Privacy Pam is Happy", "info": {"h": 256, "mimetype": "image/png", "size": 172907, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 172907, "w": 256}, "thumbnail_url": "mxc://matrix.org/FZolsrwTDUJoLlGfWHffwuFP", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/FZolsrwTDUJoLlGfWHffwuFP", "id": "FZolsrwTDUJoLlGfWHffwuFP"}, {"body": "Privacy Pam Laughs", "info": {"h": 256, "mimetype": "image/png", "size": 170855, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 170855, "w": 256}, "thumbnail_url": "mxc://matrix.org/qTfporLEnxtdkdwmPUQwWNtg", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/qTfporLEnxtdkdwmPUQwWNtg", "id": "qTfporLEnxtdkdwmPUQwWNtg"}, {"body": "Privacy Pam is Sad", "info": {"h": 256, "mimetype": "image/png", "size": 179575, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 179575, "w": 256}, "thumbnail_url": "mxc://matrix.org/MvUDjTTYKanEzFAExAhJfyAL", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/MvUDjTTYKanEzFAExAhJfyAL", "id": "MvUDjTTYKanEzFAExAhJfyAL"}, {"body": "Privacy Pam Smiles", "info": {"h": 256, "mimetype": "image/png", "size": 185764, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 185764, "w": 256}, "thumbnail_url": "mxc://matrix.org/cUbyqEDvdvxMqnfBGKmIpgfp", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/cUbyqEDvdvxMqnfBGKmIpgfp", "id": "cUbyqEDvdvxMqnfBGKmIpgfp"}, {"body": "Privacy Pam is Thinking", "info": {"h": 256, "mimetype": "image/png", "size": 199567, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 199567, "w": 256}, "thumbnail_url": "mxc://matrix.org/JnxtjVDYQHKGMWDRqSDgCwPL", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/JnxtjVDYQHKGMWDRqSDgCwPL", "id": "JnxtjVDYQHKGMWDRqSDgCwPL"}, {"body": "Privacy Pam Likes", "info": {"h": 256, "mimetype": "image/png", "size": 196924, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 196924, "w": 256}, "thumbnail_url": "mxc://matrix.org/umFLoIIzwirpWpcbnlgbtNNW", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/umFLoIIzwirpWpcbnlgbtNNW", "id": "umFLoIIzwirpWpcbnlgbtNNW"}, {"body": "Privacy Pam Winks", "info": {"h": 256, "mimetype": "image/png", "size": 167280, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 167280, "w": 256}, "thumbnail_url": "mxc://matrix.org/mehuoFXMMUdUSezwTwkkxHCB", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/mehuoFXMMUdUSezwTwkkxHCB", "id": "mehuoFXMMUdUSezwTwkkxHCB"}]}
|
1
web/packs/scalar-rabbit.json
Normal file
1
web/packs/scalar-rabbit.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"title": "Rabbit", "id": "scalar-191566", "stickers": [{"body": "Carrot", "info": {"h": 200, "mimetype": "image/png", "size": 80625, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 80625, "w": 142}, "thumbnail_url": "mxc://matrix.org/kGJDCjMOgpLmZzbgknMTUHNm", "w": 142}, "msgtype": "m.sticker", "url": "mxc://matrix.org/kGJDCjMOgpLmZzbgknMTUHNm", "id": "kGJDCjMOgpLmZzbgknMTUHNm"}, {"body": "Chef", "info": {"h": 200, "mimetype": "image/png", "size": 88633, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 88633, "w": 151}, "thumbnail_url": "mxc://matrix.org/szaTExsJurtDBUUEeHHbhyqk", "w": 151}, "msgtype": "m.sticker", "url": "mxc://matrix.org/szaTExsJurtDBUUEeHHbhyqk", "id": "szaTExsJurtDBUUEeHHbhyqk"}, {"body": "Coding", "info": {"h": 185, "mimetype": "image/png", "size": 97412, "thumbnail_info": {"h": 185, "mimetype": "image/png", "size": 97412, "w": 200}, "thumbnail_url": "mxc://matrix.org/DykipVHRXsfamLGJscNLbFAB", "w": 200}, "msgtype": "m.sticker", "url": "mxc://matrix.org/DykipVHRXsfamLGJscNLbFAB", "id": "DykipVHRXsfamLGJscNLbFAB"}, {"body": "Doctor", "info": {"h": 200, "mimetype": "image/png", "size": 113391, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 113391, "w": 184}, "thumbnail_url": "mxc://matrix.org/GEhjrKIapqcbVWKEsoDMhXeZ", "w": 184}, "msgtype": "m.sticker", "url": "mxc://matrix.org/GEhjrKIapqcbVWKEsoDMhXeZ", "id": "GEhjrKIapqcbVWKEsoDMhXeZ"}, {"body": "Driving", "info": {"h": 200, "mimetype": "image/png", "size": 77577, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 77577, "w": 156}, "thumbnail_url": "mxc://matrix.org/jxPXTKpyydzdHJkdFNZjTZrD", "w": 156}, "msgtype": "m.sticker", "url": "mxc://matrix.org/jxPXTKpyydzdHJkdFNZjTZrD", "id": "jxPXTKpyydzdHJkdFNZjTZrD"}, {"body": "Landing", "info": {"h": 200, "mimetype": "image/png", "size": 73602, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 73602, "w": 140}, "thumbnail_url": "mxc://matrix.org/sHhqkFCvSkFwtmvtETOtKnLP", "w": 140}, "msgtype": "m.sticker", "url": "mxc://matrix.org/sHhqkFCvSkFwtmvtETOtKnLP", "id": "sHhqkFCvSkFwtmvtETOtKnLP"}, {"body": "Phone", "info": {"h": 200, "mimetype": "image/png", "size": 94007, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 94007, "w": 172}, "thumbnail_url": "mxc://matrix.org/mnNNbLjjLjQIcKaybAyVMKMQ", "w": 172}, "msgtype": "m.sticker", "url": "mxc://matrix.org/mnNNbLjjLjQIcKaybAyVMKMQ", "id": "mnNNbLjjLjQIcKaybAyVMKMQ"}, {"body": "Running", "info": {"h": 157, "mimetype": "image/png", "size": 83290, "thumbnail_info": {"h": 157, "mimetype": "image/png", "size": 83290, "w": 200}, "thumbnail_url": "mxc://matrix.org/gloPNMnAwUEtrtTsaeqPTlhK", "w": 200}, "msgtype": "m.sticker", "url": "mxc://matrix.org/gloPNMnAwUEtrtTsaeqPTlhK", "id": "gloPNMnAwUEtrtTsaeqPTlhK"}, {"body": "Science", "info": {"h": 200, "mimetype": "image/png", "size": 103111, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 103111, "w": 155}, "thumbnail_url": "mxc://matrix.org/uDmIFKTXYQpzipNELqRhWSsj", "w": 155}, "msgtype": "m.sticker", "url": "mxc://matrix.org/uDmIFKTXYQpzipNELqRhWSsj", "id": "uDmIFKTXYQpzipNELqRhWSsj"}, {"body": "Work", "info": {"h": 150, "mimetype": "image/png", "size": 81850, "thumbnail_info": {"h": 150, "mimetype": "image/png", "size": 81850, "w": 200}, "thumbnail_url": "mxc://matrix.org/kYOcGZCqtNzBSUqBBOaLDBgE", "w": 200}, "msgtype": "m.sticker", "url": "mxc://matrix.org/kYOcGZCqtNzBSUqBBOaLDBgE", "id": "kYOcGZCqtNzBSUqBBOaLDBgE"}]}
|
1
web/packs/scalar-sheltie.json
Normal file
1
web/packs/scalar-sheltie.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"title": "Sheltie", "id": "scalar-192093", "stickers": [{"body": "Busy", "info": {"h": 173, "mimetype": "image/png", "size": 132161, "thumbnail_info": {"h": 173, "mimetype": "image/png", "size": 132161, "w": 200}, "thumbnail_url": "mxc://matrix.org/KbQyHYcnRFPSfRCbGqbTBiWt", "w": 200}, "msgtype": "m.sticker", "url": "mxc://matrix.org/KbQyHYcnRFPSfRCbGqbTBiWt", "id": "KbQyHYcnRFPSfRCbGqbTBiWt"}, {"body": "Confused", "info": {"h": 200, "mimetype": "image/png", "size": 111042, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 111042, "w": 173}, "thumbnail_url": "mxc://matrix.org/KkCmAbAPsgeUFdOyOceqAFBr", "w": 173}, "msgtype": "m.sticker", "url": "mxc://matrix.org/KkCmAbAPsgeUFdOyOceqAFBr", "id": "KkCmAbAPsgeUFdOyOceqAFBr"}, {"body": "Happy", "info": {"h": 158, "mimetype": "image/png", "size": 110679, "thumbnail_info": {"h": 158, "mimetype": "image/png", "size": 110679, "w": 200}, "thumbnail_url": "mxc://matrix.org/gFrdGIZbVATfwziAHnIYwEuh", "w": 200}, "msgtype": "m.sticker", "url": "mxc://matrix.org/gFrdGIZbVATfwziAHnIYwEuh", "id": "gFrdGIZbVATfwziAHnIYwEuh"}, {"body": "Hungry", "info": {"h": 183, "mimetype": "image/png", "size": 97642, "thumbnail_info": {"h": 183, "mimetype": "image/png", "size": 97642, "w": 200}, "thumbnail_url": "mxc://matrix.org/LWtWooRvIbhgLjQPPtyhWNgP", "w": 200}, "msgtype": "m.sticker", "url": "mxc://matrix.org/LWtWooRvIbhgLjQPPtyhWNgP", "id": "LWtWooRvIbhgLjQPPtyhWNgP"}, {"body": "Innocent", "info": {"h": 200, "mimetype": "image/png", "size": 107331, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 107331, "w": 186}, "thumbnail_url": "mxc://matrix.org/IItfiFhKoPieFyPLceBLcFhd", "w": 186}, "msgtype": "m.sticker", "url": "mxc://matrix.org/IItfiFhKoPieFyPLceBLcFhd", "id": "IItfiFhKoPieFyPLceBLcFhd"}, {"body": "Laughing", "info": {"h": 200, "mimetype": "image/png", "size": 118620, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 118620, "w": 194}, "thumbnail_url": "mxc://matrix.org/LxEPZAsPAfjyRfAwpYSoIxwV", "w": 194}, "msgtype": "m.sticker", "url": "mxc://matrix.org/LxEPZAsPAfjyRfAwpYSoIxwV", "id": "LxEPZAsPAfjyRfAwpYSoIxwV"}, {"body": "Sad", "info": {"h": 200, "mimetype": "image/png", "size": 104622, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 104622, "w": 177}, "thumbnail_url": "mxc://matrix.org/MjdsQxPFskrLFQfXHFuJrwbr", "w": 177}, "msgtype": "m.sticker", "url": "mxc://matrix.org/MjdsQxPFskrLFQfXHFuJrwbr", "id": "MjdsQxPFskrLFQfXHFuJrwbr"}, {"body": "Sleepy", "info": {"h": 200, "mimetype": "image/png", "size": 116609, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 116609, "w": 196}, "thumbnail_url": "mxc://matrix.org/iqEhhOuswzPITADcrmQZPxbh", "w": 196}, "msgtype": "m.sticker", "url": "mxc://matrix.org/iqEhhOuswzPITADcrmQZPxbh", "id": "iqEhhOuswzPITADcrmQZPxbh"}, {"body": "Thank-you", "info": {"h": 200, "mimetype": "image/png", "size": 109865, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 109865, "w": 160}, "thumbnail_url": "mxc://matrix.org/qnyciftjKPqVDyEIjcakwCUO", "w": 160}, "msgtype": "m.sticker", "url": "mxc://matrix.org/qnyciftjKPqVDyEIjcakwCUO", "id": "qnyciftjKPqVDyEIjcakwCUO"}, {"body": "Thumb-up", "info": {"h": 200, "mimetype": "image/png", "size": 120744, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 120744, "w": 184}, "thumbnail_url": "mxc://matrix.org/xHCAaOqwMjyJYQMHIFIgeryn", "w": 184}, "msgtype": "m.sticker", "url": "mxc://matrix.org/xHCAaOqwMjyJYQMHIFIgeryn", "id": "xHCAaOqwMjyJYQMHIFIgeryn"}]}
|
1
web/packs/scalar-smilies.json
Normal file
1
web/packs/scalar-smilies.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"title": "Smilies", "id": "scalar-192094", "stickers": [{"body": "I'm really angry!", "info": {"h": 256, "mimetype": "image/png", "size": 20840, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 20840, "w": 256}, "thumbnail_url": "mxc://matrix.org/vjgWJdgaAdPLYJMsAjbJrOIa", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/vjgWJdgaAdPLYJMsAjbJrOIa", "id": "vjgWJdgaAdPLYJMsAjbJrOIa"}, {"body": "I'm dead tired", "info": {"h": 256, "mimetype": "image/png", "size": 20143, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 20143, "w": 256}, "thumbnail_url": "mxc://matrix.org/GAZUrYmcYRtcNofjGGqAWhqI", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/GAZUrYmcYRtcNofjGGqAWhqI", "id": "GAZUrYmcYRtcNofjGGqAWhqI"}, {"body": "I'm really happy", "info": {"h": 256, "mimetype": "image/png", "size": 20509, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 20509, "w": 256}, "thumbnail_url": "mxc://matrix.org/SthCvLTenNJopFCEzEeZEwJy", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/SthCvLTenNJopFCEzEeZEwJy", "id": "SthCvLTenNJopFCEzEeZEwJy"}, {"body": "Friday I'm in love!", "info": {"h": 256, "mimetype": "image/png", "size": 20726, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 20726, "w": 256}, "thumbnail_url": "mxc://matrix.org/sCpzSdxGKNVTyyaaTtDvKIVw", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/sCpzSdxGKNVTyyaaTtDvKIVw", "id": "sCpzSdxGKNVTyyaaTtDvKIVw"}, {"body": "Show me the money!", "info": {"h": 256, "mimetype": "image/png", "size": 20852, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 20852, "w": 256}, "thumbnail_url": "mxc://matrix.org/RPsEdZjVSCdxklObGMzyeUBm", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/RPsEdZjVSCdxklObGMzyeUBm", "id": "RPsEdZjVSCdxklObGMzyeUBm"}, {"body": "I'm just sad", "info": {"h": 256, "mimetype": "image/png", "size": 22825, "thumbnail_info": {"h": 256, "mimetype": "image/png", "size": 22825, "w": 256}, "thumbnail_url": "mxc://matrix.org/RseXEsYHhkmmiGCzKYDuFyZt", "w": 256}, "msgtype": "m.sticker", "url": "mxc://matrix.org/RseXEsYHhkmmiGCzKYDuFyZt", "id": "RseXEsYHhkmmiGCzKYDuFyZt"}]}
|
1
web/packs/scalar-stakey.json
Normal file
1
web/packs/scalar-stakey.json
Normal file
File diff suppressed because one or more lines are too long
1
web/packs/scalar-stickman.json
Normal file
1
web/packs/scalar-stickman.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"title": "Stickman", "id": "scalar-192096", "stickers": [{"body": "A hastily-rendered stick figure stares at you blankly. Its arms are folded: perhaps defensively, perhaps in a half-hearted Gangnam Style.", "info": {"h": 200, "mimetype": "image/png", "size": 28154, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 28154, "w": 106}, "thumbnail_url": "mxc://matrix.org/bQGEpjrZQcWLgygXmuaJCNNA", "w": 106}, "msgtype": "m.sticker", "url": "mxc://matrix.org/bQGEpjrZQcWLgygXmuaJCNNA", "id": "bQGEpjrZQcWLgygXmuaJCNNA"}, {"body": "Question marks of varying sizes orbit a stick figure's head.", "info": {"h": 194, "mimetype": "image/png", "size": 95200, "thumbnail_info": {"h": 194, "mimetype": "image/png", "size": 95200, "w": 200}, "thumbnail_url": "mxc://matrix.org/aVdcZtGRijWluoSjCAytBHnP", "w": 200}, "msgtype": "m.sticker", "url": "mxc://matrix.org/aVdcZtGRijWluoSjCAytBHnP", "id": "aVdcZtGRijWluoSjCAytBHnP"}, {"body": "A hastily-rendered stick figure stands with arms outstretched, smiling, beneath the word 'HOORAY' in an arc above its head. The figure is smiling in celebration.", "info": {"h": 200, "mimetype": "image/png", "size": 27199, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 27199, "w": 142}, "thumbnail_url": "mxc://matrix.org/BMcDXCuQjoAaWvlPBlUjXBNa", "w": 142}, "msgtype": "m.sticker", "url": "mxc://matrix.org/BMcDXCuQjoAaWvlPBlUjXBNa", "id": "BMcDXCuQjoAaWvlPBlUjXBNa"}, {"body": "A hastily-rendered stick figure stands with arms in the air beneath three blue-and-white juggling balls apparently in motion. We cannot tell whether the figure is juggling competently or has simply thrown all three balls into the air and is awaiting the inevitable. The figure's mouth is formed into an enigmatic 'o'.", "info": {"h": 200, "mimetype": "image/png", "size": 30170, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 30170, "w": 88}, "thumbnail_url": "mxc://matrix.org/mQEotjwsEKeZivqIfZjxNfgC", "w": 88}, "msgtype": "m.sticker", "url": "mxc://matrix.org/mQEotjwsEKeZivqIfZjxNfgC", "id": "mQEotjwsEKeZivqIfZjxNfgC"}, {"body": "A hastily-rendered stick figure is thinking about lunch. Shouldn't you be thinking about lunch?", "info": {"h": 187, "mimetype": "image/png", "size": 55105, "thumbnail_info": {"h": 187, "mimetype": "image/png", "size": 55105, "w": 200}, "thumbnail_url": "mxc://matrix.org/ZHGncPEBowOpxqbVYCGbBTff", "w": 200}, "msgtype": "m.sticker", "url": "mxc://matrix.org/ZHGncPEBowOpxqbVYCGbBTff", "id": "ZHGncPEBowOpxqbVYCGbBTff"}, {"body": "A hastily-rendered stick figure stands with arms outstretched, smiling.", "info": {"h": 200, "mimetype": "image/png", "size": 26585, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 26585, "w": 138}, "thumbnail_url": "mxc://matrix.org/FGCzIxIKpswOIJCWZUWlCoKi", "w": 138}, "msgtype": "m.sticker", "url": "mxc://matrix.org/FGCzIxIKpswOIJCWZUWlCoKi", "id": "FGCzIxIKpswOIJCWZUWlCoKi"}, {"body": "A hastily-rendered stick figure stands holding a placard which reads 'I HAVE OPINIONS'. The figure's mouth is wide and angry, suggesting said opinions might not be the same as yours.", "info": {"h": 200, "mimetype": "image/png", "size": 45505, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 45505, "w": 139}, "thumbnail_url": "mxc://matrix.org/eSdUNjchqskXmCOiLgjqsakm", "w": 139}, "msgtype": "m.sticker", "url": "mxc://matrix.org/eSdUNjchqskXmCOiLgjqsakm", "id": "eSdUNjchqskXmCOiLgjqsakm"}, {"body": "A hastily-rendered stick figure stands with arms in the air. The figure's mouth is formed into an enigmatic 'o'.", "info": {"h": 200, "mimetype": "image/png", "size": 25059, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 25059, "w": 132}, "thumbnail_url": "mxc://matrix.org/puRSMGiaBfdAwYzfdHQFiJMJ", "w": 132}, "msgtype": "m.sticker", "url": "mxc://matrix.org/puRSMGiaBfdAwYzfdHQFiJMJ", "id": "puRSMGiaBfdAwYzfdHQFiJMJ"}, {"body": "A hastily-rendered stick figure has put on its robe and wizard hat.", "info": {"h": 200, "mimetype": "image/png", "size": 43579, "thumbnail_info": {"h": 200, "mimetype": "image/png", "size": 43579, "w": 108}, "thumbnail_url": "mxc://matrix.org/aplWcQPboleenDWMurAdHpHb", "w": 108}, "msgtype": "m.sticker", "url": "mxc://matrix.org/aplWcQPboleenDWMurAdHpHb", "id": "aplWcQPboleenDWMurAdHpHb"}]}
|
1
web/packs/scalar-videoplasty.json
Normal file
1
web/packs/scalar-videoplasty.json
Normal file
File diff suppressed because one or more lines are too long
1
web/res/search.svg
Normal file
1
web/res/search.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M9.145 18.29c-5.042 0-9.145-4.102-9.145-9.145s4.103-9.145 9.145-9.145 9.145 4.103 9.145 9.145-4.102 9.145-9.145 9.145zm0-15.167c-3.321 0-6.022 2.702-6.022 6.022s2.702 6.022 6.022 6.022 6.023-2.702 6.023-6.022-2.702-6.022-6.023-6.022zm9.263 12.443c-.817 1.176-1.852 2.188-3.046 2.981l5.452 5.453 3.014-3.013-5.42-5.421z"/></svg>
|
After Width: | Height: | Size: 419 B |
@@ -1,23 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no">
|
|
||||||
<title>Setup - Maunium sticker picker</title>
|
|
||||||
|
|
||||||
<link rel="modulepreload" href="../lib/htm/preact.js"/>
|
|
||||||
<link rel="modulepreload" href="../lib/preact/hooks.js"/>
|
|
||||||
<link rel="modulepreload" href="src/Spinner.js"/>
|
|
||||||
<link rel="modulepreload" href="src/Button.js"/>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="../style/setup.css"/>
|
|
||||||
<link rel="stylesheet" href="../style/setup-login.css"/>
|
|
||||||
<link rel="stylesheet" href="../style/spinner.css"/>
|
|
||||||
<link rel="stylesheet" href="../style/button.css"/>
|
|
||||||
<script src="../src/setup/index.js" type="module"></script>
|
|
||||||
<script nomodule>document.body.innerText = "This setup page requires modern JavaScript"</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>This setup page requires JavaScript</noscript>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@@ -1,30 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
import { html } from "../lib/htm/preact.js"
|
|
||||||
|
|
||||||
const Button = ({
|
|
||||||
type = "button", class: className = "", children,
|
|
||||||
variant = "filled", size = "normal",
|
|
||||||
...customProps
|
|
||||||
}) => {
|
|
||||||
const props = {
|
|
||||||
class: `mau-button variant-${variant} size-${size} ${className}`,
|
|
||||||
type, ...customProps,
|
|
||||||
}
|
|
||||||
return html`<button ...${props}>${children}</button>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Button
|
|
483
web/src/index.js
Normal file
483
web/src/index.js
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import { html, render, Component } from "../lib/htm/preact.js"
|
||||||
|
import { Spinner } from "./spinner.js"
|
||||||
|
import { SearchBox } from "./search-box.js"
|
||||||
|
import * as widgetAPI from "./widget-api.js"
|
||||||
|
import * as frequent from "./frequently-used.js"
|
||||||
|
// import GiphyAPI from "./GiphySearch.js"
|
||||||
|
|
||||||
|
// The base URL for fetching packs. The app will first fetch ${PACK_BASE_URL}/index.json,
|
||||||
|
// then ${PACK_BASE_URL}/${packFile} for each packFile in the packs object of the index.json file.
|
||||||
|
const PACKS_BASE_URL = "packs"
|
||||||
|
|
||||||
|
let INDEX = `${PACKS_BASE_URL}/index.json`
|
||||||
|
const params = new URLSearchParams(document.location.search)
|
||||||
|
if (params.has('config')) {
|
||||||
|
INDEX = params.get("config")
|
||||||
|
}
|
||||||
|
// This is updated from packs/index.json
|
||||||
|
let HOMESERVER_URL = "https://matrix-client.matrix.org"
|
||||||
|
let GIPHY_API_KEY = ""
|
||||||
|
|
||||||
|
const makeThumbnailURL = mxc => `${HOMESERVER_URL}/_matrix/media/r0/thumbnail/${mxc.substr(6)}?height=128&width=128&method=scale`
|
||||||
|
|
||||||
|
// We need to detect iOS webkit because it has a bug related to scrolling non-fixed divs
|
||||||
|
// This is also used to fix scrolling to sections on Element iOS
|
||||||
|
const isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/)
|
||||||
|
|
||||||
|
const supportedThemes = ["light", "dark", "black"]
|
||||||
|
|
||||||
|
|
||||||
|
const defaultState = {
|
||||||
|
packs: [],
|
||||||
|
filtering: {
|
||||||
|
searchTerm: "",
|
||||||
|
packs: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
class GiphySearchTab extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
searchTerm: "",
|
||||||
|
gifs: [],
|
||||||
|
loading: false,
|
||||||
|
GIFById: {},
|
||||||
|
};
|
||||||
|
this.handleSearchChange = this.handleSearchChange.bind(this);
|
||||||
|
this.searchGifs = this.searchGifs.bind(this);
|
||||||
|
this.handleGifClick = this.handleGifClick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchGifs() {
|
||||||
|
this.setState({ loading: true });
|
||||||
|
try {
|
||||||
|
// const apiKey = "Gc7131jiJuvI7IdN0HZ1D7nh0ow5BU6g";
|
||||||
|
const apiKey = GIPHY_API_KEY;
|
||||||
|
const url = `https://api.giphy.com/v1/gifs/search?q=${this.state.searchTerm}&api_key=${apiKey}`;
|
||||||
|
this.setState({ loading: true });
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
this.setState({ gifs: data.data, loading: false });
|
||||||
|
data.data.forEach((jsonElement) => {
|
||||||
|
const id = jsonElement.id;
|
||||||
|
const updatedItem = {
|
||||||
|
"body": jsonElement.title,
|
||||||
|
"info": {
|
||||||
|
"h": jsonElement.images.original.height,
|
||||||
|
"w": jsonElement.images.original.width,
|
||||||
|
"size": jsonElement.images.original.size,
|
||||||
|
"mimetype": "image/gif",
|
||||||
|
"thumbnail_info": {
|
||||||
|
"h": jsonElement.images.fixed_width_still.height,
|
||||||
|
"mimetype": "image/jpg",
|
||||||
|
"size": jsonElement.images.fixed_width_still.size,
|
||||||
|
"w": jsonElement.images.fixed_width_still.width
|
||||||
|
},
|
||||||
|
"thumbnail_url": jsonElement.images.fixed_width_still.url
|
||||||
|
},
|
||||||
|
"msgtype": "m.image",
|
||||||
|
"url": jsonElement.images.original.url
|
||||||
|
};
|
||||||
|
this.setState((prevState) => ({
|
||||||
|
GIFById: {...prevState.GIFById, [id]: updatedItem}}));
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.setState({ error: "Error fetching GIFs", loading: false });
|
||||||
|
this.setState({ loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSearchChange(event) {
|
||||||
|
this.setState({ searchTerm: event.target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGifClick(gif) {
|
||||||
|
console.log(this.state.GIFById[gif.id]);
|
||||||
|
widgetAPI.sendGIF(this.state.GIFById[gif.id]);
|
||||||
|
}
|
||||||
|
async searchGiphy(searchTerm) {
|
||||||
|
if (!searchTerm) return;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { searchTerm, gifs, loading } = this.state;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="search-box">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value=${searchTerm}
|
||||||
|
onInput=${this.handleSearchChange}
|
||||||
|
placeholder="Search GIFs..."
|
||||||
|
/>
|
||||||
|
<button onClick=${this.searchGifs} disabled=${loading}>Search</button>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="gifs-list" style="display: grid"> -->
|
||||||
|
<div class="pack-list">
|
||||||
|
<section class="stickerpack">
|
||||||
|
<div class="sticker-list">
|
||||||
|
${GIPHY_API_KEY !== "" && gifs.map((gif) => html`
|
||||||
|
<div class="sticker" onClick=${() => this.handleGifClick(gif)} data-gif-id=${gif.id}>
|
||||||
|
<img src=${gif.images.fixed_height.url} alt=${gif.title} class="visible" data=/>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class App extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.defaultTheme = params.get("theme")
|
||||||
|
this.state = {
|
||||||
|
activeTab: "stickers",
|
||||||
|
packs: defaultState.packs,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
stickersPerRow: parseInt(localStorage.mauStickersPerRow || "4"),
|
||||||
|
theme: localStorage.mauStickerThemeOverride || this.defaultTheme,
|
||||||
|
frequentlyUsed: {
|
||||||
|
id: "frequently-used",
|
||||||
|
title: "Frequently used",
|
||||||
|
stickerIDs: frequent.get(),
|
||||||
|
stickers: [],
|
||||||
|
},
|
||||||
|
filtering: defaultState.filtering,
|
||||||
|
}
|
||||||
|
if (!supportedThemes.includes(this.state.theme)) {
|
||||||
|
this.state.theme = "light"
|
||||||
|
}
|
||||||
|
if (!supportedThemes.includes(this.defaultTheme)) {
|
||||||
|
this.defaultTheme = "light"
|
||||||
|
}
|
||||||
|
this.stickersByID = new Map(JSON.parse(localStorage.mauFrequentlyUsedStickerCache || "[]"))
|
||||||
|
this.state.frequentlyUsed.stickers = this._getStickersByID(this.state.frequentlyUsed.stickerIDs)
|
||||||
|
this.imageObserver = null
|
||||||
|
this.packListRef = null
|
||||||
|
this.navRef = null
|
||||||
|
this.searchStickers = this.searchStickers.bind(this)
|
||||||
|
this.sendSticker = this.sendSticker.bind(this)
|
||||||
|
this.navScroll = this.navScroll.bind(this)
|
||||||
|
this.reloadPacks = this.reloadPacks.bind(this)
|
||||||
|
this.observeSectionIntersections = this.observeSectionIntersections.bind(this)
|
||||||
|
this.observeImageIntersections = this.observeImageIntersections.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
_getStickersByID(ids) {
|
||||||
|
return ids.map(id => this.stickersByID.get(id)).filter(sticker => !!sticker)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFrequentlyUsed() {
|
||||||
|
const stickerIDs = frequent.get()
|
||||||
|
const stickers = this._getStickersByID(stickerIDs)
|
||||||
|
this.setState({
|
||||||
|
frequentlyUsed: {
|
||||||
|
...this.state.frequentlyUsed,
|
||||||
|
stickerIDs,
|
||||||
|
stickers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(stickers.map(sticker => [sticker.id, sticker]))
|
||||||
|
}
|
||||||
|
|
||||||
|
searchStickers(e) {
|
||||||
|
const sanitizeString = s => s.toLowerCase().trim()
|
||||||
|
const searchTerm = sanitizeString(e.target.value)
|
||||||
|
|
||||||
|
const allPacks = [this.state.frequentlyUsed, ...this.state.packs]
|
||||||
|
const packsWithFilteredStickers = allPacks.map(pack => ({
|
||||||
|
...pack,
|
||||||
|
stickers: pack.stickers.filter(sticker =>
|
||||||
|
sanitizeString(sticker.body).includes(searchTerm) ||
|
||||||
|
sanitizeString(sticker.id).includes(searchTerm)
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
filtering: {
|
||||||
|
...this.state.filtering,
|
||||||
|
searchTerm,
|
||||||
|
packs: packsWithFilteredStickers.filter(({ stickers }) => !!stickers.length),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setStickersPerRow(val) {
|
||||||
|
localStorage.mauStickersPerRow = val
|
||||||
|
document.documentElement.style.setProperty("--stickers-per-row", localStorage.mauStickersPerRow)
|
||||||
|
this.setState({
|
||||||
|
stickersPerRow: val,
|
||||||
|
})
|
||||||
|
this.packListRef.scrollTop = this.packListRef.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme) {
|
||||||
|
if (theme === "default") {
|
||||||
|
delete localStorage.mauStickerThemeOverride
|
||||||
|
this.setState({ theme: this.defaultTheme })
|
||||||
|
} else {
|
||||||
|
localStorage.mauStickerThemeOverride = theme
|
||||||
|
this.setState({ theme: theme })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadPacks() {
|
||||||
|
this.imageObserver.disconnect()
|
||||||
|
this.sectionObserver.disconnect()
|
||||||
|
this.setState({
|
||||||
|
packs: defaultState.packs,
|
||||||
|
filtering: defaultState.filtering,
|
||||||
|
})
|
||||||
|
this._loadPacks(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadPacks(disableCache = false) {
|
||||||
|
const cache = disableCache ? "no-cache" : undefined
|
||||||
|
fetch(INDEX, { cache }).then(async indexRes => {
|
||||||
|
if (indexRes.status >= 400) {
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
error: indexRes.status !== 404 ? indexRes.statusText : null,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const indexData = await indexRes.json()
|
||||||
|
HOMESERVER_URL = indexData.homeserver_url || HOMESERVER_URL
|
||||||
|
GIPHY_API_KEY = indexData.giphy_api_key || ""
|
||||||
|
// TODO only load pack metadata when scrolled into view?
|
||||||
|
for (const packFile of indexData.packs) {
|
||||||
|
let packRes
|
||||||
|
if (packFile.startsWith("https://") || packFile.startsWith("http://")) {
|
||||||
|
packRes = await fetch(packFile, { cache })
|
||||||
|
} else {
|
||||||
|
packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, { cache })
|
||||||
|
}
|
||||||
|
const packData = await packRes.json()
|
||||||
|
for (const sticker of packData.stickers) {
|
||||||
|
this.stickersByID.set(sticker.id, sticker)
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
packs: [...this.state.packs, packData],
|
||||||
|
loading: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.updateFrequentlyUsed()
|
||||||
|
}, error => this.setState({ loading: false, error }))
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
document.documentElement.style.setProperty("--stickers-per-row", this.state.stickersPerRow.toString())
|
||||||
|
this._loadPacks()
|
||||||
|
this.imageObserver = new IntersectionObserver(this.observeImageIntersections, {
|
||||||
|
rootMargin: "100px",
|
||||||
|
})
|
||||||
|
this.sectionObserver = new IntersectionObserver(this.observeSectionIntersections)
|
||||||
|
}
|
||||||
|
|
||||||
|
observeImageIntersections(intersections) {
|
||||||
|
for (const entry of intersections) {
|
||||||
|
const img = entry.target.children.item(0)
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
img.setAttribute("src", img.getAttribute("data-src"))
|
||||||
|
img.classList.add("visible")
|
||||||
|
} else {
|
||||||
|
img.removeAttribute("src")
|
||||||
|
img.classList.remove("visible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
observeSectionIntersections(intersections) {
|
||||||
|
const navWidth = this.navRef.getBoundingClientRect().width
|
||||||
|
let minX = 0, maxX = navWidth
|
||||||
|
let minXElem = null
|
||||||
|
let maxXElem = null
|
||||||
|
for (const entry of intersections) {
|
||||||
|
const packID = entry.target.getAttribute("data-pack-id")
|
||||||
|
const navElement = document.getElementById(`nav-${packID}`)
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
navElement.classList.add("visible")
|
||||||
|
const bb = navElement.getBoundingClientRect()
|
||||||
|
if (bb.x < minX) {
|
||||||
|
minX = bb.x
|
||||||
|
minXElem = navElement
|
||||||
|
} else if (bb.right > maxX) {
|
||||||
|
maxX = bb.right
|
||||||
|
maxXElem = navElement
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navElement.classList.remove("visible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (minXElem !== null) {
|
||||||
|
minXElem.scrollIntoView({ inline: "start" })
|
||||||
|
} else if (maxXElem !== null) {
|
||||||
|
maxXElem.scrollIntoView({ inline: "end" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
if (this.packListRef === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const elem of this.packListRef.getElementsByClassName("sticker")) {
|
||||||
|
this.imageObserver.observe(elem)
|
||||||
|
}
|
||||||
|
for (const elem of this.packListRef.children) {
|
||||||
|
this.sectionObserver.observe(elem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.imageObserver.disconnect()
|
||||||
|
this.sectionObserver.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSticker(evt) {
|
||||||
|
const id = evt.currentTarget.getAttribute("data-sticker-id")
|
||||||
|
const sticker = this.stickersByID.get(id)
|
||||||
|
frequent.add(id)
|
||||||
|
this.updateFrequentlyUsed()
|
||||||
|
widgetAPI.sendSticker(sticker)
|
||||||
|
}
|
||||||
|
|
||||||
|
navScroll(evt) {
|
||||||
|
this.navRef.scrollLeft += evt.deltaY
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const theme = `theme-${this.state.theme}`;
|
||||||
|
const filterActive = !!this.state.filtering.searchTerm;
|
||||||
|
const packs = filterActive
|
||||||
|
? this.state.filtering.packs
|
||||||
|
: [this.state.frequentlyUsed, ...this.state.packs];
|
||||||
|
|
||||||
|
if (this.state.loading) {
|
||||||
|
return html`<main class="spinner ${theme}"><${Spinner} size=${80} green /></main>`;
|
||||||
|
} else if (this.state.error) {
|
||||||
|
return html`<main class="error ${theme}">
|
||||||
|
<h1>Failed to load packs</h1>
|
||||||
|
<p>${this.state.error}</p>
|
||||||
|
</main>`;
|
||||||
|
} else if (this.state.packs.length === 0) {
|
||||||
|
return html`<main class="empty ${theme}"><h1>No packs found 😿</h1></main>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<main class="has-content ${theme}">
|
||||||
|
<div class="tab-container" style="display: flex;">
|
||||||
|
<a href="#stickers" class="tab" onClick=${() => this.setState({ activeTab: "stickers" })}>Stickers</a>
|
||||||
|
<a href="#gifs" class="tab" onClick=${() => this.setState({ activeTab: "gifs" })}>GIFs</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.state.activeTab === "stickers" && html`
|
||||||
|
<nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}>
|
||||||
|
<${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" />
|
||||||
|
${this.state.packs.map(pack => html`<${NavBarItem} id=${pack.id} pack=${pack}/>`)}
|
||||||
|
<${NavBarItem} pack=${{ id: "settings", title: "Settings" }} iconOverride="settings" />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<${SearchBox} onKeyUp=${this.searchStickers} />
|
||||||
|
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${(elem) => (this.packListRef = elem)}>
|
||||||
|
${filterActive && packs.length === 0
|
||||||
|
? html`<div class="search-empty"><h1>No stickers match your search</h1></div>`
|
||||||
|
: null}
|
||||||
|
${packs.map((pack) => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker} />`)}
|
||||||
|
<${Settings} app=${this} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
`}
|
||||||
|
${this.state.activeTab === "gifs" && GIPHY_API_KEY !== "" && html`
|
||||||
|
<${GiphySearchTab} send=${this.sendGIF} />
|
||||||
|
`}
|
||||||
|
${this.state.activeTab === "gifs" && GIPHY_API_KEY === "" && html`
|
||||||
|
<h1><center>GIF Search is not enabled. Please enable it in the config.</center></h1>
|
||||||
|
`}
|
||||||
|
</main>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Settings = ({ app }) => html`
|
||||||
|
<section class="stickerpack settings" id="pack-settings" data-pack-id="settings">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<div class="settings-list">
|
||||||
|
<button onClick=${app.reloadPacks}>Reload</button>
|
||||||
|
<div>
|
||||||
|
<label for="stickers-per-row">Stickers per row: ${app.state.stickersPerRow}</label>
|
||||||
|
<input type="range" min=2 max=10 id="stickers-per-row" id="stickers-per-row"
|
||||||
|
value=${app.state.stickersPerRow}
|
||||||
|
onInput=${evt => app.setStickersPerRow(evt.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="theme">Theme: </label>
|
||||||
|
<select name="theme" id="theme" onChange=${evt => app.setTheme(evt.target.value)}>
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="black">Black</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
|
||||||
|
// By default we just let the browser handle scrolling to sections, but webviews on Element iOS
|
||||||
|
// open the link in the browser instead of just scrolling there, so we need to scroll manually:
|
||||||
|
const scrollToSection = (evt, id) => {
|
||||||
|
const pack = document.getElementById(`pack-${id}`)
|
||||||
|
pack.scrollIntoView({ block: "start", behavior: "instant" })
|
||||||
|
evt.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavBarItem = ({ pack, iconOverride = null }) => html`
|
||||||
|
<a href="#pack-${pack.id}" id="nav-${pack.id}" data-pack-id=${pack.id} title=${pack.title}
|
||||||
|
onClick=${isMobileSafari ? (evt => scrollToSection(evt, pack.id)) : undefined}>
|
||||||
|
<div class="sticker">
|
||||||
|
${iconOverride ? html`
|
||||||
|
<span class="icon icon-${iconOverride}"/>
|
||||||
|
` : html`
|
||||||
|
<img src=${makeThumbnailURL(pack.stickers[0].url)}
|
||||||
|
alt=${pack.stickers[0].body} class="visible" />
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
|
||||||
|
const Pack = ({ pack, send }) => html`
|
||||||
|
<section class="stickerpack" id="pack-${pack.id}" data-pack-id=${pack.id}>
|
||||||
|
<h1>${pack.title}</h1>
|
||||||
|
<div class="sticker-list">
|
||||||
|
${pack.stickers.map(sticker => html`
|
||||||
|
<${Sticker} key=${sticker.id} content=${sticker} send=${send}/>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
|
||||||
|
const Sticker = ({ content, send }) => html`
|
||||||
|
<div class="sticker" onClick=${send} data-sticker-id=${content.id}>
|
||||||
|
<img data-src=${makeThumbnailURL(content.url)} alt=${content.body} title=${content.body} />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
render(html`<${App} />`, document.body)
|
@@ -13,6 +13,14 @@
|
|||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import { html } from "../lib/htm/preact.js"
|
||||||
|
|
||||||
body
|
export const SearchBox = ({ onKeyUp, placeholder = 'Find stickers' }) => {
|
||||||
font-family: sans-serif
|
const component = html`
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" placeholder=${placeholder} onKeyUp=${onKeyUp} />
|
||||||
|
<span class="icon icon-search" />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
return component
|
||||||
|
}
|
@@ -1,73 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
import { useEffect, useState } from "../../lib/preact/hooks.js"
|
|
||||||
import { html } from "../../lib/htm/preact.js"
|
|
||||||
|
|
||||||
import LoginView from "./LoginView.js"
|
|
||||||
import Spinner from "../Spinner.js"
|
|
||||||
import * as matrix from "./matrix-api.js"
|
|
||||||
import * as sticker from "./sticker-api.js"
|
|
||||||
|
|
||||||
const App = () => {
|
|
||||||
const [loggedIn, setLoggedIn] = useState(Boolean(localStorage.mxAccessToken))
|
|
||||||
const [widgetSecret, setWidgetSecret] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
if (!loggedIn) {
|
|
||||||
return html`
|
|
||||||
<${LoginView}
|
|
||||||
onLoggedIn=${() => setLoggedIn(Boolean(localStorage.mxAccessToken))}
|
|
||||||
/>`
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (widgetSecret === null) {
|
|
||||||
setLoading(true)
|
|
||||||
const whoamiReceived = data => {
|
|
||||||
setLoading(false)
|
|
||||||
setWidgetSecret(data.widget_secret)
|
|
||||||
}
|
|
||||||
const reauth = async () => {
|
|
||||||
const openIDToken = await matrix.requestOpenIDToken(
|
|
||||||
localStorage.mxHomeserver, localStorage.mxUserID, localStorage.mxAccessToken)
|
|
||||||
const integrationData = await matrix.requestIntegrationToken(openIDToken)
|
|
||||||
localStorage.stickerSetupAccessToken = integrationData.token
|
|
||||||
return await sticker.whoami()
|
|
||||||
}
|
|
||||||
const whoamiErrored = err => {
|
|
||||||
console.error("Setup API whoami returned", err)
|
|
||||||
if (err.code === "NET.MAUNIUM_TOKEN_EXPIRED" || err.code === "M_UNKNOWN_TOKEN") {
|
|
||||||
return reauth().then(whoamiReceived)
|
|
||||||
} else {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sticker.whoami().then(whoamiReceived, whoamiErrored).catch(err => {
|
|
||||||
setLoading(false)
|
|
||||||
setError(err.message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return html`<${Spinner} size=80 green />`
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`${widgetSecret}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
@@ -1,207 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "../../lib/preact/hooks.js"
|
|
||||||
import { html } from "../../lib/htm/preact.js"
|
|
||||||
|
|
||||||
import * as matrix from "./matrix-api.js"
|
|
||||||
import Button from "../Button.js"
|
|
||||||
import Spinner from "../Spinner.js"
|
|
||||||
|
|
||||||
const query = Object.fromEntries(location.search
|
|
||||||
.substr(1).split("&")
|
|
||||||
.map(part => part.split("="))
|
|
||||||
.map(([key, value = ""]) => [key, value]))
|
|
||||||
|
|
||||||
const LoginView = ({ onLoggedIn }) => {
|
|
||||||
const usernameWrapperRef = useRef()
|
|
||||||
const usernameRef = useRef()
|
|
||||||
const serverRef = useRef()
|
|
||||||
const passwordRef = useRef()
|
|
||||||
const previousServerValue = useRef()
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [userIDFocused, setUserIDFocused] = useState(false)
|
|
||||||
const [supportedFlows, setSupportedFlows] = useState(["m.login.password"])
|
|
||||||
const [username, setUsername] = useState("")
|
|
||||||
const [server, setServer] = useState("")
|
|
||||||
const [serverURL, setServerURL] = useState("")
|
|
||||||
const [password, setPassword] = useState("")
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
const keyDown = evt => {
|
|
||||||
if ((evt.target.name === "username" && evt.key === ":") || evt.key === "Enter") {
|
|
||||||
if (evt.target.name === "username") {
|
|
||||||
serverRef.current.focus()
|
|
||||||
} else if (evt.target.name === "server") {
|
|
||||||
passwordRef.current.focus()
|
|
||||||
}
|
|
||||||
evt.preventDefault()
|
|
||||||
} else if (evt.target.name === "server" && !evt.target.value && evt.key === "Backspace") {
|
|
||||||
usernameRef.current.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const paste = evt => {
|
|
||||||
if (usernameRef.current.value !== "" || serverRef.current.value !== "") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = evt.clipboardData.getData("text")
|
|
||||||
if (data.startsWith("@")) {
|
|
||||||
data = data.substr(1)
|
|
||||||
}
|
|
||||||
const separator = data.indexOf(":")
|
|
||||||
if (separator === -1) {
|
|
||||||
setUsername(data)
|
|
||||||
} else {
|
|
||||||
setUsername(data.substr(0, separator))
|
|
||||||
setServer(data.substr(separator + 1))
|
|
||||||
serverRef.current.focus()
|
|
||||||
}
|
|
||||||
evt.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
useLayoutEffect(() => usernameRef.current.focus(), [])
|
|
||||||
const onFocus = () => setUserIDFocused(true)
|
|
||||||
const onBlur = () => {
|
|
||||||
setUserIDFocused(false)
|
|
||||||
if (previousServerValue.current !== server && server) {
|
|
||||||
previousServerValue.current = server
|
|
||||||
setSupportedFlows(null)
|
|
||||||
setError(null)
|
|
||||||
matrix.resolveWellKnown(server).then(url => {
|
|
||||||
setServerURL(url)
|
|
||||||
localStorage.mxServerName = server
|
|
||||||
localStorage.mxHomeserver = url
|
|
||||||
return matrix.getLoginFlows(url)
|
|
||||||
}).then(flows => {
|
|
||||||
setSupportedFlows(flows)
|
|
||||||
}).catch(err => {
|
|
||||||
setError(err.message)
|
|
||||||
setSupportedFlows(["m.login.password"])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (localStorage.mxHomeserver && query.loginToken) {
|
|
||||||
console.log("Found homeserver in localstorage and loginToken in query, " +
|
|
||||||
"attempting SSO token login")
|
|
||||||
setError(null)
|
|
||||||
setLoading(true)
|
|
||||||
submit(query.loginToken, localStorage.mxHomeserver)
|
|
||||||
.catch(err => console.error("Fatal error:", err))
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
const url = new URL(location.href)
|
|
||||||
url.searchParams.delete("loginToken")
|
|
||||||
history.replaceState({}, document.title, url.toString())
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const submit = async (token, serverURLOverride) => {
|
|
||||||
let authInfo
|
|
||||||
if (token) {
|
|
||||||
authInfo = {
|
|
||||||
type: "m.login.token",
|
|
||||||
token,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
authInfo = {
|
|
||||||
type: "m.login.password",
|
|
||||||
identifier: {
|
|
||||||
type: "m.id.user",
|
|
||||||
user: username,
|
|
||||||
},
|
|
||||||
password,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const actualServerURL = serverURLOverride || serverURL
|
|
||||||
const [accessToken, userID, realURL] = await matrix.login(actualServerURL, authInfo)
|
|
||||||
const openIDToken = await matrix.requestOpenIDToken(realURL, userID, accessToken)
|
|
||||||
const integrationData = await matrix.requestIntegrationToken(openIDToken)
|
|
||||||
localStorage.mxHomeserver = realURL
|
|
||||||
localStorage.mxAccessToken = accessToken
|
|
||||||
localStorage.mxUserID = userID
|
|
||||||
localStorage.stickerSetupAccessToken = integrationData.token
|
|
||||||
onLoggedIn()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmit = evt => {
|
|
||||||
evt.preventDefault()
|
|
||||||
setError(null)
|
|
||||||
setLoading(true)
|
|
||||||
submit()
|
|
||||||
.catch(err => console.error("Fatal error:", err))
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
const startSSOLogin = () => {
|
|
||||||
const redir = encodeURIComponent(location.href)
|
|
||||||
location.href = `${serverURL}/_matrix/client/r0/login/sso/redirect?redirectUrl=${redir}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const usernameWrapperClick = evt => evt.target === usernameWrapperRef.current
|
|
||||||
&& usernameRef.current.focus()
|
|
||||||
|
|
||||||
const ssoButton = html`
|
|
||||||
<${Button} type="button" disabled=${!serverURL} onClick=${startSSOLogin}
|
|
||||||
title=${!serverURL ? "Enter your server name before using SSO" : undefined}>
|
|
||||||
${loading ? html`<${Spinner} size=30/>` : "Log in with SSO"}
|
|
||||||
</Button>
|
|
||||||
`
|
|
||||||
|
|
||||||
const disablePwLogin = !username || !server || !serverURL
|
|
||||||
const loginButton = html`
|
|
||||||
<${Button} type="submit" disabled=${disablePwLogin}
|
|
||||||
title=${disablePwLogin ? "Fill out the form before submitting" : undefined}>
|
|
||||||
${loading ? html`<${Spinner} size=30/>` : "Log in"}
|
|
||||||
</Button>
|
|
||||||
`
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<main class="login-view">
|
|
||||||
<form class="login-box ${error ? "has-error" : ""}" onSubmit=${onSubmit}>
|
|
||||||
<h1>Stickerpicker setup</h1>
|
|
||||||
<div class="username input ${userIDFocused ? "focus" : ""}"
|
|
||||||
ref=${usernameWrapperRef} onClick=${usernameWrapperClick}>
|
|
||||||
<span onClick=${() => usernameRef.current.focus()}>@</span>
|
|
||||||
<input type="text" placeholder="username" name="username" value=${username}
|
|
||||||
onChange=${evt => setUsername(evt.target.value)} ref=${usernameRef}
|
|
||||||
onKeyDown=${keyDown} onFocus=${onFocus} onBlur=${onBlur}
|
|
||||||
onPaste=${paste}/>
|
|
||||||
<span onClick=${() => serverRef.current.focus()}>:</span>
|
|
||||||
<input type="text" placeholder="example.com" name="server" value=${server}
|
|
||||||
onChange=${evt => setServer(evt.target.value)} ref=${serverRef}
|
|
||||||
onKeyDown=${keyDown} onFocus=${onFocus} onBlur=${onBlur}/>
|
|
||||||
</div>
|
|
||||||
<input type="password" placeholder="password" name="password" value=${password}
|
|
||||||
class="password input" ref=${passwordRef}
|
|
||||||
disabled=${supportedFlows && !supportedFlows.includes("m.login.password")}
|
|
||||||
onChange=${evt => setPassword(evt.target.value)}/>
|
|
||||||
<div class="button-group">
|
|
||||||
${supportedFlows === null && html`<${Spinner} green size=30 />`}
|
|
||||||
${supportedFlows?.includes("m.login.sso") && ssoButton}
|
|
||||||
${supportedFlows?.includes("m.login.password") && loginButton}
|
|
||||||
</div>
|
|
||||||
${error && html`<div class="error">${error}</div>`}
|
|
||||||
</form>
|
|
||||||
</main>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LoginView
|
|
@@ -1,20 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
import { html, render } from "../../lib/htm/preact.js"
|
|
||||||
|
|
||||||
import App from "./App.js"
|
|
||||||
|
|
||||||
render(html`<${App} />`, document.body)
|
|
@@ -1,112 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
import { tryFetch, integrationPrefix } from "./tryGet.js"
|
|
||||||
|
|
||||||
export const resolveWellKnown = async (server) => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`https://${server}/.well-known/matrix/client`)
|
|
||||||
const data = await resp.json()
|
|
||||||
let url = data["m.homeserver"].base_url
|
|
||||||
if (url.endsWith("/")) {
|
|
||||||
url = url.slice(0, -1)
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Resolution failed:", err)
|
|
||||||
throw new Error(`Failed to resolve Matrix URL for ${server}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getLoginFlows = async (address) => {
|
|
||||||
const data = await tryFetch(`${address}/_matrix/client/r0/login`, {},
|
|
||||||
{ service: address, requestType: "get login flows" })
|
|
||||||
const flows = []
|
|
||||||
for (const flow of data.flows) {
|
|
||||||
flows.push(flow.type)
|
|
||||||
}
|
|
||||||
return flows
|
|
||||||
}
|
|
||||||
|
|
||||||
export const login = async (address, authInfo) => {
|
|
||||||
const data = await tryFetch(`${address}/_matrix/client/r0/login`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
...authInfo,
|
|
||||||
/* eslint-disable camelcase */
|
|
||||||
device_id: "maunium-stickerpicker",
|
|
||||||
initial_device_display_name: "maunium-stickerpicker",
|
|
||||||
/* eslint-enable camelcase */
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
service: address,
|
|
||||||
requestType: "login",
|
|
||||||
})
|
|
||||||
if (data.well_known && data.well_known["m.homeserver"]) {
|
|
||||||
address = data.well_known["m.homeserver"].base_url || address
|
|
||||||
if (address.endsWith("/")) {
|
|
||||||
address = address.slice(0, -1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [data.access_token, data.user_id, address]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const whoami = (address, accessToken) => tryFetch(
|
|
||||||
`${address}/_matrix/client/r0/account/whoami`,
|
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: address,
|
|
||||||
requestType: "whoami",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const requestOpenIDToken = (address, userID, accessToken) => tryFetch(
|
|
||||||
`${address}/_matrix/client/r0/user/${userID}/openid/request_token`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: "{}",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: "OpenID",
|
|
||||||
requestType: "token",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const requestIntegrationToken = tokenData => tryFetch(
|
|
||||||
`${integrationPrefix}/account/register`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(tokenData),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
service: "sticker server",
|
|
||||||
requestType: "register",
|
|
||||||
})
|
|
||||||
|
|
||||||
export const logout = () => tryFetch(`${integrationPrefix}/account/logout`, { method: "POST" }, {
|
|
||||||
service: "sticker server",
|
|
||||||
requestType: "logout",
|
|
||||||
})
|
|
@@ -1,34 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import { tryFetch as tryFetchDefault, setupPrefix } from "./tryGet.js"
|
|
||||||
|
|
||||||
const service = "setup API"
|
|
||||||
|
|
||||||
const tryFetch = (url, options, reqInfo) => {
|
|
||||||
if (!options.headers?.Authorization) {
|
|
||||||
if (!options.headers) {
|
|
||||||
options.headers = {}
|
|
||||||
}
|
|
||||||
options.headers.Authorization = `Bearer ${localStorage.stickerSetupAccessToken}`
|
|
||||||
}
|
|
||||||
return tryFetchDefault(url, options, reqInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const whoami = () => tryFetch(
|
|
||||||
`${setupPrefix}/whoami`,
|
|
||||||
{}, { service, requestType: "whoami" },
|
|
||||||
)
|
|
@@ -1,73 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
export const integrationPrefix = "../_matrix/integrations/v1"
|
|
||||||
export const setupPrefix = "api"
|
|
||||||
|
|
||||||
export const queryToURL = (url, query) => {
|
|
||||||
if (!Array.isArray(query)) {
|
|
||||||
query = Object.entries(query)
|
|
||||||
}
|
|
||||||
query = query.map(([key, value]) =>
|
|
||||||
[key, typeof value === "string" ? value : JSON.stringify(value)])
|
|
||||||
url = `${url}?${new URLSearchParams(query)}`
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
class MatrixError extends Error {
|
|
||||||
constructor(data, status) {
|
|
||||||
super(data.error)
|
|
||||||
this.code = data.errcode
|
|
||||||
this.httpStatus = status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tryFetch = async (url, options, reqInfo) => {
|
|
||||||
if (options.query) {
|
|
||||||
url = queryToURL(url, options.query)
|
|
||||||
delete options.query
|
|
||||||
}
|
|
||||||
const reqName = `${reqInfo.service} ${reqInfo.requestType}`
|
|
||||||
let resp
|
|
||||||
try {
|
|
||||||
resp = await fetch(url, options)
|
|
||||||
} catch (err) {
|
|
||||||
console.error(reqName, "request failed:", err)
|
|
||||||
throw new Error(`Failed to contact ${reqInfo.service}`)
|
|
||||||
}
|
|
||||||
if (resp.status === 502) {
|
|
||||||
console.error("Unexpected", reqName, "request bad gateway:", await resp.text())
|
|
||||||
throw new Error(`Failed to contact ${reqInfo.service}`)
|
|
||||||
}
|
|
||||||
if (reqInfo.raw) {
|
|
||||||
return resp
|
|
||||||
} else if (resp.status === 204) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let data
|
|
||||||
try {
|
|
||||||
data = await resp.json()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(reqName, "request JSON parse failed:", err)
|
|
||||||
throw new Error(`Invalid response from ${reqInfo.service}`)
|
|
||||||
}
|
|
||||||
if (data.error && data.errcode) {
|
|
||||||
throw new MatrixError(data, resp.status)
|
|
||||||
} else if (resp.status >= 400) {
|
|
||||||
console.error("Unexpected", reqName, "request status:", resp.status, data)
|
|
||||||
throw new Error(data.error || data.message || `Invalid response from ${reqInfo.service}`)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
@@ -15,7 +15,7 @@
|
|||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { html } from "../lib/htm/preact.js"
|
import { html } from "../lib/htm/preact.js"
|
||||||
|
|
||||||
const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false }) => {
|
export const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false }) => {
|
||||||
let margin = 0
|
let margin = 0
|
||||||
if (!isNaN(+size)) {
|
if (!isNaN(+size)) {
|
||||||
size = +size
|
size = +size
|
||||||
@@ -39,5 +39,3 @@ const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false
|
|||||||
}
|
}
|
||||||
return comp
|
return comp
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Spinner
|
|
190
web/src/widget-api.js
Normal file
190
web/src/widget-api.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
||||||
|
// Copyright (C) 2020 Tulir Asokan
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const widgetId = urlParams.get('widgetId'); // if you know the widget ID, supply it.
|
||||||
|
console.log("Widget ID:"+widgetId);
|
||||||
|
const api = new mxwidgets.WidgetApi(widgetId, '*');
|
||||||
|
|
||||||
|
|
||||||
|
// Before doing anything else, request capabilities:
|
||||||
|
api.requestCapabilities(mxwidgets.StickerpickerCapabilities);
|
||||||
|
api.requestCapability(mxwidgets.MatrixCapabilities.MSC4039UploadFile);
|
||||||
|
|
||||||
|
api.on("ready", () => {console.log("ready event received")});
|
||||||
|
|
||||||
|
// Start the messaging
|
||||||
|
api.start();
|
||||||
|
|
||||||
|
// If waitForIframeLoad is false, tell the client that we're good to go
|
||||||
|
//api.sendContentLoaded();
|
||||||
|
|
||||||
|
export function sendSticker(content){
|
||||||
|
const data = {
|
||||||
|
content: {...content},
|
||||||
|
name: content.body,
|
||||||
|
};
|
||||||
|
// do the same thing that tulir does
|
||||||
|
delete data.content.id;
|
||||||
|
// send data
|
||||||
|
api.sendSticker(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
*export function sendGIF(content){
|
||||||
|
* // just print out content, should be URL
|
||||||
|
* console.log("Content:"+content.url);
|
||||||
|
* return new Promise((resolve, reject) => {
|
||||||
|
* const xhr = new XMLHttpRequest();
|
||||||
|
* xhr.open('GET', content.url, true);
|
||||||
|
* xhr.onreadystatechange = function() {
|
||||||
|
* if (xhr.readyState === 4) {
|
||||||
|
* if (xhr.status === 200) {
|
||||||
|
* const responseData = xhr.responseText;
|
||||||
|
* // Call uploadFile with response data
|
||||||
|
* api.uploadFile(responseData)
|
||||||
|
* .then(result => {
|
||||||
|
* console.log("Here's the result:"+result.content_uri);
|
||||||
|
* // mess around with the content object, then send it as sticker
|
||||||
|
* content.url = result.content_uri;
|
||||||
|
* sendSticker(content);
|
||||||
|
* resolve(result);
|
||||||
|
* })
|
||||||
|
* .catch(error => {
|
||||||
|
* reject(error);
|
||||||
|
* });
|
||||||
|
* } else {
|
||||||
|
* reject(new Error('Failed to fetch data')); // Reject the outer promise if fetching data fails
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* };
|
||||||
|
* xhr.send();
|
||||||
|
* });
|
||||||
|
*}
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function sendGIF(content){
|
||||||
|
// just print content, since it's a custom type with URL
|
||||||
|
console.log("Content:"+content.url);
|
||||||
|
// use fetch because I'm on IE
|
||||||
|
const lol = await fetch(content.url);
|
||||||
|
const uri_file = await lol.blob();
|
||||||
|
// call uploadFile with this
|
||||||
|
var result = await api.uploadFile(uri_file)
|
||||||
|
console.log("Got URI:"+result.content_uri);
|
||||||
|
content.url = result.content_uri;
|
||||||
|
// get thumbnail
|
||||||
|
//const thumb_uri = await fetch(content.info.thumbnail_url)
|
||||||
|
//const thumb_file = await thumb_uri.blob();
|
||||||
|
//result = await api.uploadFile(thumb_file)
|
||||||
|
//console.log("Thumb URI:"+result.content_uri);
|
||||||
|
//content.info.thumbnail_url = result.content_uri;
|
||||||
|
// actually, just delete the thumbnail
|
||||||
|
delete content.info.thumbnail_url;
|
||||||
|
// finally, send it as sticker
|
||||||
|
sendSticker(content);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
*let widgetId = null
|
||||||
|
*
|
||||||
|
*window.onmessage = event => {
|
||||||
|
* if (!window.parent || !event.data) {
|
||||||
|
* return
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* const request = event.data
|
||||||
|
* if (!request.requestId || !request.widgetId || !request.action || request.api !== "toWidget") {
|
||||||
|
* return
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* if (widgetId) {
|
||||||
|
* if (widgetId !== request.widgetId) {
|
||||||
|
* return
|
||||||
|
* }
|
||||||
|
* } else {
|
||||||
|
* widgetId = request.widgetId
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* let response
|
||||||
|
*
|
||||||
|
* if (request.action === "visibility") {
|
||||||
|
* response = {}
|
||||||
|
* } else if (request.action === "capabilities") {
|
||||||
|
* response = { capabilities: ["m.sticker", "org.matrix.msc4039.upload_file"] }
|
||||||
|
* } else {
|
||||||
|
* response = { error: { message: "Action not supported" } }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* window.parent.postMessage({ ...request, response }, event.origin)
|
||||||
|
*}
|
||||||
|
*
|
||||||
|
*export function sendSticker(content) {
|
||||||
|
* const data = {
|
||||||
|
* content: { ...content },
|
||||||
|
* // `name` is for Element Web (and also the spec)
|
||||||
|
* // Element Android uses content -> body as the name
|
||||||
|
* name: content.body,
|
||||||
|
* }
|
||||||
|
* // Custom field that stores the ID even for non-telegram stickers
|
||||||
|
* delete data.content.id
|
||||||
|
*
|
||||||
|
* // This is for Element iOS
|
||||||
|
* const widgetData = {
|
||||||
|
* ...data,
|
||||||
|
* description: content.body,
|
||||||
|
* file: `${content.id}.png`,
|
||||||
|
* }
|
||||||
|
* // Element iOS explodes if there are extra fields present
|
||||||
|
* delete widgetData.content["net.maunium.telegram.sticker"]
|
||||||
|
*
|
||||||
|
* window.parent.postMessage({
|
||||||
|
* api: "fromWidget",
|
||||||
|
* action: "m.sticker",
|
||||||
|
* requestId: `sticker-${Date.now()}`,
|
||||||
|
* widgetId,
|
||||||
|
* data,
|
||||||
|
* widgetData,
|
||||||
|
* }, "*")
|
||||||
|
*}
|
||||||
|
*
|
||||||
|
*export function sendGIF(content) {
|
||||||
|
* const data = {
|
||||||
|
* content: { ...content },
|
||||||
|
* name: content.body,
|
||||||
|
* msgtype: "m.image"
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* delete data.content.id
|
||||||
|
* // This is for Element iOS
|
||||||
|
* const widgetData = {
|
||||||
|
* ...data,
|
||||||
|
* description: content.body,
|
||||||
|
* file: `${content.id}.png`,
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* window.parent.postMessage({
|
||||||
|
* api: "fromWidget",
|
||||||
|
* action: "m.room.message",
|
||||||
|
* requestId: `gif-${Date.now()}`,
|
||||||
|
* widgetId,
|
||||||
|
* data,
|
||||||
|
* widgetData,
|
||||||
|
* }, "*")
|
||||||
|
*}
|
||||||
|
*/
|
@@ -1,362 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
import { html, render, Component } from "../../lib/htm/preact.js"
|
|
||||||
|
|
||||||
import Spinner from "../Spinner.js"
|
|
||||||
import * as widgetAPI from "./widget-api.js"
|
|
||||||
import * as frequent from "./frequently-used.js"
|
|
||||||
|
|
||||||
// The base URL for fetching packs. The app will first fetch ${PACK_BASE_URL}/index.json,
|
|
||||||
// then ${PACK_BASE_URL}/${packFile} for each packFile in the packs object of the index.json file.
|
|
||||||
const PACKS_BASE_URL = "packs"
|
|
||||||
// This is updated from packs/index.json
|
|
||||||
let HOMESERVER_URL = "https://matrix-client.matrix.org"
|
|
||||||
|
|
||||||
// eslint-disable-next-line max-len
|
|
||||||
const makeThumbnailURL = mxc => `${HOMESERVER_URL}/_matrix/media/r0/thumbnail/${mxc.substr(6)}?height=128&width=128&method=scale`
|
|
||||||
|
|
||||||
// We need to detect iOS webkit because it has a bug related to scrolling non-fixed divs
|
|
||||||
// This is also used to fix scrolling to sections on Element iOS
|
|
||||||
const isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/)
|
|
||||||
&& navigator.userAgent.match(/AppleWebKit/)
|
|
||||||
|
|
||||||
const query = Object.fromEntries(location.search
|
|
||||||
.substr(1).split("&")
|
|
||||||
.map(part => part.split("="))
|
|
||||||
.map(([key, value = ""]) => [key, value]))
|
|
||||||
|
|
||||||
const supportedThemes = ["light", "dark", "black"]
|
|
||||||
|
|
||||||
class App extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props)
|
|
||||||
this.defaultTheme = query.theme
|
|
||||||
this.state = {
|
|
||||||
packs: [],
|
|
||||||
loading: true,
|
|
||||||
error: null,
|
|
||||||
stickersPerRow: parseInt(localStorage.mauStickersPerRow || "4"),
|
|
||||||
theme: localStorage.mauStickerThemeOverride || this.defaultTheme,
|
|
||||||
frequentlyUsed: {
|
|
||||||
id: "frequently-used",
|
|
||||||
title: "Frequently used",
|
|
||||||
stickerIDs: frequent.get(),
|
|
||||||
stickers: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (!supportedThemes.includes(this.state.theme)) {
|
|
||||||
this.state.theme = "light"
|
|
||||||
}
|
|
||||||
if (!supportedThemes.includes(this.defaultTheme)) {
|
|
||||||
this.defaultTheme = "light"
|
|
||||||
}
|
|
||||||
this.stickersByID = new Map(JSON.parse(localStorage.mauFrequentlyUsedStickerCache || "[]"))
|
|
||||||
this.state.frequentlyUsed.stickers = this._getStickersByID(
|
|
||||||
this.state.frequentlyUsed.stickerIDs)
|
|
||||||
this.imageObserver = null
|
|
||||||
this.packListRef = null
|
|
||||||
this.navRef = null
|
|
||||||
this.sendSticker = this.sendSticker.bind(this)
|
|
||||||
this.navScroll = this.navScroll.bind(this)
|
|
||||||
this.reloadPacks = this.reloadPacks.bind(this)
|
|
||||||
this.observeSectionIntersections = this.observeSectionIntersections.bind(this)
|
|
||||||
this.observeImageIntersections = this.observeImageIntersections.bind(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
_getStickersByID(ids) {
|
|
||||||
return ids.map(id => this.stickersByID.get(id)).filter(sticker => !!sticker)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFrequentlyUsed() {
|
|
||||||
const stickerIDs = frequent.get()
|
|
||||||
const stickers = this._getStickersByID(stickerIDs)
|
|
||||||
this.setState({
|
|
||||||
frequentlyUsed: {
|
|
||||||
...this.state.frequentlyUsed,
|
|
||||||
stickerIDs,
|
|
||||||
stickers,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(
|
|
||||||
stickers.map(sticker => [sticker.id, sticker]))
|
|
||||||
}
|
|
||||||
|
|
||||||
setStickersPerRow(val) {
|
|
||||||
localStorage.mauStickersPerRow = val
|
|
||||||
document.documentElement.style.setProperty("--stickers-per-row",
|
|
||||||
localStorage.mauStickersPerRow)
|
|
||||||
this.setState({
|
|
||||||
stickersPerRow: val,
|
|
||||||
})
|
|
||||||
this.packListRef.scrollTop = this.packListRef.scrollHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
setTheme(theme) {
|
|
||||||
if (theme === "default") {
|
|
||||||
delete localStorage.mauStickerThemeOverride
|
|
||||||
this.setState({ theme: this.defaultTheme })
|
|
||||||
} else {
|
|
||||||
localStorage.mauStickerThemeOverride = theme
|
|
||||||
this.setState({ theme: theme })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reloadPacks() {
|
|
||||||
this.imageObserver.disconnect()
|
|
||||||
this.sectionObserver.disconnect()
|
|
||||||
this.setState({ packs: [] })
|
|
||||||
this._loadPacks(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
_loadPacks(disableCache = false) {
|
|
||||||
const args = {
|
|
||||||
cache: disableCache ? "no-cache" : undefined,
|
|
||||||
headers: query.user_id && query.token ? {
|
|
||||||
Authorization: `Bearer ${query.token}`,
|
|
||||||
"X-Matrix-User-ID": query.user_id,
|
|
||||||
} : {},
|
|
||||||
}
|
|
||||||
fetch(`${PACKS_BASE_URL}/index.json`, args).then(async indexRes => {
|
|
||||||
if (indexRes.status >= 400) {
|
|
||||||
try {
|
|
||||||
const errData = await indexRes.json()
|
|
||||||
this.setState({
|
|
||||||
loading: false,
|
|
||||||
error: errData.error,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
this.setState({
|
|
||||||
loading: false,
|
|
||||||
error: indexRes.status !== 404 ? indexRes.statusText : null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const indexData = await indexRes.json()
|
|
||||||
HOMESERVER_URL = indexData.homeserver_url || HOMESERVER_URL
|
|
||||||
// TODO only load pack metadata when scrolled into view?
|
|
||||||
for (const packFile of indexData.packs) {
|
|
||||||
const packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, args)
|
|
||||||
const packData = await packRes.json()
|
|
||||||
for (const sticker of packData.stickers) {
|
|
||||||
this.stickersByID.set(sticker.id, sticker)
|
|
||||||
}
|
|
||||||
this.setState({
|
|
||||||
packs: [...this.state.packs, packData],
|
|
||||||
loading: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
this.updateFrequentlyUsed()
|
|
||||||
}, error => this.setState({
|
|
||||||
loading: false,
|
|
||||||
error,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
document.documentElement.style.setProperty("--stickers-per-row",
|
|
||||||
this.state.stickersPerRow.toString())
|
|
||||||
this._loadPacks()
|
|
||||||
this.imageObserver = new IntersectionObserver(this.observeImageIntersections, {
|
|
||||||
rootMargin: "100px",
|
|
||||||
})
|
|
||||||
this.sectionObserver = new IntersectionObserver(this.observeSectionIntersections)
|
|
||||||
}
|
|
||||||
|
|
||||||
observeImageIntersections(intersections) {
|
|
||||||
for (const entry of intersections) {
|
|
||||||
const img = entry.target.children.item(0)
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
img.setAttribute("src", img.getAttribute("data-src"))
|
|
||||||
img.classList.add("visible")
|
|
||||||
} else {
|
|
||||||
img.removeAttribute("src")
|
|
||||||
img.classList.remove("visible")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
observeSectionIntersections(intersections) {
|
|
||||||
const navWidth = this.navRef.getBoundingClientRect().width
|
|
||||||
let minX = 0, maxX = navWidth
|
|
||||||
let minXElem = null
|
|
||||||
let maxXElem = null
|
|
||||||
for (const entry of intersections) {
|
|
||||||
const packID = entry.target.getAttribute("data-pack-id")
|
|
||||||
const navElement = document.getElementById(`nav-${packID}`)
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
navElement.classList.add("visible")
|
|
||||||
const bb = navElement.getBoundingClientRect()
|
|
||||||
if (bb.x < minX) {
|
|
||||||
minX = bb.x
|
|
||||||
minXElem = navElement
|
|
||||||
} else if (bb.right > maxX) {
|
|
||||||
maxX = bb.right
|
|
||||||
maxXElem = navElement
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
navElement.classList.remove("visible")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (minXElem !== null) {
|
|
||||||
minXElem.scrollIntoView({ inline: "start" })
|
|
||||||
} else if (maxXElem !== null) {
|
|
||||||
maxXElem.scrollIntoView({ inline: "end" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
|
||||||
if (this.packListRef === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const elem of this.packListRef.getElementsByClassName("sticker")) {
|
|
||||||
this.imageObserver.observe(elem)
|
|
||||||
}
|
|
||||||
for (const elem of this.packListRef.children) {
|
|
||||||
this.sectionObserver.observe(elem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.imageObserver.disconnect()
|
|
||||||
this.sectionObserver.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
sendSticker(evt) {
|
|
||||||
const id = evt.currentTarget.getAttribute("data-sticker-id")
|
|
||||||
const sticker = this.stickersByID.get(id)
|
|
||||||
frequent.add(id)
|
|
||||||
this.updateFrequentlyUsed()
|
|
||||||
widgetAPI.sendSticker(sticker)
|
|
||||||
}
|
|
||||||
|
|
||||||
navScroll(evt) {
|
|
||||||
this.navRef.scrollLeft += evt.deltaY * 12
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const theme = `theme-${this.state.theme}`
|
|
||||||
if (this.state.loading) {
|
|
||||||
return html`
|
|
||||||
<main class="spinner ${theme}">
|
|
||||||
<${Spinner} size=${80} green />
|
|
||||||
</main>`
|
|
||||||
} else if (this.state.error) {
|
|
||||||
return html`
|
|
||||||
<main class="error ${theme}">
|
|
||||||
<h1>Failed to load packs</h1>
|
|
||||||
<p>${this.state.error}</p>
|
|
||||||
</main>`
|
|
||||||
} else if (this.state.packs.length === 0) {
|
|
||||||
return html`
|
|
||||||
<main class="empty ${theme}"><h1>No packs found 😿</h1></main>`
|
|
||||||
}
|
|
||||||
return html`
|
|
||||||
<main class="has-content ${theme}">
|
|
||||||
<nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}>
|
|
||||||
<${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" />
|
|
||||||
${this.state.packs.map(pack => html`
|
|
||||||
<${NavBarItem} id=${pack.id} pack=${pack}/>`)}
|
|
||||||
<${NavBarItem} pack=${{ id: "settings", title: "Settings" }}
|
|
||||||
iconOverride="settings" />
|
|
||||||
</nav>
|
|
||||||
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}"
|
|
||||||
ref=${elem => this.packListRef = elem}>
|
|
||||||
<${Pack} pack=${this.state.frequentlyUsed} send=${this.sendSticker}/>
|
|
||||||
${this.state.packs.map(pack => html`
|
|
||||||
<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker}/>`)}
|
|
||||||
<${Settings} app=${this}/>
|
|
||||||
</div>
|
|
||||||
</main>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Settings = ({ app }) => html`
|
|
||||||
<section class="stickerpack settings" id="pack-settings" data-pack-id="settings">
|
|
||||||
<h1>Settings</h1>
|
|
||||||
<div class="settings-list">
|
|
||||||
<button onClick=${app.reloadPacks}>Reload</button>
|
|
||||||
<div>
|
|
||||||
<label for="stickers-per-row">Stickers per row: ${app.state.stickersPerRow}</label>
|
|
||||||
<input type="range" min=2 max=10 id="stickers-per-row"
|
|
||||||
value=${app.state.stickersPerRow}
|
|
||||||
onInput=${evt => app.setStickersPerRow(evt.target.value)}/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="theme">Theme: </label>
|
|
||||||
<select name="theme" id="theme" onChange=${evt => app.setTheme(evt.target.value)}>
|
|
||||||
<option value="default">Default</option>
|
|
||||||
<option value="light">Light</option>
|
|
||||||
<option value="dark">Dark</option>
|
|
||||||
<option value="black">Black</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`
|
|
||||||
|
|
||||||
// By default we just let the browser handle scrolling to sections, but webviews on Element iOS
|
|
||||||
// open the link in the browser instead of just scrolling there, so we need to scroll manually:
|
|
||||||
const scrollToSection = (evt, id) => {
|
|
||||||
const pack = document.getElementById(`pack-${id}`)
|
|
||||||
pack.scrollIntoView({
|
|
||||||
block: "start",
|
|
||||||
behavior: "instant",
|
|
||||||
})
|
|
||||||
evt.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
const NavBarItem = ({
|
|
||||||
pack,
|
|
||||||
iconOverride = null,
|
|
||||||
}) => html`
|
|
||||||
<a href="#pack-${pack.id}" id="nav-${pack.id}" data-pack-id=${pack.id} title=${pack.title}
|
|
||||||
onClick=${isMobileSafari ? evt => scrollToSection(evt, pack.id) : undefined}>
|
|
||||||
<div class="sticker">
|
|
||||||
${iconOverride ? html`
|
|
||||||
<span class="icon icon-${iconOverride}" />
|
|
||||||
` : html`
|
|
||||||
<img src=${makeThumbnailURL(pack.stickers[0].url)}
|
|
||||||
alt=${pack.stickers[0].body} class="visible" />
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
`
|
|
||||||
|
|
||||||
const Pack = ({
|
|
||||||
pack,
|
|
||||||
send,
|
|
||||||
}) => html`
|
|
||||||
<section class="stickerpack" id="pack-${pack.id}" data-pack-id=${pack.id}>
|
|
||||||
<h1>${pack.title}</h1>
|
|
||||||
<div class="sticker-list">
|
|
||||||
${pack.stickers.map(sticker => html`
|
|
||||||
<${Sticker} key=${sticker.id} content=${sticker} send=${send}/>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`
|
|
||||||
|
|
||||||
const Sticker = ({
|
|
||||||
content,
|
|
||||||
send,
|
|
||||||
}) => html`
|
|
||||||
<div class="sticker" onClick=${send} data-sticker-id=${content.id}>
|
|
||||||
<img data-src=${makeThumbnailURL(content.url)} alt=${content.body}/>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
|
|
||||||
render(html`<${App} />`, document.body)
|
|
@@ -1,76 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
let widgetId = null
|
|
||||||
|
|
||||||
window.onmessage = event => {
|
|
||||||
if (!window.parent || !event.data) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = event.data
|
|
||||||
if (!request.requestId || !request.widgetId || !request.action || request.api !== "toWidget") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (widgetId) {
|
|
||||||
if (widgetId !== request.widgetId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
widgetId = request.widgetId
|
|
||||||
}
|
|
||||||
|
|
||||||
let response
|
|
||||||
|
|
||||||
if (request.action === "visibility") {
|
|
||||||
response = {}
|
|
||||||
} else if (request.action === "capabilities") {
|
|
||||||
response = { capabilities: ["m.sticker"] }
|
|
||||||
} else {
|
|
||||||
response = { error: { message: "Action not supported" } }
|
|
||||||
}
|
|
||||||
|
|
||||||
window.parent.postMessage({ ...request, response }, event.origin)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sendSticker(content) {
|
|
||||||
const data = {
|
|
||||||
content: { ...content },
|
|
||||||
// `name` is for Element Web (and also the spec)
|
|
||||||
// Element Android uses content -> body as the name
|
|
||||||
name: content.body,
|
|
||||||
}
|
|
||||||
// Custom field that stores the ID even for non-telegram stickers
|
|
||||||
delete data.content.id
|
|
||||||
|
|
||||||
// This is for Element iOS
|
|
||||||
const widgetData = {
|
|
||||||
...data,
|
|
||||||
description: content.body,
|
|
||||||
file: `${content.id}.png`,
|
|
||||||
}
|
|
||||||
// Element iOS explodes if there are extra fields present
|
|
||||||
delete widgetData.content["net.maunium.telegram.sticker"]
|
|
||||||
|
|
||||||
window.parent.postMessage({
|
|
||||||
api: "fromWidget",
|
|
||||||
action: "m.sticker",
|
|
||||||
requestId: `sticker-${Date.now()}`,
|
|
||||||
widgetId,
|
|
||||||
data,
|
|
||||||
widgetData,
|
|
||||||
}, "*")
|
|
||||||
}
|
|
@@ -1 +0,0 @@
|
|||||||
button.mau-button{cursor:pointer;margin:.5rem 0;border-radius:.25rem;font-size:1rem;box-sizing:border-box;padding:0}button.mau-button:disabled{cursor:default}button.mau-button.size-thick{height:3rem}button.mau-button.size-normal{height:2.5rem}button.mau-button.size-thin{height:2rem}button.mau-button.variant-filled{background-color:#2e7d32;color:#fff;border:none}button.mau-button.variant-filled:hover{background-color:#005005}button.mau-button.variant-filled:disabled{background-color:#CCC;color:#212121}button.mau-button.variant-outlined{background-color:#fff;border:2px solid #2e7d32;color:#2e7d32}button.mau-button.variant-outlined:hover{background-color:#60ad5e}button.mau-button.variant-outlined:disabled{background-color:#fff;border-color:#CCC}
|
|
@@ -1,60 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
@import theme.sass
|
|
||||||
|
|
||||||
button.mau-button
|
|
||||||
cursor: pointer
|
|
||||||
margin: .5rem 0
|
|
||||||
border-radius: .25rem
|
|
||||||
font-size: 1rem
|
|
||||||
box-sizing: border-box
|
|
||||||
padding: 0
|
|
||||||
|
|
||||||
&:disabled
|
|
||||||
cursor: default
|
|
||||||
|
|
||||||
&.size-thick
|
|
||||||
height: 3rem
|
|
||||||
|
|
||||||
&.size-normal
|
|
||||||
height: 2.5rem
|
|
||||||
|
|
||||||
&.size-thin
|
|
||||||
height: 2rem
|
|
||||||
|
|
||||||
&.variant-filled
|
|
||||||
background-color: $primary
|
|
||||||
color: $primaryContrastText
|
|
||||||
border: none
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
background-color: $primaryDark
|
|
||||||
|
|
||||||
&:disabled
|
|
||||||
background-color: $disabled
|
|
||||||
color: $text
|
|
||||||
|
|
||||||
&.variant-outlined
|
|
||||||
background-color: $background
|
|
||||||
border: 2px solid $primary
|
|
||||||
color: $primary
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
background-color: $primaryLight
|
|
||||||
|
|
||||||
&:disabled
|
|
||||||
background-color: $background
|
|
||||||
border-color: $disabled
|
|
16
web/style/index.css
Normal file
16
web/style/index.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
*{font-family:sans-serif}body{margin:0}h1{font-size:1rem}:root{--stickers-per-row: 4;--sticker-size: calc(100vw / var(--stickers-per-row))}main{color:var(--text-color)}main.spinner{margin-top:5rem}main.error,main.empty{margin:2rem}main.empty{text-align:center}main.has-content{position:fixed;top:0;left:0;right:0;bottom:0;display:grid;grid-template-rows:calc(12vw + 2px) min-content auto}main.theme-light{--highlight-color: #eee;--search-box-color: var(--highlight-color);--text-color: black;background-color:#fff}main.theme-dark{--highlight-color: #444;--search-box-color: #383e4b;--text-color: white;background-color:#22262e}main.theme-black{--highlight-color: #222;--search-box-color: var(--highlight-color);--text-color: white;background-color:#000}.icon{width:100%;height:100%;background-color:var(--text-color);mask-size:contain;-webkit-mask-size:contain;mask-image:var(--icon-image);-webkit-mask-image:var(--icon-image)}.icon.icon-settings{--icon-image: url(../res/settings.svg)}.icon.icon-recent{--icon-image: url(../res/recent.svg)}.icon.icon.icon-search{--icon-image: url(../res/search.svg)}nav{display:flex;overflow-x:auto}nav>a{border-bottom:2px solid transparent}nav>a.visible{border-bottom-color:green}nav>a>div.sticker{width:12vw;height:12vw}div.pack-list,nav{scrollbar-width:none}div.pack-list::-webkit-scrollbar,nav::-webkit-scrollbar{display:none}div.pack-list{overflow-y:auto}div.pack-list.ios-safari-hack{position:fixed;top:calc(calc(12vw + 2px) + calc(2 * 0.7rem + 2 * 0.5rem + 1rem));bottom:0;left:0;right:0;-webkit-overflow-scrolling:touch}div.search-empty{margin:1.2rem;text-align:center}section.stickerpack{margin-top:.75rem}section.stickerpack>div.sticker-list{display:flex;flex-wrap:wrap}section.stickerpack>h1{margin:0 0 0 .75rem}div.sticker{display:flex;padding:4px;cursor:pointer;position:relative;width:var(--sticker-size);height:var(--sticker-size);box-sizing:border-box}div.sticker:hover{background-color:var(--highlight-color)}div.sticker>img{display:none;width:100%;object-fit:contain}div.sticker>img.visible{display:initial}div.sticker>.icon{width:70%;height:70%;margin:15%}div.search-box{position:relative;display:flex}div.search-box>input[type=text]{flex-grow:1;background-color:var(--search-box-color);outline:none;border:none;border-radius:.25rem;height:1rem;padding:.7rem;padding-right:calc(1rem + 0.7rem);margin:.5rem;font-size:1rem;color:var(--text-color)}div.search-box>span.icon{display:flex;position:absolute;top:calc(50% - 1rem / 2);right:1rem;width:1rem;height:1rem;box-sizing:border-box}div.settings-list{display:flex;flex-direction:column}div.settings-list>*{margin:.5rem}div.settings-list button{padding:.5rem;border-radius:.25rem}div.settings-list input{width:100%}
|
||||||
|
|
||||||
|
a.tab {
|
||||||
|
padding: 5% 5%;
|
||||||
|
width: 40%;
|
||||||
|
text-align: center;
|
||||||
|
border: none;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-appearance: button;
|
||||||
|
-moz-appearance: button;
|
||||||
|
appearance: button;
|
||||||
|
|
||||||
|
text-decoration: none;
|
||||||
|
color: initial;
|
||||||
|
}
|
@@ -32,6 +32,12 @@ $nav-bottom-highlight: 2px
|
|||||||
$nav-height: calc(#{$nav-sticker-size} + #{$nav-bottom-highlight})
|
$nav-height: calc(#{$nav-sticker-size} + #{$nav-bottom-highlight})
|
||||||
$nav-height-inverse: calc(-#{$nav-sticker-size} - #{$nav-bottom-highlight})
|
$nav-height-inverse: calc(-#{$nav-sticker-size} - #{$nav-bottom-highlight})
|
||||||
|
|
||||||
|
$search-box-icon-size: 1rem
|
||||||
|
$search-box-input-height: 1rem
|
||||||
|
$search-box-input-padding: .7rem
|
||||||
|
$search-box-input-margin: .5rem
|
||||||
|
$search-box-height: calc(2 * #{$search-box-input-padding} + 2 * #{$search-box-input-margin} + #{$search-box-input-height})
|
||||||
|
|
||||||
main
|
main
|
||||||
color: var(--text-color)
|
color: var(--text-color)
|
||||||
|
|
||||||
@@ -50,22 +56,24 @@ main
|
|||||||
left: 0
|
left: 0
|
||||||
right: 0
|
right: 0
|
||||||
bottom: 0
|
bottom: 0
|
||||||
|
|
||||||
display: grid
|
display: grid
|
||||||
grid-template-rows: $nav-height auto
|
grid-template-rows: $nav-height min-content auto
|
||||||
|
|
||||||
main.theme-light
|
main.theme-light
|
||||||
--highlight-color: #eee
|
--highlight-color: #eee
|
||||||
|
--search-box-color: var(--highlight-color)
|
||||||
--text-color: black
|
--text-color: black
|
||||||
background-color: white
|
background-color: white
|
||||||
|
|
||||||
main.theme-dark
|
main.theme-dark
|
||||||
--highlight-color: #444
|
--highlight-color: #444
|
||||||
|
--search-box-color: #383e4b
|
||||||
--text-color: white
|
--text-color: white
|
||||||
background-color: #22262e
|
background-color: #22262e
|
||||||
|
|
||||||
main.theme-black
|
main.theme-black
|
||||||
--highlight-color: #222
|
--highlight-color: #222
|
||||||
|
--search-box-color: var(--highlight-color)
|
||||||
--text-color: white
|
--text-color: white
|
||||||
background-color: black
|
background-color: black
|
||||||
|
|
||||||
@@ -84,6 +92,9 @@ main.theme-black
|
|||||||
&.icon-recent
|
&.icon-recent
|
||||||
--icon-image: url(../res/recent.svg)
|
--icon-image: url(../res/recent.svg)
|
||||||
|
|
||||||
|
&.icon.icon-search
|
||||||
|
--icon-image: url(../res/search.svg)
|
||||||
|
|
||||||
nav
|
nav
|
||||||
display: flex
|
display: flex
|
||||||
overflow-x: auto
|
overflow-x: auto
|
||||||
@@ -109,12 +120,16 @@ div.pack-list
|
|||||||
|
|
||||||
div.pack-list.ios-safari-hack
|
div.pack-list.ios-safari-hack
|
||||||
position: fixed
|
position: fixed
|
||||||
top: $nav-height
|
top: calc(#{$nav-height} + #{$search-box-height})
|
||||||
bottom: 0
|
bottom: 0
|
||||||
left: 0
|
left: 0
|
||||||
right: 0
|
right: 0
|
||||||
-webkit-overflow-scrolling: touch
|
-webkit-overflow-scrolling: touch
|
||||||
|
|
||||||
|
div.search-empty
|
||||||
|
margin: 1.2rem
|
||||||
|
text-align: center
|
||||||
|
|
||||||
section.stickerpack
|
section.stickerpack
|
||||||
margin-top: .75rem
|
margin-top: .75rem
|
||||||
|
|
||||||
@@ -150,6 +165,32 @@ div.sticker
|
|||||||
height: 70%
|
height: 70%
|
||||||
margin: 15%
|
margin: 15%
|
||||||
|
|
||||||
|
div.search-box
|
||||||
|
position: relative
|
||||||
|
display: flex
|
||||||
|
|
||||||
|
>input[type="text"]
|
||||||
|
flex-grow: 1
|
||||||
|
background-color: var(--search-box-color)
|
||||||
|
outline: none
|
||||||
|
border: none
|
||||||
|
border-radius: .25rem
|
||||||
|
height: $search-box-input-height
|
||||||
|
padding: $search-box-input-padding
|
||||||
|
padding-right: calc(#{$search-box-icon-size} + #{$search-box-input-padding})
|
||||||
|
margin: $search-box-input-margin
|
||||||
|
font-size: 1rem
|
||||||
|
color: var(--text-color)
|
||||||
|
|
||||||
|
>span.icon
|
||||||
|
display: flex
|
||||||
|
position: absolute
|
||||||
|
top: calc(50% - #{$search-box-icon-size} / 2)
|
||||||
|
right: $search-box-icon-size
|
||||||
|
width: $search-box-icon-size
|
||||||
|
height: $search-box-icon-size
|
||||||
|
box-sizing: border-box
|
||||||
|
|
||||||
div.settings-list
|
div.settings-list
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
@@ -163,3 +204,4 @@ div.settings-list
|
|||||||
|
|
||||||
input
|
input
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
@@ -1 +0,0 @@
|
|||||||
main.login-view{position:fixed;top:0;bottom:0;right:0;left:0;background-color:#2e7d32;display:flex;justify-content:space-around}main.login-view form.login-box{background-color:#fff;width:25rem;height:22.5rem;padding:2.5rem 2.5rem 2rem;margin-top:3rem;border-radius:.25rem;box-sizing:border-box;display:flex;flex-direction:column}main.login-view form.login-box.has-error{min-height:27rem;height:auto;margin-bottom:auto}main.login-view form.login-box h1{color:#2e7d32;margin:.5rem auto 3rem;font-size:1.5rem}main.login-view form.login-box .input{margin:.5rem 0;border-radius:.25rem;border:1px solid #DDD;padding:1px}main.login-view form.login-box .input:hover,main.login-view form.login-box .input:focus,main.login-view form.login-box .input.focus{border-color:#2e7d32}main.login-view form.login-box .input:focus,main.login-view form.login-box .input.focus{border-width:2px;padding:0}main.login-view form.login-box .username{display:flex;cursor:text}main.login-view form.login-box .username>input{border:none;padding:.75rem .125rem;color:#212121;min-width:0;font-size:1rem}main.login-view form.login-box .username>input:last-of-type{padding-right:.5rem;border-radius:0 .25rem .25rem 0}main.login-view form.login-box .username>input:focus{outline:none}main.login-view form.login-box .username>span{user-select:none;padding:.75rem 0;color:#212121}main.login-view form.login-box .username>span:first-of-type{padding-left:.5rem}main.login-view form.login-box .password{font-size:1rem;margin:.5rem 0;border-radius:.25rem;border:1px solid #DDD;padding:calc(.75rem + 1px) 1rem;box-sizing:border-box}main.login-view form.login-box .password:hover:not(:disabled),main.login-view form.login-box .password:focus:not(:disabled){border-color:#2e7d32}main.login-view form.login-box .password:focus{padding:0.75rem calc(1rem - 1px);border-width:2px;outline:none}main.login-view form.login-box .button-group{display:flex;gap:4px}main.login-view form.login-box .button-group button{width:100%}main.login-view form.login-box .error{padding:1rem;border-radius:.25rem;border:2px solid #B71C1C;background-color:#F7A9A1;margin:.5rem 0;width:100%;box-sizing:border-box}
|
|
@@ -1,105 +0,0 @@
|
|||||||
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
|
|
||||||
// Copyright (C) 2020 Tulir Asokan
|
|
||||||
//
|
|
||||||
// This program is free software: you can redistribute it and/or modify
|
|
||||||
// it under the terms of the GNU Affero General Public License as published by
|
|
||||||
// the Free Software Foundation, either version 3 of the License, or
|
|
||||||
// (at your option) any later version.
|
|
||||||
//
|
|
||||||
// This program is distributed in the hope that it will be useful,
|
|
||||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
// GNU Affero General Public License for more details.
|
|
||||||
//
|
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
@import theme.sass
|
|
||||||
|
|
||||||
main.login-view
|
|
||||||
position: fixed
|
|
||||||
top: 0
|
|
||||||
bottom: 0
|
|
||||||
right: 0
|
|
||||||
left: 0
|
|
||||||
background-color: $primary
|
|
||||||
display: flex
|
|
||||||
justify-content: space-around
|
|
||||||
|
|
||||||
form.login-box
|
|
||||||
background-color: $background
|
|
||||||
width: 25rem
|
|
||||||
height: 22.5rem
|
|
||||||
padding: 2.5rem 2.5rem 2rem
|
|
||||||
margin-top: 3rem
|
|
||||||
border-radius: .25rem
|
|
||||||
box-sizing: border-box
|
|
||||||
display: flex
|
|
||||||
flex-direction: column
|
|
||||||
|
|
||||||
&.has-error
|
|
||||||
min-height: 27rem
|
|
||||||
height: auto
|
|
||||||
margin-bottom: auto
|
|
||||||
|
|
||||||
h1
|
|
||||||
color: $primary
|
|
||||||
margin: .5rem auto 3rem
|
|
||||||
font-size: 1.5rem
|
|
||||||
|
|
||||||
.input
|
|
||||||
margin: .5rem 0
|
|
||||||
border-radius: .25rem
|
|
||||||
border: 1px solid $border
|
|
||||||
padding: 1px
|
|
||||||
|
|
||||||
&:hover, &:focus, &.focus
|
|
||||||
border-color: $primary
|
|
||||||
|
|
||||||
&:focus, &.focus
|
|
||||||
border-width: 2px
|
|
||||||
padding: 0
|
|
||||||
|
|
||||||
.username
|
|
||||||
display: flex
|
|
||||||
cursor: text
|
|
||||||
|
|
||||||
& > input
|
|
||||||
border: none
|
|
||||||
padding: .75rem .125rem
|
|
||||||
color: $text
|
|
||||||
min-width: 0
|
|
||||||
font-size: 1rem
|
|
||||||
|
|
||||||
&:last-of-type
|
|
||||||
padding-right: .5rem
|
|
||||||
border-radius: 0 .25rem .25rem 0
|
|
||||||
|
|
||||||
&:focus
|
|
||||||
outline: none
|
|
||||||
|
|
||||||
& > span
|
|
||||||
user-select: none
|
|
||||||
padding: .75rem 0
|
|
||||||
color: $text
|
|
||||||
|
|
||||||
&:first-of-type
|
|
||||||
padding-left: .5rem
|
|
||||||
|
|
||||||
.password
|
|
||||||
@include input
|
|
||||||
|
|
||||||
.button-group
|
|
||||||
display: flex
|
|
||||||
gap: 4px
|
|
||||||
|
|
||||||
button
|
|
||||||
width: 100%
|
|
||||||
|
|
||||||
.error
|
|
||||||
padding: 1rem
|
|
||||||
border-radius: .25rem
|
|
||||||
border: 2px solid $errorDark
|
|
||||||
background-color: $error
|
|
||||||
margin: .5rem 0
|
|
||||||
width: 100%
|
|
||||||
box-sizing: border-box
|
|
@@ -1 +0,0 @@
|
|||||||
body{font-family:sans-serif}
|
|
@@ -1 +1 @@
|
|||||||
.sk-center-wrapper{width:100%;display:flex;justify-content:space-around}.sk-chase{position:relative;animation:sk-chase 2.5s infinite linear both}.sk-chase.green>.sk-chase-dot:before{background-color:#00C853}.sk-chase>.sk-chase-dot{width:100%;height:100%;position:absolute;left:0;top:0;animation:sk-chase-dot 2.0s infinite ease-in-out both}.sk-chase>.sk-chase-dot:before{content:'';display:block;width:25%;height:25%;border-radius:100%;animation:sk-chase-dot-before 2.0s infinite ease-in-out both;background-color:#FFF}.sk-chase>.sk-chase-dot:nth-child(1){animation-delay:-1.1s}.sk-chase>.sk-chase-dot:nth-child(2){animation-delay:-1.0s}.sk-chase>.sk-chase-dot:nth-child(3){animation-delay:-0.9s}.sk-chase>.sk-chase-dot:nth-child(4){animation-delay:-0.8s}.sk-chase>.sk-chase-dot:nth-child(5){animation-delay:-0.7s}.sk-chase>.sk-chase-dot:nth-child(6){animation-delay:-0.6s}.sk-chase>.sk-chase-dot:nth-child(1):before{animation-delay:-1.1s}.sk-chase>.sk-chase-dot:nth-child(2):before{animation-delay:-1.0s}.sk-chase>.sk-chase-dot:nth-child(3):before{animation-delay:-0.9s}.sk-chase>.sk-chase-dot:nth-child(4):before{animation-delay:-0.8s}.sk-chase>.sk-chase-dot:nth-child(5):before{animation-delay:-0.7s}.sk-chase>.sk-chase-dot:nth-child(6):before{animation-delay:-0.6s}@keyframes sk-chase{100%{transform:rotate(360deg)}}@keyframes sk-chase-dot{80%,100%{transform:rotate(360deg)}}@keyframes sk-chase-dot-before{50%{transform:scale(0.4)}100%,0%{transform:scale(1)}}
|
.sk-center-wrapper{width:100%;display:flex;justify-content:space-around}.sk-chase{position:relative;animation:sk-chase 2.5s infinite linear both}.sk-chase.green>.sk-chase-dot:before{background-color:#00c853}.sk-chase>.sk-chase-dot{width:100%;height:100%;position:absolute;left:0;top:0;animation:sk-chase-dot 2s infinite ease-in-out both}.sk-chase>.sk-chase-dot:before{content:"";display:block;width:25%;height:25%;border-radius:100%;animation:sk-chase-dot-before 2s infinite ease-in-out both;background-color:#fff}.sk-chase>.sk-chase-dot:nth-child(1){animation-delay:-1.1s}.sk-chase>.sk-chase-dot:nth-child(2){animation-delay:-1s}.sk-chase>.sk-chase-dot:nth-child(3){animation-delay:-0.9s}.sk-chase>.sk-chase-dot:nth-child(4){animation-delay:-0.8s}.sk-chase>.sk-chase-dot:nth-child(5){animation-delay:-0.7s}.sk-chase>.sk-chase-dot:nth-child(6){animation-delay:-0.6s}.sk-chase>.sk-chase-dot:nth-child(1):before{animation-delay:-1.1s}.sk-chase>.sk-chase-dot:nth-child(2):before{animation-delay:-1s}.sk-chase>.sk-chase-dot:nth-child(3):before{animation-delay:-0.9s}.sk-chase>.sk-chase-dot:nth-child(4):before{animation-delay:-0.8s}.sk-chase>.sk-chase-dot:nth-child(5):before{animation-delay:-0.7s}.sk-chase>.sk-chase-dot:nth-child(6):before{animation-delay:-0.6s}@keyframes sk-chase{100%{transform:rotate(360deg)}}@keyframes sk-chase-dot{80%,100%{transform:rotate(360deg)}}@keyframes sk-chase-dot-before{50%{transform:scale(0.4)}100%,0%{transform:scale(1)}}
|
||||||
|
@@ -1,33 +0,0 @@
|
|||||||
// Material UI green 800
|
|
||||||
$primary: #2e7d32
|
|
||||||
$primaryDark: #005005
|
|
||||||
$primaryLight: #60ad5e
|
|
||||||
// Material UI blue 700
|
|
||||||
$secondary: #1976d2
|
|
||||||
$secondaryDark: #004ba0
|
|
||||||
$secondaryLight: #63a4ff
|
|
||||||
|
|
||||||
$error: #F7A9A1
|
|
||||||
$errorDark: #B71C1C
|
|
||||||
|
|
||||||
$primaryContrastText: white
|
|
||||||
$background: white
|
|
||||||
$text: #212121
|
|
||||||
$border: #DDD
|
|
||||||
$disabled: #CCC
|
|
||||||
|
|
||||||
@mixin input
|
|
||||||
font-size: 1rem
|
|
||||||
margin: .5rem 0
|
|
||||||
border-radius: .25rem
|
|
||||||
border: 1px solid $border
|
|
||||||
padding: calc(.75rem + 1px) 1rem
|
|
||||||
box-sizing: border-box
|
|
||||||
|
|
||||||
&:hover:not(:disabled), &:focus:not(:disabled)
|
|
||||||
border-color: $primary
|
|
||||||
|
|
||||||
&:focus
|
|
||||||
padding: .75rem calc(1rem - 1px)
|
|
||||||
border-width: 2px
|
|
||||||
outline: none
|
|
@@ -1 +0,0 @@
|
|||||||
*{font-family:sans-serif}body{margin:0}h1{font-size:1rem}:root{--stickers-per-row: 4;--sticker-size: calc(100vw / var(--stickers-per-row))}main{color:var(--text-color)}main.spinner{margin-top:5rem}main.error,main.empty{margin:2rem}main.empty{text-align:center}main.has-content{position:fixed;top:0;left:0;right:0;bottom:0;display:grid;grid-template-rows:calc(12vw + 2px) auto}main.theme-light{--highlight-color: #eee;--text-color: black;background-color:white}main.theme-dark{--highlight-color: #444;--text-color: white;background-color:#22262e}main.theme-black{--highlight-color: #222;--text-color: white;background-color:black}.icon{width:100%;height:100%;background-color:var(--text-color);mask-size:contain;-webkit-mask-size:contain;mask-image:var(--icon-image);-webkit-mask-image:var(--icon-image)}.icon.icon-settings{--icon-image: url(../res/settings.svg)}.icon.icon-recent{--icon-image: url(../res/recent.svg)}nav{display:flex;overflow-x:auto}nav>a{border-bottom:2px solid transparent}nav>a.visible{border-bottom-color:green}nav>a>div.sticker{width:12vw;height:12vw}div.pack-list,nav{scrollbar-width:none}div.pack-list::-webkit-scrollbar,nav::-webkit-scrollbar{display:none}div.pack-list{overflow-y:auto}div.pack-list.ios-safari-hack{position:fixed;top:calc(12vw + 2px);bottom:0;left:0;right:0;-webkit-overflow-scrolling:touch}section.stickerpack{margin-top:.75rem}section.stickerpack>div.sticker-list{display:flex;flex-wrap:wrap}section.stickerpack>h1{margin:0 0 0 .75rem}div.sticker{display:flex;padding:4px;cursor:pointer;position:relative;width:var(--sticker-size);height:var(--sticker-size);box-sizing:border-box}div.sticker:hover{background-color:var(--highlight-color)}div.sticker>img{display:none;width:100%;object-fit:contain}div.sticker>img.visible{display:initial}div.sticker>.icon{width:70%;height:70%;margin:15%}div.settings-list{display:flex;flex-direction:column}div.settings-list>*{margin:.5rem}div.settings-list button{padding:.5rem;border-radius:.25rem}div.settings-list input{width:100%}
|
|
3106
web/yarn.lock
3106
web/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user