mirror of
https://github.com/maunium/stickerpicker.git
synced 2025-07-17 14:33:27 +02:00
Compare commits
3 Commits
f090cc076e
...
server
Author | SHA1 | Date | |
---|---|---|---|
12e1cb265d | |||
0b15a44820 | |||
9151f4cb6d |
19
.editorconfig
Normal file
19
.editorconfig
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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,3 +12,5 @@ web/lib/import-map.json
|
|||||||
*.session
|
*.session
|
||||||
/*.json
|
/*.json
|
||||||
*.bak
|
*.bak
|
||||||
|
*.log
|
||||||
|
config.yaml
|
||||||
|
4
MANIFEST.in
Normal file
4
MANIFEST.in
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
include README.md
|
||||||
|
include LICENSE
|
||||||
|
include requirements.txt
|
||||||
|
include optional-requirements.txt
|
@ -11,14 +11,8 @@ 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 | ❌ | ✔️ | ✔️ |
|
||||||
|
11
optional-requirements.txt
Normal file
11
optional-requirements.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# 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
|
aiohttp>=3,<4
|
||||||
yarl
|
yarl>=1,<2
|
||||||
pillow
|
pillow
|
||||||
telethon
|
telethon>=1.16
|
||||||
cryptg
|
cryptg
|
||||||
python-magic
|
python-magic
|
||||||
|
23
setup.py
23
setup.py
@ -5,6 +5,19 @@ 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:
|
||||||
@ -34,6 +47,7 @@ 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=[
|
||||||
@ -48,7 +62,14 @@ setuptools.setup(
|
|||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
],
|
],
|
||||||
entry_points={"console_scripts": [
|
entry_points={"console_scripts": [
|
||||||
"sticker-import=sticker.stickerimport:cmd",
|
"sticker-import=sticker.import: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"]}
|
||||||
)
|
)
|
||||||
|
@ -71,7 +71,7 @@ async def reupload_pack(client: TelegramClient, pack: StickerSetFull, output_dir
|
|||||||
|
|
||||||
already_uploaded = {}
|
already_uploaded = {}
|
||||||
try:
|
try:
|
||||||
with util.open_utf8(pack_path) as pack_file:
|
with open(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 util.open_utf8(pack_path, "w") as pack_file:
|
with open(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,6 +117,8 @@ 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()
|
||||||
|
|
||||||
@ -132,18 +134,17 @@ 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, 298751, "cb676d6bae20553c9996996a8f52b4d7")
|
client = TelegramClient(args.session, api_id, api_hash)
|
||||||
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(len(stickers.sets)))
|
width = len(str(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]:
|
||||||
@ -153,7 +154,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, hash=0))
|
pack: StickerSetFull = await client(GetStickerSetRequest(input_pack))
|
||||||
await reupload_pack(client, pack, args.output_dir)
|
await reupload_pack(client, pack, args.output_dir)
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
@ -42,7 +42,6 @@ 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
|
||||||
@ -60,8 +59,6 @@ async def load_config(path: str) -> None:
|
|||||||
homeserver_url = input("Homeserver URL: ")
|
homeserver_url = input("Homeserver URL: ")
|
||||||
access_token = input("Access token: ")
|
access_token = input("Access token: ")
|
||||||
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({
|
||||||
|
@ -13,7 +13,6 @@
|
|||||||
#
|
#
|
||||||
# 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
|
||||||
@ -22,7 +21,6 @@ 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")
|
||||||
@ -43,7 +41,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_utf8(index_path) as index_file:
|
with open(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": []}
|
||||||
@ -51,7 +49,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_utf8(index_path, "w") as index_file:
|
with open(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}")
|
||||||
|
|
||||||
@ -76,5 +74,4 @@ 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 util.open_utf8(meta_path) as pack_file:
|
with open(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 util.open_utf8(meta_path, "w") as pack_file:
|
with open(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 util.open_utf8(picker_pack_path, "w") as pack_file:
|
with open(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 util.open_utf8(index_path) as index_file:
|
with open(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 util.open_utf8(sys.argv[-1]) as file:
|
with open(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 util.open_utf8(pack_path, "w") as pack_file:
|
with open(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 util.open_utf8(index_path, "w") as index_file:
|
with open(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}")
|
||||||
|
0
sticker/server/__init__.py
Normal file
0
sticker/server/__init__.py
Normal file
53
sticker/server/__main__.py
Normal file
53
sticker/server/__main__.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# 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()
|
38
sticker/server/api/__init__.py
Normal file
38
sticker/server/api/__init__.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# 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)
|
216
sticker/server/api/auth.py
Normal file
216
sticker/server/api/auth.py
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
# 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
|
119
sticker/server/api/errors.py
Normal file
119
sticker/server/api/errors.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# 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
|
110
sticker/server/api/fed_connector.py
Normal file
110
sticker/server/api/fed_connector.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
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())
|
126
sticker/server/api/pack.schema.json
Normal file
126
sticker/server/api/pack.schema.json
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
{
|
||||||
|
"$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"
|
||||||
|
]
|
||||||
|
}
|
52
sticker/server/api/packs.py
Normal file
52
sticker/server/api/packs.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# 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
|
107
sticker/server/api/setup.py
Normal file
107
sticker/server/api/setup.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# 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)
|
55
sticker/server/config.py
Normal file
55
sticker/server/config.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# 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, {}),
|
||||||
|
})
|
6
sticker/server/database/__init__.py
Normal file
6
sticker/server/database/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
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
|
72
sticker/server/database/access_token.py
Normal file
72
sticker/server/database/access_token.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# 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)
|
9
sticker/server/database/base.py
Normal file
9
sticker/server/database/base.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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
|
64
sticker/server/database/pack.py
Normal file
64
sticker/server/database/pack.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# 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,
|
||||||
|
}
|
47
sticker/server/database/sticker.py
Normal file
47
sticker/server/database/sticker.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# 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)
|
57
sticker/server/database/upgrade.py
Normal file
57
sticker/server/database/upgrade.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# 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)
|
||||||
|
)""")
|
104
sticker/server/database/user.py
Normal file
104
sticker/server/database/user.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# 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)
|
74
sticker/server/example-config.yaml
Normal file
74
sticker/server/example-config.yaml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# 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
sticker/server/frontend
Symbolic link
1
sticker/server/frontend
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../../web/
|
50
sticker/server/server.py
Normal file
50
sticker/server/server.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# 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()
|
106
sticker/server/static.py
Normal file
106
sticker/server/static.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# 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}>"
|
196
web/.eslintrc.json
Normal file
196
web/.eslintrc.json
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
@ -1,23 +0,0 @@
|
|||||||
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))
|
|
||||||
})
|
|
@ -5,15 +5,15 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<link rel="modulepreload" href="src/widget-api.js"/>
|
<link rel="modulepreload" href="src/widget/widget-api.js"/>
|
||||||
<link rel="modulepreload" href="src/frequently-used.js"/>
|
<link rel="modulepreload" href="src/widget/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/index.css"/>
|
<link rel="stylesheet" href="style/widget.css"/>
|
||||||
<link rel="stylesheet" href="style/spinner.css"/>
|
<link rel="stylesheet" href="style/spinner.css"/>
|
||||||
<script src="src/index.js" type="module"></script>
|
<script src="src/widget/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>
|
||||||
|
3
web/lib/common/preact.module-9c264606.js
Normal file
3
web/lib/common/preact.module-9c264606.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
5
web/lib/preact/hooks.js
Normal file
5
web/lib/preact/hooks.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
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,16 +4,34 @@
|
|||||||
"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": "AGPL-3.0-or-later",
|
"license": "MPL-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"esinstall": "node ./esinstall.js",
|
"snowpack": "snowpack",
|
||||||
"sass": "sass --no-source-map --style=compressed style/"
|
"sass": "node-sass -o style style/*.sass --output-style compressed"
|
||||||
|
},
|
||||||
|
"snowpack": {
|
||||||
|
"install": [
|
||||||
|
"htm/preact",
|
||||||
|
"preact/hooks"
|
||||||
|
],
|
||||||
|
"installOptions": {
|
||||||
|
"sourceMap": false,
|
||||||
|
"dest": "lib",
|
||||||
|
"treeshake": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"htm": "^3.1.0",
|
"htm": "^3.0.4",
|
||||||
"preact": "^10.5.14",
|
"preact": "^10.5.5",
|
||||||
"esinstall": "^1.1.7",
|
"snowpack": "^2.16.1"
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/></svg>
|
|
Before Width: | Height: | Size: 313 B |
@ -1 +0,0 @@
|
|||||||
<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>
|
|
Before Width: | Height: | Size: 419 B |
23
web/setup/index.html
Normal file
23
web/setup/index.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<!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>
|
30
web/src/Button.js
Normal file
30
web/src/Button.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// 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
|
@ -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"
|
||||||
|
|
||||||
export const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false }) => {
|
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,3 +39,5 @@ export const Spinner = ({ size = 40, noCenter = false, noMargin = false, green =
|
|||||||
}
|
}
|
||||||
return comp
|
return comp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Spinner
|
@ -1,89 +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, Component } from "../lib/htm/preact.js"
|
|
||||||
import { checkMobileSafari, checkAndroid } from "./user-agent-detect.js"
|
|
||||||
|
|
||||||
export function shouldDisplayAutofocusSearchBar() {
|
|
||||||
return !checkMobileSafari() && !checkAndroid()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldAutofocusSearchBar() {
|
|
||||||
return localStorage.mauAutofocusSearchBar === 'true' && shouldDisplayAutofocusSearchBar()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function focusSearchBar() {
|
|
||||||
const inputInWebView = document.querySelector('.search-box input')
|
|
||||||
if (inputInWebView && shouldAutofocusSearchBar()) {
|
|
||||||
inputInWebView.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SearchBox extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props)
|
|
||||||
|
|
||||||
this.autofocus = shouldAutofocusSearchBar()
|
|
||||||
this.value = props.value
|
|
||||||
this.onSearch = props.onSearch
|
|
||||||
this.onReset = props.onReset
|
|
||||||
|
|
||||||
this.search = this.search.bind(this)
|
|
||||||
this.clearSearch = this.clearSearch.bind(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
focusSearchBar()
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillReceiveProps(props) {
|
|
||||||
this.value = props.value
|
|
||||||
}
|
|
||||||
|
|
||||||
search(e) {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
this.clearSearch()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.onSearch(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSearch() {
|
|
||||||
this.onReset()
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const isEmpty = !this.value
|
|
||||||
|
|
||||||
const className = `icon-display ${isEmpty ? null : 'reset-click-zone'}`
|
|
||||||
const title = isEmpty ? null : 'Click to reset'
|
|
||||||
const onClick = isEmpty ? null : this.clearSearch
|
|
||||||
const iconToDisplay = `icon-${isEmpty ? 'search' : 'reset'}`
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="search-box">
|
|
||||||
<input
|
|
||||||
placeholder="Find stickers …"
|
|
||||||
value=${this.value}
|
|
||||||
onKeyUp=${this.search}
|
|
||||||
autoFocus=${this.autofocus}
|
|
||||||
/>
|
|
||||||
<div class=${className} title=${title} onClick=${onClick}>
|
|
||||||
<span class="icon ${iconToDisplay}" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
}
|
|
73
web/src/setup/App.js
Normal file
73
web/src/setup/App.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// 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
|
207
web/src/setup/LoginView.js
Normal file
207
web/src/setup/LoginView.js
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
// 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
|
20
web/src/setup/index.js
Normal file
20
web/src/setup/index.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
// 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)
|
112
web/src/setup/matrix-api.js
Normal file
112
web/src/setup/matrix-api.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// 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",
|
||||||
|
})
|
34
web/src/setup/sticker-api.js
Normal file
34
web/src/setup/sticker-api.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// 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" },
|
||||||
|
)
|
73
web/src/setup/tryGet.js
Normal file
73
web/src/setup/tryGet.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// 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
|
||||||
|
}
|
@ -1,18 +0,0 @@
|
|||||||
export const getUserAgent = () => navigator.userAgent || navigator.vendor || window.opera
|
|
||||||
|
|
||||||
export const checkiOSDevice = () => {
|
|
||||||
const agent = getUserAgent()
|
|
||||||
return agent.match(/(iPod|iPhone|iPad)/)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkMobileSafari = () => {
|
|
||||||
const agent = getUserAgent()
|
|
||||||
return agent.match(/(iPod|iPhone|iPad)/) && agent.match(/AppleWebKit/)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkAndroid = () => {
|
|
||||||
const agent = getUserAgent()
|
|
||||||
return agent.match(/android/i)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkMobileDevice = () => checkiOSDevice() || checkAndroid()
|
|
@ -13,56 +13,41 @@
|
|||||||
//
|
//
|
||||||
// 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, render, Component } from "../lib/htm/preact.js"
|
import { html, render, Component } from "../../lib/htm/preact.js"
|
||||||
import { Spinner } from "./spinner.js"
|
|
||||||
import { shouldAutofocusSearchBar, shouldDisplayAutofocusSearchBar, SearchBox } from "./search-box.js"
|
import Spinner from "../Spinner.js"
|
||||||
import { checkMobileSafari } from "./user-agent-detect.js"
|
|
||||||
import * as widgetAPI from "./widget-api.js"
|
import * as widgetAPI from "./widget-api.js"
|
||||||
import * as frequent from "./frequently-used.js"
|
import * as frequent from "./frequently-used.js"
|
||||||
|
|
||||||
// The base URL for fetching packs. The app will first fetch ${PACK_BASE_URL}/index.json,
|
// 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.
|
// then ${PACK_BASE_URL}/${packFile} for each packFile in the packs object of the index.json file.
|
||||||
const PACKS_BASE_URL = "packs"
|
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
|
// This is updated from packs/index.json
|
||||||
let HOMESERVER_URL = "https://matrix-client.matrix.org"
|
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`
|
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
|
// 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
|
// This is also used to fix scrolling to sections on Element iOS
|
||||||
const isMobileSafari = checkMobileSafari()
|
const isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/)
|
||||||
|
&& navigator.userAgent.match(/AppleWebKit/)
|
||||||
|
|
||||||
// We need to detect iOS webkit / Android because autofocusing a field does not open
|
const query = Object.fromEntries(location.search
|
||||||
// the device keyboard by design, making the option obsolete
|
.substr(1).split("&")
|
||||||
const shouldAutofocusOption = shouldAutofocusSearchBar()
|
.map(part => part.split("="))
|
||||||
const displayAutofocusOption = shouldDisplayAutofocusSearchBar()
|
.map(([key, value = ""]) => [key, value]))
|
||||||
|
|
||||||
const supportedThemes = ["light", "dark", "black"]
|
const supportedThemes = ["light", "dark", "black"]
|
||||||
|
|
||||||
const defaultState = {
|
|
||||||
packs: [],
|
|
||||||
loading: true,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultSearchState = {
|
|
||||||
searchTerm: null,
|
|
||||||
filteredPacks: null
|
|
||||||
}
|
|
||||||
|
|
||||||
class App extends Component {
|
class App extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props)
|
super(props)
|
||||||
this.defaultTheme = params.get("theme")
|
this.defaultTheme = query.theme
|
||||||
this.state = {
|
this.state = {
|
||||||
...defaultState,
|
packs: [],
|
||||||
...defaultSearchState,
|
loading: true,
|
||||||
|
error: null,
|
||||||
stickersPerRow: parseInt(localStorage.mauStickersPerRow || "4"),
|
stickersPerRow: parseInt(localStorage.mauStickersPerRow || "4"),
|
||||||
theme: localStorage.mauStickerThemeOverride || this.defaultTheme,
|
theme: localStorage.mauStickerThemeOverride || this.defaultTheme,
|
||||||
frequentlyUsed: {
|
frequentlyUsed: {
|
||||||
@ -79,12 +64,11 @@ class App extends Component {
|
|||||||
this.defaultTheme = "light"
|
this.defaultTheme = "light"
|
||||||
}
|
}
|
||||||
this.stickersByID = new Map(JSON.parse(localStorage.mauFrequentlyUsedStickerCache || "[]"))
|
this.stickersByID = new Map(JSON.parse(localStorage.mauFrequentlyUsedStickerCache || "[]"))
|
||||||
this.state.frequentlyUsed.stickers = this._getStickersByID(this.state.frequentlyUsed.stickerIDs)
|
this.state.frequentlyUsed.stickers = this._getStickersByID(
|
||||||
|
this.state.frequentlyUsed.stickerIDs)
|
||||||
this.imageObserver = null
|
this.imageObserver = null
|
||||||
this.packListRef = null
|
this.packListRef = null
|
||||||
this.navRef = null
|
this.navRef = null
|
||||||
this.searchStickers = this.searchStickers.bind(this)
|
|
||||||
this.resetSearch = this.resetSearch.bind(this)
|
|
||||||
this.sendSticker = this.sendSticker.bind(this)
|
this.sendSticker = this.sendSticker.bind(this)
|
||||||
this.navScroll = this.navScroll.bind(this)
|
this.navScroll = this.navScroll.bind(this)
|
||||||
this.reloadPacks = this.reloadPacks.bind(this)
|
this.reloadPacks = this.reloadPacks.bind(this)
|
||||||
@ -106,39 +90,14 @@ class App extends Component {
|
|||||||
stickers,
|
stickers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(stickers.map(sticker => [sticker.id, sticker]))
|
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(
|
||||||
|
stickers.map(sticker => [sticker.id, sticker]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search
|
|
||||||
|
|
||||||
resetSearch() {
|
|
||||||
this.setState({ ...defaultSearchState })
|
|
||||||
}
|
|
||||||
|
|
||||||
searchStickers(searchTerm) {
|
|
||||||
const sanitizeString = s => s.toLowerCase().trim()
|
|
||||||
const sanitizedSearch = sanitizeString(searchTerm)
|
|
||||||
|
|
||||||
const allPacks = [this.state.frequentlyUsed, ...this.state.packs]
|
|
||||||
const packsWithFilteredStickers = allPacks.map(pack => ({
|
|
||||||
...pack,
|
|
||||||
stickers: pack.stickers.filter(sticker =>
|
|
||||||
sanitizeString(sticker.body).includes(sanitizedSearch) ||
|
|
||||||
sanitizeString(sticker.id).includes(sanitizedSearch)
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
const filteredPacks = packsWithFilteredStickers.filter(({ stickers }) => !!stickers.length)
|
|
||||||
|
|
||||||
this.setState({ searchTerm, filteredPacks })
|
|
||||||
}
|
|
||||||
|
|
||||||
// End search
|
|
||||||
|
|
||||||
// Settings
|
|
||||||
|
|
||||||
setStickersPerRow(val) {
|
setStickersPerRow(val) {
|
||||||
localStorage.mauStickersPerRow = val
|
localStorage.mauStickersPerRow = val
|
||||||
document.documentElement.style.setProperty("--stickers-per-row", localStorage.mauStickersPerRow)
|
document.documentElement.style.setProperty("--stickers-per-row",
|
||||||
|
localStorage.mauStickersPerRow)
|
||||||
this.setState({
|
this.setState({
|
||||||
stickersPerRow: val,
|
stickersPerRow: val,
|
||||||
})
|
})
|
||||||
@ -155,40 +114,42 @@ class App extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAutofocusSearchBar(checked) {
|
|
||||||
localStorage.mauAutofocusSearchBar = checked
|
|
||||||
}
|
|
||||||
|
|
||||||
// End settings
|
|
||||||
|
|
||||||
reloadPacks() {
|
reloadPacks() {
|
||||||
this.imageObserver.disconnect()
|
this.imageObserver.disconnect()
|
||||||
this.sectionObserver.disconnect()
|
this.sectionObserver.disconnect()
|
||||||
this.setState({ packs: defaultState.packs })
|
this.setState({ packs: [] })
|
||||||
this.resetSearch()
|
|
||||||
this._loadPacks(true)
|
this._loadPacks(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadPacks(disableCache = false) {
|
_loadPacks(disableCache = false) {
|
||||||
const cache = disableCache ? "no-cache" : undefined
|
const args = {
|
||||||
fetch(INDEX, { cache }).then(async indexRes => {
|
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) {
|
if (indexRes.status >= 400) {
|
||||||
|
try {
|
||||||
|
const errData = await indexRes.json()
|
||||||
|
this.setState({
|
||||||
|
loading: false,
|
||||||
|
error: errData.error,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
this.setState({
|
this.setState({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: indexRes.status !== 404 ? indexRes.statusText : null,
|
error: indexRes.status !== 404 ? indexRes.statusText : null,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const indexData = await indexRes.json()
|
const indexData = await indexRes.json()
|
||||||
HOMESERVER_URL = indexData.homeserver_url || HOMESERVER_URL
|
HOMESERVER_URL = indexData.homeserver_url || HOMESERVER_URL
|
||||||
// TODO only load pack metadata when scrolled into view?
|
// TODO only load pack metadata when scrolled into view?
|
||||||
for (const packFile of indexData.packs) {
|
for (const packFile of indexData.packs) {
|
||||||
let packRes
|
const packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, args)
|
||||||
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()
|
const packData = await packRes.json()
|
||||||
for (const sticker of packData.stickers) {
|
for (const sticker of packData.stickers) {
|
||||||
this.stickersByID.set(sticker.id, sticker)
|
this.stickersByID.set(sticker.id, sticker)
|
||||||
@ -199,11 +160,15 @@ class App extends Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
this.updateFrequentlyUsed()
|
this.updateFrequentlyUsed()
|
||||||
}, error => this.setState({ loading: false, error }))
|
}, error => this.setState({
|
||||||
|
loading: false,
|
||||||
|
error,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
document.documentElement.style.setProperty("--stickers-per-row", this.state.stickersPerRow.toString())
|
document.documentElement.style.setProperty("--stickers-per-row",
|
||||||
|
this.state.stickersPerRow.toString())
|
||||||
this._loadPacks()
|
this._loadPacks()
|
||||||
this.imageObserver = new IntersectionObserver(this.observeImageIntersections, {
|
this.imageObserver = new IntersectionObserver(this.observeImageIntersections, {
|
||||||
rootMargin: "100px",
|
rootMargin: "100px",
|
||||||
@ -232,9 +197,6 @@ class App extends Component {
|
|||||||
for (const entry of intersections) {
|
for (const entry of intersections) {
|
||||||
const packID = entry.target.getAttribute("data-pack-id")
|
const packID = entry.target.getAttribute("data-pack-id")
|
||||||
const navElement = document.getElementById(`nav-${packID}`)
|
const navElement = document.getElementById(`nav-${packID}`)
|
||||||
if (!navElement) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
navElement.classList.add("visible")
|
navElement.classList.add("visible")
|
||||||
const bb = navElement.getBoundingClientRect()
|
const bb = navElement.getBoundingClientRect()
|
||||||
@ -278,46 +240,44 @@ class App extends Component {
|
|||||||
const sticker = this.stickersByID.get(id)
|
const sticker = this.stickersByID.get(id)
|
||||||
frequent.add(id)
|
frequent.add(id)
|
||||||
this.updateFrequentlyUsed()
|
this.updateFrequentlyUsed()
|
||||||
this.resetSearch()
|
|
||||||
widgetAPI.sendSticker(sticker)
|
widgetAPI.sendSticker(sticker)
|
||||||
}
|
}
|
||||||
|
|
||||||
navScroll(evt) {
|
navScroll(evt) {
|
||||||
this.navRef.scrollLeft += evt.deltaY
|
this.navRef.scrollLeft += evt.deltaY * 12
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const theme = `theme-${this.state.theme}`
|
const theme = `theme-${this.state.theme}`
|
||||||
|
|
||||||
const filterActive = !!this.state.filteredPacks
|
|
||||||
const packs = filterActive ? this.state.filteredPacks : [this.state.frequentlyUsed, ...this.state.packs]
|
|
||||||
const noPacksForSearch = filterActive && packs.length === 0
|
|
||||||
|
|
||||||
if (this.state.loading) {
|
if (this.state.loading) {
|
||||||
return html`<main class="spinner ${theme}"><${Spinner} size=${80} green /></main>`
|
return html`
|
||||||
|
<main class="spinner ${theme}">
|
||||||
|
<${Spinner} size=${80} green />
|
||||||
|
</main>`
|
||||||
} else if (this.state.error) {
|
} else if (this.state.error) {
|
||||||
return html`<main class="error ${theme}">
|
return html`
|
||||||
|
<main class="error ${theme}">
|
||||||
<h1>Failed to load packs</h1>
|
<h1>Failed to load packs</h1>
|
||||||
<p>${this.state.error}</p>
|
<p>${this.state.error}</p>
|
||||||
</main>`
|
</main>`
|
||||||
} else if (this.state.packs.length === 0) {
|
} else if (this.state.packs.length === 0) {
|
||||||
return html`<main class="empty ${theme}"><h1>No packs found 😿</h1></main>`
|
return html`
|
||||||
|
<main class="empty ${theme}"><h1>No packs found 😿</h1></main>`
|
||||||
}
|
}
|
||||||
|
return html`
|
||||||
return html`<main class="has-content ${theme}">
|
<main class="has-content ${theme}">
|
||||||
<nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}>
|
<nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}>
|
||||||
<${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" />
|
<${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" />
|
||||||
${this.state.packs.map(pack => html`<${NavBarItem} id=${pack.id} pack=${pack}/>`)}
|
${this.state.packs.map(pack => html`
|
||||||
<${NavBarItem} pack=${{ id: "settings", title: "Settings" }} iconOverride="settings" />
|
<${NavBarItem} id=${pack.id} pack=${pack}/>`)}
|
||||||
|
<${NavBarItem} pack=${{ id: "settings", title: "Settings" }}
|
||||||
|
iconOverride="settings" />
|
||||||
</nav>
|
</nav>
|
||||||
<${SearchBox}
|
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}"
|
||||||
value=${this.state.searchTerm}
|
ref=${elem => this.packListRef = elem}>
|
||||||
onSearch=${this.searchStickers}
|
<${Pack} pack=${this.state.frequentlyUsed} send=${this.sendSticker}/>
|
||||||
onReset=${this.resetSearch}
|
${this.state.packs.map(pack => html`
|
||||||
/>
|
<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker}/>`)}
|
||||||
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${elem => this.packListRef = elem}>
|
|
||||||
${noPacksForSearch ? 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}/>
|
<${Settings} app=${this}/>
|
||||||
</div>
|
</div>
|
||||||
</main>`
|
</main>`
|
||||||
@ -331,7 +291,7 @@ const Settings = ({ app }) => html`
|
|||||||
<button onClick=${app.reloadPacks}>Reload</button>
|
<button onClick=${app.reloadPacks}>Reload</button>
|
||||||
<div>
|
<div>
|
||||||
<label for="stickers-per-row">Stickers per row: ${app.state.stickersPerRow}</label>
|
<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"
|
<input type="range" min=2 max=10 id="stickers-per-row"
|
||||||
value=${app.state.stickersPerRow}
|
value=${app.state.stickersPerRow}
|
||||||
onInput=${evt => app.setStickersPerRow(evt.target.value)}/>
|
onInput=${evt => app.setStickersPerRow(evt.target.value)}/>
|
||||||
</div>
|
</div>
|
||||||
@ -344,14 +304,6 @@ const Settings = ({ app }) => html`
|
|||||||
<option value="black">Black</option>
|
<option value="black">Black</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
${displayAutofocusOption ? html`<div>
|
|
||||||
Autofocus search bar:
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked=${shouldAutofocusOption}
|
|
||||||
onChange=${evt => app.setAutofocusSearchBar(evt.target.checked)}
|
|
||||||
/>
|
|
||||||
</div>` : null}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`
|
`
|
||||||
@ -360,13 +312,19 @@ const Settings = ({ app }) => html`
|
|||||||
// open the link in the browser instead of just scrolling there, so we need to scroll manually:
|
// open the link in the browser instead of just scrolling there, so we need to scroll manually:
|
||||||
const scrollToSection = (evt, id) => {
|
const scrollToSection = (evt, id) => {
|
||||||
const pack = document.getElementById(`pack-${id}`)
|
const pack = document.getElementById(`pack-${id}`)
|
||||||
pack.scrollIntoView({ block: "start", behavior: "instant" })
|
pack.scrollIntoView({
|
||||||
|
block: "start",
|
||||||
|
behavior: "instant",
|
||||||
|
})
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavBarItem = ({ pack, iconOverride = null }) => html`
|
const NavBarItem = ({
|
||||||
|
pack,
|
||||||
|
iconOverride = null,
|
||||||
|
}) => html`
|
||||||
<a href="#pack-${pack.id}" id="nav-${pack.id}" data-pack-id=${pack.id} title=${pack.title}
|
<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}>
|
onClick=${isMobileSafari ? evt => scrollToSection(evt, pack.id) : undefined}>
|
||||||
<div class="sticker">
|
<div class="sticker">
|
||||||
${iconOverride ? html`
|
${iconOverride ? html`
|
||||||
<span class="icon icon-${iconOverride}" />
|
<span class="icon icon-${iconOverride}" />
|
||||||
@ -378,7 +336,10 @@ const NavBarItem = ({ pack, iconOverride = null }) => html`
|
|||||||
</a>
|
</a>
|
||||||
`
|
`
|
||||||
|
|
||||||
const Pack = ({ pack, send }) => html`
|
const Pack = ({
|
||||||
|
pack,
|
||||||
|
send,
|
||||||
|
}) => html`
|
||||||
<section class="stickerpack" id="pack-${pack.id}" data-pack-id=${pack.id}>
|
<section class="stickerpack" id="pack-${pack.id}" data-pack-id=${pack.id}>
|
||||||
<h1>${pack.title}</h1>
|
<h1>${pack.title}</h1>
|
||||||
<div class="sticker-list">
|
<div class="sticker-list">
|
||||||
@ -389,9 +350,12 @@ const Pack = ({ pack, send }) => html`
|
|||||||
</section>
|
</section>
|
||||||
`
|
`
|
||||||
|
|
||||||
const Sticker = ({ content, send }) => html`
|
const Sticker = ({
|
||||||
|
content,
|
||||||
|
send,
|
||||||
|
}) => html`
|
||||||
<div class="sticker" onClick=${send} data-sticker-id=${content.id}>
|
<div class="sticker" onClick=${send} data-sticker-id=${content.id}>
|
||||||
<img data-src=${makeThumbnailURL(content.url)} alt=${content.body} title=${content.body} />
|
<img data-src=${makeThumbnailURL(content.url)} alt=${content.body}/>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
@ -13,8 +13,6 @@
|
|||||||
//
|
//
|
||||||
// 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 { focusSearchBar } from "./search-box.js"
|
|
||||||
|
|
||||||
let widgetId = null
|
let widgetId = null
|
||||||
|
|
||||||
window.onmessage = event => {
|
window.onmessage = event => {
|
||||||
@ -35,12 +33,10 @@ window.onmessage = event => {
|
|||||||
widgetId = request.widgetId
|
widgetId = request.widgetId
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = {}
|
let response
|
||||||
|
|
||||||
if (request.action === "visibility") { // visibility of the widget changed
|
if (request.action === "visibility") {
|
||||||
if (request.visible) {
|
response = {}
|
||||||
focusSearchBar() // we have to re-focus the search bar when appropriate
|
|
||||||
}
|
|
||||||
} else if (request.action === "capabilities") {
|
} else if (request.action === "capabilities") {
|
||||||
response = { capabilities: ["m.sticker"] }
|
response = { capabilities: ["m.sticker"] }
|
||||||
} else {
|
} else {
|
1
web/style/button.css
Normal file
1
web/style/button.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
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}
|
60
web/style/button.sass
Normal file
60
web/style/button.sass
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// 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
|
@ -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) 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-search{--icon-image: url(../res/search.svg)}.icon.icon-reset{--icon-image: url(../res/reset.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{display:flex;margin:.5rem;padding:0;border-radius:.4rem;background-color:var(--search-box-color);opacity:.5;transition:all .1s;border:1px solid transparent}div.search-box:focus-within{opacity:1;border:1px solid var(--text-color)}div.search-box:not(:focus-within):hover{opacity:.7}div.search-box input,div.search-box .icon-display{color:var(--text-color);outline:none;border:none;height:1rem;margin:0;padding:.7rem;background-color:transparent;-webkit-tap-highlight-color:transparent}div.search-box .icon-display{width:1rem}div.search-box .icon-display.reset-click-zone{cursor:pointer}div.search-box .icon-display .icon{display:block;width:1rem;height:1rem}div.search-box .icon-display .icon-search{opacity:.5}div.search-box input{flex-grow:1;font-size:1rem}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:not([type=checkbox]){width:100%}
|
|
1
web/style/setup-login.css
Normal file
1
web/style/setup-login.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
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}
|
105
web/style/setup-login.sass
Normal file
105
web/style/setup-login.sass
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
// 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
web/style/setup.css
Normal file
1
web/style/setup.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
body{font-family:sans-serif}
|
18
web/style/setup.sass
Normal file
18
web/style/setup.sass
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// 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/>.
|
||||||
|
|
||||||
|
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 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)}}
|
.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)}}
|
||||||
|
0
web/style/theme.css
Normal file
0
web/style/theme.css
Normal file
33
web/style/theme.sass
Normal file
33
web/style/theme.sass
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// 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
web/style/widget.css
Normal file
1
web/style/widget.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
*{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%}
|
@ -32,12 +32,6 @@ $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)
|
||||||
|
|
||||||
@ -56,24 +50,22 @@ main
|
|||||||
left: 0
|
left: 0
|
||||||
right: 0
|
right: 0
|
||||||
bottom: 0
|
bottom: 0
|
||||||
|
|
||||||
display: grid
|
display: grid
|
||||||
grid-template-rows: $nav-height min-content auto
|
grid-template-rows: $nav-height 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
|
||||||
|
|
||||||
@ -92,12 +84,6 @@ main.theme-black
|
|||||||
&.icon-recent
|
&.icon-recent
|
||||||
--icon-image: url(../res/recent.svg)
|
--icon-image: url(../res/recent.svg)
|
||||||
|
|
||||||
&.icon-search
|
|
||||||
--icon-image: url(../res/search.svg)
|
|
||||||
|
|
||||||
&.icon-reset
|
|
||||||
--icon-image: url(../res/reset.svg)
|
|
||||||
|
|
||||||
nav
|
nav
|
||||||
display: flex
|
display: flex
|
||||||
overflow-x: auto
|
overflow-x: auto
|
||||||
@ -123,16 +109,12 @@ div.pack-list
|
|||||||
|
|
||||||
div.pack-list.ios-safari-hack
|
div.pack-list.ios-safari-hack
|
||||||
position: fixed
|
position: fixed
|
||||||
top: calc(#{$nav-height} + #{$search-box-height})
|
top: $nav-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
|
||||||
|
|
||||||
@ -168,51 +150,6 @@ div.sticker
|
|||||||
height: 70%
|
height: 70%
|
||||||
margin: 15%
|
margin: 15%
|
||||||
|
|
||||||
div.search-box
|
|
||||||
display: flex
|
|
||||||
margin: $search-box-input-margin
|
|
||||||
padding: 0
|
|
||||||
border-radius: .4rem
|
|
||||||
background-color: var(--search-box-color)
|
|
||||||
opacity: .5
|
|
||||||
transition: all .1s
|
|
||||||
border: 1px solid transparent
|
|
||||||
|
|
||||||
&:focus-within
|
|
||||||
opacity: 1
|
|
||||||
border: 1px solid var(--text-color)
|
|
||||||
|
|
||||||
&:not(:focus-within):hover
|
|
||||||
opacity: .7
|
|
||||||
|
|
||||||
input,.icon-display
|
|
||||||
color: var(--text-color)
|
|
||||||
outline: none
|
|
||||||
border: none
|
|
||||||
height: $search-box-input-height
|
|
||||||
margin: 0
|
|
||||||
padding: $search-box-input-padding
|
|
||||||
background-color: transparent
|
|
||||||
-webkit-tap-highlight-color: transparent
|
|
||||||
|
|
||||||
.icon-display
|
|
||||||
width: $search-box-input-height
|
|
||||||
|
|
||||||
&.reset-click-zone
|
|
||||||
cursor: pointer
|
|
||||||
|
|
||||||
.icon
|
|
||||||
display: block
|
|
||||||
width: $search-box-icon-size
|
|
||||||
height: $search-box-icon-size
|
|
||||||
|
|
||||||
.icon-search
|
|
||||||
opacity: .5
|
|
||||||
|
|
||||||
input
|
|
||||||
flex-grow: 1
|
|
||||||
font-size: 1rem
|
|
||||||
|
|
||||||
div.settings-list
|
div.settings-list
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
@ -224,5 +161,5 @@ div.settings-list
|
|||||||
padding: .5rem
|
padding: .5rem
|
||||||
border-radius: .25rem
|
border-radius: .25rem
|
||||||
|
|
||||||
input:not([type="checkbox"])
|
input
|
||||||
width: 100%
|
width: 100%
|
3112
web/yarn.lock
3112
web/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user