Compare commits
	
		
			7 Commits
		
	
	
		
			main
			...
			5474c5a101
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 5474c5a101 | |||
| 4b7261fa20 | |||
| 4992ff3a2d | |||
| 88ee0aa6f0 | |||
| 392206c19e | |||
| 
						
						
							
						
						f9e95e5733
	
				 | 
					
					
						|||
| 
						
						
							
						
						1444c945de
	
				 | 
					
					
						
@@ -14,7 +14,7 @@ IndentWidth: 4
 | 
			
		||||
MaxEmptyLinesToKeep: 1
 | 
			
		||||
ObjCBlockIndentWidth: 4
 | 
			
		||||
ObjCBreakBeforeNestedBlockParam: false
 | 
			
		||||
SortIncludes: true
 | 
			
		||||
SortIncludes: false
 | 
			
		||||
TabWidth: 4
 | 
			
		||||
UseTab: Always
 | 
			
		||||
...
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
.git
 | 
			
		||||
db.sqlite*
 | 
			
		||||
out/
 | 
			
		||||
.svn
 | 
			
		||||
db.sqlite
 | 
			
		||||
out/**/*.o
 | 
			
		||||
out/**/*.d
 | 
			
		||||
 
 | 
			
		||||
@@ -1,71 +0,0 @@
 | 
			
		||||
name: Build Tilde Friends
 | 
			
		||||
run-name: ${{ gitea.actor }} running 🚀
 | 
			
		||||
on: [push]
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  Build-All:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    container:
 | 
			
		||||
      image: node:23-bookworm-slim
 | 
			
		||||
      valid_volumes:
 | 
			
		||||
        - '/opt/keys'
 | 
			
		||||
        - '/opt/deps'
 | 
			
		||||
      volumes:
 | 
			
		||||
        - /opt/keys:/opt/keys
 | 
			
		||||
        - /opt/deps:/opt/deps
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Install build dependencies
 | 
			
		||||
        run: >
 | 
			
		||||
          apt update && apt install -y \
 | 
			
		||||
            build-essential \
 | 
			
		||||
            clang-19 \
 | 
			
		||||
            cmake \
 | 
			
		||||
            curl \
 | 
			
		||||
            docker.io \
 | 
			
		||||
            doxygen \
 | 
			
		||||
            file \
 | 
			
		||||
            gcc-aarch64-linux-gnu \
 | 
			
		||||
            git \
 | 
			
		||||
            graphviz \
 | 
			
		||||
            libgpgme11 \
 | 
			
		||||
            libssl-dev \
 | 
			
		||||
            mingw-w64 \
 | 
			
		||||
            rsync \
 | 
			
		||||
            unzip \
 | 
			
		||||
            zip \
 | 
			
		||||
            zlib1g-dev
 | 
			
		||||
      - name: Get code
 | 
			
		||||
        uses: actions/checkout@v4
 | 
			
		||||
        with:
 | 
			
		||||
          submodules: true
 | 
			
		||||
      - name: Setup environment
 | 
			
		||||
        run: |
 | 
			
		||||
          update-alternatives --install /usr/bin/clang clang /usr/bin/clang-19 100
 | 
			
		||||
          update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-19 100
 | 
			
		||||
          ln -s /opt/keys .keys
 | 
			
		||||
          ln -sf /opt/deps/ios_toolchain deps/
 | 
			
		||||
          ln -sf /opt/deps/macos_toolchain deps/
 | 
			
		||||
      - name: Build documentation
 | 
			
		||||
        run: |
 | 
			
		||||
          mkdir -p out/html/ ~/.ssh/
 | 
			
		||||
          make -j`nproc` docs
 | 
			
		||||
          echo 'pildefriends ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKD3Kde5vDO0TrMBDK0IGGeNGe/XinWAZkSQ/rXxwUjt' >> ~/.ssh/known_hosts
 | 
			
		||||
          rsync -avP --delete -e "ssh -i /opt/keys/ssh.ed25519" out/html/ tfdocs@pildefriends:docs/html/
 | 
			
		||||
      - name: Setup JDK
 | 
			
		||||
        uses: actions/setup-java@v3
 | 
			
		||||
        with:
 | 
			
		||||
          java-version: '17'
 | 
			
		||||
          distribution: 'temurin'
 | 
			
		||||
      - name: Setup Android SDK
 | 
			
		||||
        uses: android-actions/setup-android@v3
 | 
			
		||||
        with:
 | 
			
		||||
          packages: 'tools platform-tools build-tools;35.0.0 platforms;android-35 ndk;27.2.12479018'
 | 
			
		||||
      - name: Docker build
 | 
			
		||||
        run: DOCKER_BUILDKIT=1 docker build .
 | 
			
		||||
      - name: Build
 | 
			
		||||
        run: ANDROID_SDK=$HOME/.android/sdk make -j`nproc` all dist
 | 
			
		||||
      - name: Upload artifacts
 | 
			
		||||
        uses: actions/upload-artifact@v3
 | 
			
		||||
        with:
 | 
			
		||||
          name: dist
 | 
			
		||||
          path: dist/*
 | 
			
		||||
							
								
								
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,20 +1,10 @@
 | 
			
		||||
build/
 | 
			
		||||
*.core
 | 
			
		||||
db.*
 | 
			
		||||
deps/ios_toolchain
 | 
			
		||||
deps/macos_toolchain
 | 
			
		||||
deps/ios_toolchain/
 | 
			
		||||
deps/openssl/
 | 
			
		||||
dist/
 | 
			
		||||
.flatpak-builder
 | 
			
		||||
.keys
 | 
			
		||||
**/.DS_Store
 | 
			
		||||
logs/
 | 
			
		||||
**/node_modules
 | 
			
		||||
out
 | 
			
		||||
repo/
 | 
			
		||||
result
 | 
			
		||||
*.swo
 | 
			
		||||
*.swp
 | 
			
		||||
tmp/
 | 
			
		||||
unsigned/
 | 
			
		||||
.zsign_cache/
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										27
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										27
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							@@ -1,27 +0,0 @@
 | 
			
		||||
[submodule "deps/zlib"]
 | 
			
		||||
	path = deps/zlib
 | 
			
		||||
	url = https://github.com/madler/zlib.git
 | 
			
		||||
[submodule "deps/libsodium"]
 | 
			
		||||
	path = deps/libsodium
 | 
			
		||||
	url = https://github.com/jedisct1/libsodium.git
 | 
			
		||||
[submodule "deps/quickjs"]
 | 
			
		||||
	path = deps/quickjs
 | 
			
		||||
	url = https://github.com/bellard/quickjs.git
 | 
			
		||||
[submodule "deps/crypt_blowfish"]
 | 
			
		||||
	path = deps/crypt_blowfish
 | 
			
		||||
	url = https://github.com/openwall/crypt_blowfish.git
 | 
			
		||||
[submodule "deps/libbacktrace"]
 | 
			
		||||
	path = deps/libbacktrace
 | 
			
		||||
	url = https://github.com/ianlancetaylor/libbacktrace.git
 | 
			
		||||
[submodule "deps/libuv"]
 | 
			
		||||
	path = deps/libuv
 | 
			
		||||
	url = https://github.com/libuv/libuv.git
 | 
			
		||||
[submodule "deps/picohttpparser"]
 | 
			
		||||
	path = deps/picohttpparser
 | 
			
		||||
	url = https://github.com/h2o/picohttpparser.git
 | 
			
		||||
[submodule "deps/c-ares"]
 | 
			
		||||
	path = deps/c-ares
 | 
			
		||||
	url = https://github.com/c-ares/c-ares.git
 | 
			
		||||
[submodule "deps/zsign"]
 | 
			
		||||
	path = deps/zsign
 | 
			
		||||
	url = https://github.com/zhlynn/zsign.git
 | 
			
		||||
@@ -2,7 +2,6 @@ node_modules
 | 
			
		||||
src
 | 
			
		||||
deps
 | 
			
		||||
.clang-format
 | 
			
		||||
flake.lock
 | 
			
		||||
 | 
			
		||||
# Minified files
 | 
			
		||||
**/*.min.css
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,19 @@
 | 
			
		||||
FROM bitnami/minideb:bookworm AS build
 | 
			
		||||
FROM bitnami/minideb:bullseye AS build
 | 
			
		||||
 | 
			
		||||
RUN apt-get update && \
 | 
			
		||||
	apt-get install -y --no-install-recommends \
 | 
			
		||||
		gcc \
 | 
			
		||||
		libc6-dev \
 | 
			
		||||
		libssl-dev \
 | 
			
		||||
		make
 | 
			
		||||
 | 
			
		||||
COPY . /app
 | 
			
		||||
RUN make -C /app -j $(nproc) release
 | 
			
		||||
 | 
			
		||||
FROM bitnami/minideb:bookworm
 | 
			
		||||
FROM bitnami/minideb:bullseye
 | 
			
		||||
RUN apt-get update && \
 | 
			
		||||
	apt-get install -y --no-install-recommends \
 | 
			
		||||
		libssl1.1
 | 
			
		||||
 | 
			
		||||
COPY --from=build /app/out/release/tildefriends /app/out/release/tildefriends
 | 
			
		||||
COPY --from=build /app/apps /app/apps
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										379
									
								
								Doxyfile
									
									
									
									
									
								
							
							
						
						
									
										379
									
								
								Doxyfile
									
									
									
									
									
								
							@@ -1,4 +1,4 @@
 | 
			
		||||
# Doxyfile 1.9.4
 | 
			
		||||
# Doxyfile 1.9.1
 | 
			
		||||
 | 
			
		||||
# This file describes the settings to be used by the documentation system
 | 
			
		||||
# doxygen (www.doxygen.org) for a project.
 | 
			
		||||
@@ -12,15 +12,6 @@
 | 
			
		||||
# For lists, items can also be appended using:
 | 
			
		||||
# TAG += value [value, ...]
 | 
			
		||||
# Values that contain spaces should be placed between quotes (\" \").
 | 
			
		||||
#
 | 
			
		||||
# Note:
 | 
			
		||||
#
 | 
			
		||||
# Use doxygen to compare the used configuration file with the template
 | 
			
		||||
# configuration file:
 | 
			
		||||
# doxygen -x [configFile]
 | 
			
		||||
# Use doxygen to compare the used configuration file with the template
 | 
			
		||||
# configuration file without replacing the environment variables:
 | 
			
		||||
# doxygen -x_noenv [configFile]
 | 
			
		||||
 | 
			
		||||
#---------------------------------------------------------------------------
 | 
			
		||||
# Project related configuration options
 | 
			
		||||
@@ -69,28 +60,16 @@ PROJECT_LOGO           =
 | 
			
		||||
 | 
			
		||||
OUTPUT_DIRECTORY       =
 | 
			
		||||
 | 
			
		||||
# If the CREATE_SUBDIRS tag is set to YES then doxygen will create up to 4096
 | 
			
		||||
# sub-directories (in 2 levels) under the output directory of each output format
 | 
			
		||||
# and will distribute the generated files over these directories. Enabling this
 | 
			
		||||
# If the CREATE_SUBDIRS tag is set to YES then doxygen will create 4096 sub-
 | 
			
		||||
# directories (in 2 levels) under the output directory of each output format and
 | 
			
		||||
# will distribute the generated files over these directories. Enabling this
 | 
			
		||||
# option can be useful when feeding doxygen a huge amount of source files, where
 | 
			
		||||
# putting all generated files in the same directory would otherwise causes
 | 
			
		||||
# performance problems for the file system. Adapt CREATE_SUBDIRS_LEVEL to
 | 
			
		||||
# control the number of sub-directories.
 | 
			
		||||
# performance problems for the file system.
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
 | 
			
		||||
CREATE_SUBDIRS         = NO
 | 
			
		||||
 | 
			
		||||
# Controls the number of sub-directories that will be created when
 | 
			
		||||
# CREATE_SUBDIRS tag is set to YES. Level 0 represents 16 directories, and every
 | 
			
		||||
# level increment doubles the number of directories, resulting in 4096
 | 
			
		||||
# directories at level 8 which is the default and also the maximum value. The
 | 
			
		||||
# sub-directories are organized in 2 levels, the first level always has a fixed
 | 
			
		||||
# numer of 16 directories.
 | 
			
		||||
# Minimum value: 0, maximum value: 8, default value: 8.
 | 
			
		||||
# This tag requires that the tag CREATE_SUBDIRS is set to YES.
 | 
			
		||||
 | 
			
		||||
CREATE_SUBDIRS_LEVEL   = 8
 | 
			
		||||
 | 
			
		||||
# If the ALLOW_UNICODE_NAMES tag is set to YES, doxygen will allow non-ASCII
 | 
			
		||||
# characters to appear in the names of generated files. If set to NO, non-ASCII
 | 
			
		||||
# characters will be escaped, for example _xE3_x81_x84 will be used for Unicode
 | 
			
		||||
@@ -102,18 +81,26 @@ ALLOW_UNICODE_NAMES    = NO
 | 
			
		||||
# The OUTPUT_LANGUAGE tag is used to specify the language in which all
 | 
			
		||||
# documentation generated by doxygen is written. Doxygen will use this
 | 
			
		||||
# information to generate all constant output in the proper language.
 | 
			
		||||
# Possible values are: Afrikaans, Arabic, Armenian, Brazilian, Bulgarian,
 | 
			
		||||
# Catalan, Chinese, Chinese-Traditional, Croatian, Czech, Danish, Dutch, English
 | 
			
		||||
# (United States), Esperanto, Farsi (Persian), Finnish, French, German, Greek,
 | 
			
		||||
# Hindi, Hungarian, Indonesian, Italian, Japanese, Japanese-en (Japanese with
 | 
			
		||||
# English messages), Korean, Korean-en (Korean with English messages), Latvian,
 | 
			
		||||
# Lithuanian, Macedonian, Norwegian, Persian (Farsi), Polish, Portuguese,
 | 
			
		||||
# Romanian, Russian, Serbian, Serbian-Cyrillic, Slovak, Slovene, Spanish,
 | 
			
		||||
# Swedish, Turkish, Ukrainian and Vietnamese.
 | 
			
		||||
# Possible values are: Afrikaans, Arabic, Armenian, Brazilian, Catalan, Chinese,
 | 
			
		||||
# Chinese-Traditional, Croatian, Czech, Danish, Dutch, English (United States),
 | 
			
		||||
# Esperanto, Farsi (Persian), Finnish, French, German, Greek, Hungarian,
 | 
			
		||||
# Indonesian, Italian, Japanese, Japanese-en (Japanese with English messages),
 | 
			
		||||
# Korean, Korean-en (Korean with English messages), Latvian, Lithuanian,
 | 
			
		||||
# Macedonian, Norwegian, Persian (Farsi), Polish, Portuguese, Romanian, Russian,
 | 
			
		||||
# Serbian, Serbian-Cyrillic, Slovak, Slovene, Spanish, Swedish, Turkish,
 | 
			
		||||
# Ukrainian and Vietnamese.
 | 
			
		||||
# The default value is: English.
 | 
			
		||||
 | 
			
		||||
OUTPUT_LANGUAGE        = English
 | 
			
		||||
 | 
			
		||||
# The OUTPUT_TEXT_DIRECTION tag is used to specify the direction in which all
 | 
			
		||||
# documentation generated by doxygen is written. Doxygen will use this
 | 
			
		||||
# information to generate all generated output in the proper direction.
 | 
			
		||||
# Possible values are: None, LTR, RTL and Context.
 | 
			
		||||
# The default value is: None.
 | 
			
		||||
 | 
			
		||||
OUTPUT_TEXT_DIRECTION  = None
 | 
			
		||||
 | 
			
		||||
# If the BRIEF_MEMBER_DESC tag is set to YES, doxygen will include brief member
 | 
			
		||||
# descriptions after the members that are listed in the file and class
 | 
			
		||||
# documentation (similar to Javadoc). Set to NO to disable this.
 | 
			
		||||
@@ -271,16 +258,16 @@ TAB_SIZE               = 4
 | 
			
		||||
# the documentation. An alias has the form:
 | 
			
		||||
# name=value
 | 
			
		||||
# For example adding
 | 
			
		||||
# "sideeffect=@par Side Effects:^^"
 | 
			
		||||
# "sideeffect=@par Side Effects:\n"
 | 
			
		||||
# will allow you to put the command \sideeffect (or @sideeffect) in the
 | 
			
		||||
# documentation, which will result in a user-defined paragraph with heading
 | 
			
		||||
# "Side Effects:". Note that you cannot put \n's in the value part of an alias
 | 
			
		||||
# to insert newlines (in the resulting output). You can put ^^ in the value part
 | 
			
		||||
# of an alias to insert a newline as if a physical newline was in the original
 | 
			
		||||
# file. When you need a literal { or } or , in the value part of an alias you
 | 
			
		||||
# have to escape them by means of a backslash (\), this can lead to conflicts
 | 
			
		||||
# with the commands \{ and \} for these it is advised to use the version @{ and
 | 
			
		||||
# @} or use a double escape (\\{ and \\})
 | 
			
		||||
# "Side Effects:". You can put \n's in the value part of an alias to insert
 | 
			
		||||
# newlines (in the resulting output). You can put ^^ in the value part of an
 | 
			
		||||
# alias to insert a newline as if a physical newline was in the original file.
 | 
			
		||||
# When you need a literal { or } or , in the value part of an alias you have to
 | 
			
		||||
# escape them by means of a backslash (\), this can lead to conflicts with the
 | 
			
		||||
# commands \{ and \} for these it is advised to use the version @{ and @} or use
 | 
			
		||||
# a double escape (\\{ and \\})
 | 
			
		||||
 | 
			
		||||
ALIASES                =
 | 
			
		||||
 | 
			
		||||
@@ -325,8 +312,8 @@ OPTIMIZE_OUTPUT_SLICE  = NO
 | 
			
		||||
# extension. Doxygen has a built-in mapping, but you can override or extend it
 | 
			
		||||
# using this tag. The format is ext=language, where ext is a file extension, and
 | 
			
		||||
# language is one of the parsers supported by doxygen: IDL, Java, JavaScript,
 | 
			
		||||
# Csharp (C#), C, C++, Lex, D, PHP, md (Markdown), Objective-C, Python, Slice,
 | 
			
		||||
# VHDL, Fortran (fixed format Fortran: FortranFixed, free formatted Fortran:
 | 
			
		||||
# Csharp (C#), C, C++, D, PHP, md (Markdown), Objective-C, Python, Slice, VHDL,
 | 
			
		||||
# Fortran (fixed format Fortran: FortranFixed, free formatted Fortran:
 | 
			
		||||
# FortranFree, unknown formatted Fortran: Fortran. In the later case the parser
 | 
			
		||||
# tries to guess whether the code is fixed or free formatted code, this is the
 | 
			
		||||
# default for Fortran type files). For instance to make doxygen treat .inc files
 | 
			
		||||
@@ -341,7 +328,7 @@ OPTIMIZE_OUTPUT_SLICE  = NO
 | 
			
		||||
#
 | 
			
		||||
# Note see also the list of default file extension mappings.
 | 
			
		||||
 | 
			
		||||
EXTENSION_MAPPING      = js=javascript
 | 
			
		||||
EXTENSION_MAPPING      =
 | 
			
		||||
 | 
			
		||||
# If the MARKDOWN_SUPPORT tag is enabled then doxygen pre-processes all comments
 | 
			
		||||
# according to the Markdown format, which allows for more readable
 | 
			
		||||
@@ -473,13 +460,13 @@ TYPEDEF_HIDES_STRUCT   = NO
 | 
			
		||||
 | 
			
		||||
LOOKUP_CACHE_SIZE      = 0
 | 
			
		||||
 | 
			
		||||
# The NUM_PROC_THREADS specifies the number of threads doxygen is allowed to use
 | 
			
		||||
# The NUM_PROC_THREADS specifies the number threads doxygen is allowed to use
 | 
			
		||||
# during processing. When set to 0 doxygen will based this on the number of
 | 
			
		||||
# cores available in the system. You can set it explicitly to a value larger
 | 
			
		||||
# than 0 to get more control over the balance between CPU load and processing
 | 
			
		||||
# speed. At this moment only the input processing can be done using multiple
 | 
			
		||||
# threads. Since this is still an experimental feature the default is set to 1,
 | 
			
		||||
# which effectively disables parallel processing. Please report any issues you
 | 
			
		||||
# which efficively disables parallel processing. Please report any issues you
 | 
			
		||||
# encounter. Generating dot graphs in parallel is controlled by the
 | 
			
		||||
# DOT_NUM_THREADS setting.
 | 
			
		||||
# Minimum value: 0, maximum value: 32, default value: 1.
 | 
			
		||||
@@ -598,7 +585,7 @@ INTERNAL_DOCS          = NO
 | 
			
		||||
# filesystem is case sensitive (i.e. it supports files in the same directory
 | 
			
		||||
# whose names only differ in casing), the option must be set to YES to properly
 | 
			
		||||
# deal with such files in case they appear in the input. For filesystems that
 | 
			
		||||
# are not case sensitive the option should be set to NO to properly deal with
 | 
			
		||||
# are not case sensitive the option should be be set to NO to properly deal with
 | 
			
		||||
# output files written for symbols that only differ in casing, such as for two
 | 
			
		||||
# classes, one named CLASS and the other named Class, and to also support
 | 
			
		||||
# references to files without having to specify the exact matching casing. On
 | 
			
		||||
@@ -623,12 +610,6 @@ HIDE_SCOPE_NAMES       = NO
 | 
			
		||||
 | 
			
		||||
HIDE_COMPOUND_REFERENCE= NO
 | 
			
		||||
 | 
			
		||||
# If the SHOW_HEADERFILE tag is set to YES then the documentation for a class
 | 
			
		||||
# will show which file needs to be included to use the class.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
 | 
			
		||||
SHOW_HEADERFILE        = YES
 | 
			
		||||
 | 
			
		||||
# If the SHOW_INCLUDE_FILES tag is set to YES then doxygen will put a list of
 | 
			
		||||
# the files that are included by a file in the documentation of that file.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
@@ -786,8 +767,7 @@ FILE_VERSION_FILTER    =
 | 
			
		||||
# output files in an output format independent way. To create the layout file
 | 
			
		||||
# that represents doxygen's defaults, run doxygen with the -l option. You can
 | 
			
		||||
# optionally specify a file name after the option, if omitted DoxygenLayout.xml
 | 
			
		||||
# will be used as the name of the layout file. See also section "Changing the
 | 
			
		||||
# layout of pages" for information.
 | 
			
		||||
# will be used as the name of the layout file.
 | 
			
		||||
#
 | 
			
		||||
# Note that if you run doxygen from a directory containing a file called
 | 
			
		||||
# DoxygenLayout.xml, doxygen will parse it automatically even if the LAYOUT_FILE
 | 
			
		||||
@@ -833,26 +813,18 @@ WARNINGS               = YES
 | 
			
		||||
WARN_IF_UNDOCUMENTED   = YES
 | 
			
		||||
 | 
			
		||||
# If the WARN_IF_DOC_ERROR tag is set to YES, doxygen will generate warnings for
 | 
			
		||||
# potential errors in the documentation, such as documenting some parameters in
 | 
			
		||||
# a documented function twice, or documenting parameters that don't exist or
 | 
			
		||||
# using markup commands wrongly.
 | 
			
		||||
# potential errors in the documentation, such as not documenting some parameters
 | 
			
		||||
# in a documented function, or documenting parameters that don't exist or using
 | 
			
		||||
# markup commands wrongly.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
 | 
			
		||||
WARN_IF_DOC_ERROR      = YES
 | 
			
		||||
 | 
			
		||||
# If WARN_IF_INCOMPLETE_DOC is set to YES, doxygen will warn about incomplete
 | 
			
		||||
# function parameter documentation. If set to NO, doxygen will accept that some
 | 
			
		||||
# parameters have no documentation without warning.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
 | 
			
		||||
WARN_IF_INCOMPLETE_DOC = YES
 | 
			
		||||
 | 
			
		||||
# This WARN_NO_PARAMDOC option can be enabled to get warnings for functions that
 | 
			
		||||
# are documented, but have no documentation for their parameters or return
 | 
			
		||||
# value. If set to NO, doxygen will only warn about wrong parameter
 | 
			
		||||
# documentation, but not about the absence of documentation. If EXTRACT_ALL is
 | 
			
		||||
# set to YES then this flag will automatically be disabled. See also
 | 
			
		||||
# WARN_IF_INCOMPLETE_DOC
 | 
			
		||||
# value. If set to NO, doxygen will only warn about wrong or incomplete
 | 
			
		||||
# parameter documentation, but not about the absence of documentation. If
 | 
			
		||||
# EXTRACT_ALL is set to YES then this flag will automatically be disabled.
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
 | 
			
		||||
WARN_NO_PARAMDOC       = NO
 | 
			
		||||
@@ -872,27 +844,13 @@ WARN_AS_ERROR          = NO
 | 
			
		||||
# and the warning text. Optionally the format may contain $version, which will
 | 
			
		||||
# be replaced by the version of the file (if it could be obtained via
 | 
			
		||||
# FILE_VERSION_FILTER)
 | 
			
		||||
# See also: WARN_LINE_FORMAT
 | 
			
		||||
# The default value is: $file:$line: $text.
 | 
			
		||||
 | 
			
		||||
WARN_FORMAT            = "$file:$line: $text"
 | 
			
		||||
 | 
			
		||||
# In the $text part of the WARN_FORMAT command it is possible that a reference
 | 
			
		||||
# to a more specific place is given. To make it easier to jump to this place
 | 
			
		||||
# (outside of doxygen) the user can define a custom "cut" / "paste" string.
 | 
			
		||||
# Example:
 | 
			
		||||
# WARN_LINE_FORMAT = "'vi $file +$line'"
 | 
			
		||||
# See also: WARN_FORMAT
 | 
			
		||||
# The default value is: at line $line of file $file.
 | 
			
		||||
 | 
			
		||||
WARN_LINE_FORMAT       = "at line $line of file $file"
 | 
			
		||||
 | 
			
		||||
# The WARN_LOGFILE tag can be used to specify a file to which warning and error
 | 
			
		||||
# messages should be written. If left blank the output is written to standard
 | 
			
		||||
# error (stderr). In case the file specified cannot be opened for writing the
 | 
			
		||||
# warning and error messages are written to standard error. When as file - is
 | 
			
		||||
# specified the warning and error messages are written to standard output
 | 
			
		||||
# (stdout).
 | 
			
		||||
# error (stderr).
 | 
			
		||||
 | 
			
		||||
WARN_LOGFILE           =
 | 
			
		||||
 | 
			
		||||
@@ -906,13 +864,7 @@ WARN_LOGFILE           =
 | 
			
		||||
# spaces. See also FILE_PATTERNS and EXTENSION_MAPPING
 | 
			
		||||
# Note: If this tag is empty the current directory is searched.
 | 
			
		||||
 | 
			
		||||
INPUT                  = README.md \
 | 
			
		||||
                         core/app.js \
 | 
			
		||||
                         core/client.js \
 | 
			
		||||
                         core/core.js \
 | 
			
		||||
                         core/tfrpc.js \
 | 
			
		||||
                         docs/ \
 | 
			
		||||
                         src/
 | 
			
		||||
INPUT                  = src/
 | 
			
		||||
 | 
			
		||||
# This tag can be used to specify the character encoding of the source files
 | 
			
		||||
# that doxygen parses. Internally doxygen uses the UTF-8 encoding. Doxygen uses
 | 
			
		||||
@@ -936,14 +888,12 @@ INPUT_ENCODING         = UTF-8
 | 
			
		||||
#
 | 
			
		||||
# If left blank the following patterns are tested:*.c, *.cc, *.cxx, *.cpp,
 | 
			
		||||
# *.c++, *.java, *.ii, *.ixx, *.ipp, *.i++, *.inl, *.idl, *.ddl, *.odl, *.h,
 | 
			
		||||
# *.hh, *.hxx, *.hpp, *.h++, *.l, *.cs, *.d, *.php, *.php4, *.php5, *.phtml,
 | 
			
		||||
# *.inc, *.m, *.markdown, *.md, *.mm, *.dox (to be provided as doxygen C
 | 
			
		||||
# comment), *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, *.f18, *.f, *.for, *.vhd,
 | 
			
		||||
# *.vhdl, *.ucf, *.qsf and *.ice.
 | 
			
		||||
# *.hh, *.hxx, *.hpp, *.h++, *.cs, *.d, *.php, *.php4, *.php5, *.phtml, *.inc,
 | 
			
		||||
# *.m, *.markdown, *.md, *.mm, *.dox (to be provided as doxygen C comment),
 | 
			
		||||
# *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, *.f18, *.f, *.for, *.vhd, *.vhdl,
 | 
			
		||||
# *.ucf, *.qsf and *.ice.
 | 
			
		||||
 | 
			
		||||
FILE_PATTERNS          = *.h \
 | 
			
		||||
                         *.js \
 | 
			
		||||
                         *.md
 | 
			
		||||
FILE_PATTERNS          = *.h *.md
 | 
			
		||||
 | 
			
		||||
# The RECURSIVE tag can be used to specify whether or not subdirectories should
 | 
			
		||||
# be searched for input files as well.
 | 
			
		||||
@@ -980,7 +930,7 @@ EXCLUDE_PATTERNS       =
 | 
			
		||||
# (namespaces, classes, functions, etc.) that should be excluded from the
 | 
			
		||||
# output. The symbol name can be a fully qualified name, a word, or if the
 | 
			
		||||
# wildcard * is used, a substring. Examples: ANamespace, AClass,
 | 
			
		||||
# ANamespace::AClass, ANamespace::*Test
 | 
			
		||||
# AClass::ANamespace, ANamespace::*Test
 | 
			
		||||
#
 | 
			
		||||
# Note that the wildcards are matched against the file with absolute path, so to
 | 
			
		||||
# exclude all test directories use the pattern */test/*
 | 
			
		||||
@@ -1011,7 +961,7 @@ EXAMPLE_RECURSIVE      = NO
 | 
			
		||||
# that contain images that are to be included in the documentation (see the
 | 
			
		||||
# \image command).
 | 
			
		||||
 | 
			
		||||
IMAGE_PATH             = docs/images/
 | 
			
		||||
IMAGE_PATH             =
 | 
			
		||||
 | 
			
		||||
# The INPUT_FILTER tag can be used to specify a program that doxygen should
 | 
			
		||||
# invoke to filter for each input file. Doxygen will invoke the filter program
 | 
			
		||||
@@ -1067,7 +1017,7 @@ FILTER_SOURCE_PATTERNS =
 | 
			
		||||
# (index.html). This can be useful if you have a project on for instance GitHub
 | 
			
		||||
# and want to reuse the introduction page also for the doxygen output.
 | 
			
		||||
 | 
			
		||||
USE_MDFILE_AS_MAINPAGE = README.md
 | 
			
		||||
USE_MDFILE_AS_MAINPAGE =
 | 
			
		||||
 | 
			
		||||
#---------------------------------------------------------------------------
 | 
			
		||||
# Configuration options related to source browsing
 | 
			
		||||
@@ -1166,11 +1116,9 @@ VERBATIM_HEADERS       = YES
 | 
			
		||||
 | 
			
		||||
CLANG_ASSISTED_PARSING = NO
 | 
			
		||||
 | 
			
		||||
# If the CLANG_ASSISTED_PARSING tag is set to YES and the CLANG_ADD_INC_PATHS
 | 
			
		||||
# tag is set to YES then doxygen will add the directory of each input to the
 | 
			
		||||
# include path.
 | 
			
		||||
# If clang assisted parsing is enabled and the CLANG_ADD_INC_PATHS tag is set to
 | 
			
		||||
# YES then doxygen will add the directory of each input to the include path.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
# This tag requires that the tag CLANG_ASSISTED_PARSING is set to YES.
 | 
			
		||||
 | 
			
		||||
CLANG_ADD_INC_PATHS    = YES
 | 
			
		||||
 | 
			
		||||
@@ -1305,7 +1253,7 @@ HTML_EXTRA_FILES       =
 | 
			
		||||
 | 
			
		||||
# The HTML_COLORSTYLE_HUE tag controls the color of the HTML output. Doxygen
 | 
			
		||||
# will adjust the colors in the style sheet and background images according to
 | 
			
		||||
# this color. Hue is specified as an angle on a color-wheel, see
 | 
			
		||||
# this color. Hue is specified as an angle on a colorwheel, see
 | 
			
		||||
# https://en.wikipedia.org/wiki/Hue for more information. For instance the value
 | 
			
		||||
# 0 represents red, 60 is yellow, 120 is green, 180 is cyan, 240 is blue, 300
 | 
			
		||||
# purple, and 360 is red again.
 | 
			
		||||
@@ -1315,7 +1263,7 @@ HTML_EXTRA_FILES       =
 | 
			
		||||
HTML_COLORSTYLE_HUE    = 220
 | 
			
		||||
 | 
			
		||||
# The HTML_COLORSTYLE_SAT tag controls the purity (or saturation) of the colors
 | 
			
		||||
# in the HTML output. For a value of 0 the output will use gray-scales only. A
 | 
			
		||||
# in the HTML output. For a value of 0 the output will use grayscales only. A
 | 
			
		||||
# value of 255 will produce the most vivid colors.
 | 
			
		||||
# Minimum value: 0, maximum value: 255, default value: 100.
 | 
			
		||||
# This tag requires that the tag GENERATE_HTML is set to YES.
 | 
			
		||||
@@ -1340,7 +1288,7 @@ HTML_COLORSTYLE_GAMMA  = 80
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
# This tag requires that the tag GENERATE_HTML is set to YES.
 | 
			
		||||
 | 
			
		||||
#HTML_TIMESTAMP         = NO
 | 
			
		||||
HTML_TIMESTAMP         = NO
 | 
			
		||||
 | 
			
		||||
# If the HTML_DYNAMIC_MENUS tag is set to YES then the generated HTML
 | 
			
		||||
# documentation will contain a main index with vertical navigation menus that
 | 
			
		||||
@@ -1397,13 +1345,6 @@ GENERATE_DOCSET        = NO
 | 
			
		||||
 | 
			
		||||
DOCSET_FEEDNAME        = "Doxygen generated docs"
 | 
			
		||||
 | 
			
		||||
# This tag determines the URL of the docset feed. A documentation feed provides
 | 
			
		||||
# an umbrella under which multiple documentation sets from a single provider
 | 
			
		||||
# (such as a company or product suite) can be grouped.
 | 
			
		||||
# This tag requires that the tag GENERATE_DOCSET is set to YES.
 | 
			
		||||
 | 
			
		||||
DOCSET_FEEDURL         =
 | 
			
		||||
 | 
			
		||||
# This tag specifies a string that should uniquely identify the documentation
 | 
			
		||||
# set bundle. This should be a reverse domain-name style string, e.g.
 | 
			
		||||
# com.mycompany.MyDocSet. Doxygen will append .docset to the name.
 | 
			
		||||
@@ -1429,12 +1370,8 @@ DOCSET_PUBLISHER_NAME  = Publisher
 | 
			
		||||
# If the GENERATE_HTMLHELP tag is set to YES then doxygen generates three
 | 
			
		||||
# additional HTML index files: index.hhp, index.hhc, and index.hhk. The
 | 
			
		||||
# index.hhp is a project file that can be read by Microsoft's HTML Help Workshop
 | 
			
		||||
# on Windows. In the beginning of 2021 Microsoft took the original page, with
 | 
			
		||||
# a.o. the download links, offline the HTML help workshop was already many years
 | 
			
		||||
# in maintenance mode). You can download the HTML help workshop from the web
 | 
			
		||||
# archives at Installation executable (see:
 | 
			
		||||
# http://web.archive.org/web/20160201063255/http://download.microsoft.com/downlo
 | 
			
		||||
# ad/0/A/9/0A939EF6-E31C-430F-A3DF-DFAE7960D564/htmlhelp.exe).
 | 
			
		||||
# (see:
 | 
			
		||||
# https://www.microsoft.com/en-us/download/details.aspx?id=21138) on Windows.
 | 
			
		||||
#
 | 
			
		||||
# The HTML Help Workshop contains a compiler that can convert all HTML output
 | 
			
		||||
# generated by doxygen into a single compiled HTML file (.chm). Compiled HTML
 | 
			
		||||
@@ -1593,27 +1530,15 @@ DISABLE_INDEX          = NO
 | 
			
		||||
# to work a browser that supports JavaScript, DHTML, CSS and frames is required
 | 
			
		||||
# (i.e. any modern browser). Windows users are probably better off using the
 | 
			
		||||
# HTML help feature. Via custom style sheets (see HTML_EXTRA_STYLESHEET) one can
 | 
			
		||||
# further fine tune the look of the index (see "Fine-tuning the output"). As an
 | 
			
		||||
# example, the default style sheet generated by doxygen has an example that
 | 
			
		||||
# shows how to put an image at the root of the tree instead of the PROJECT_NAME.
 | 
			
		||||
# Since the tree basically has the same information as the tab index, you could
 | 
			
		||||
# consider setting DISABLE_INDEX to YES when enabling this option.
 | 
			
		||||
# further fine-tune the look of the index. As an example, the default style
 | 
			
		||||
# sheet generated by doxygen has an example that shows how to put an image at
 | 
			
		||||
# the root of the tree instead of the PROJECT_NAME. Since the tree basically has
 | 
			
		||||
# the same information as the tab index, you could consider setting
 | 
			
		||||
# DISABLE_INDEX to YES when enabling this option.
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
# This tag requires that the tag GENERATE_HTML is set to YES.
 | 
			
		||||
 | 
			
		||||
GENERATE_TREEVIEW      = YES
 | 
			
		||||
 | 
			
		||||
# When both GENERATE_TREEVIEW and DISABLE_INDEX are set to YES, then the
 | 
			
		||||
# FULL_SIDEBAR option determines if the side bar is limited to only the treeview
 | 
			
		||||
# area (value NO) or if it should extend to the full height of the window (value
 | 
			
		||||
# YES). Setting this to YES gives a layout similar to
 | 
			
		||||
# https://docs.readthedocs.io with more room for contents, but less room for the
 | 
			
		||||
# project logo, title, and description. If either GENERATE_TREEVIEW or
 | 
			
		||||
# DISABLE_INDEX is set to NO, this option has no effect.
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
# This tag requires that the tag GENERATE_HTML is set to YES.
 | 
			
		||||
 | 
			
		||||
FULL_SIDEBAR           = NO
 | 
			
		||||
GENERATE_TREEVIEW      = NO
 | 
			
		||||
 | 
			
		||||
# The ENUM_VALUES_PER_LINE tag can be used to set the number of enum values that
 | 
			
		||||
# doxygen will group on one line in the generated HTML documentation.
 | 
			
		||||
@@ -1639,13 +1564,6 @@ TREEVIEW_WIDTH         = 250
 | 
			
		||||
 | 
			
		||||
EXT_LINKS_IN_WINDOW    = NO
 | 
			
		||||
 | 
			
		||||
# If the OBFUSCATE_EMAILS tag is set to YES, doxygen will obfuscate email
 | 
			
		||||
# addresses.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
# This tag requires that the tag GENERATE_HTML is set to YES.
 | 
			
		||||
 | 
			
		||||
OBFUSCATE_EMAILS       = YES
 | 
			
		||||
 | 
			
		||||
# If the HTML_FORMULA_FORMAT option is set to svg, doxygen will use the pdf2svg
 | 
			
		||||
# tool (see https://github.com/dawbarton/pdf2svg) or inkscape (see
 | 
			
		||||
# https://inkscape.org) to generate formulas as SVG images instead of PNGs for
 | 
			
		||||
@@ -1675,7 +1593,7 @@ FORMULA_FONTSIZE       = 10
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
# This tag requires that the tag GENERATE_HTML is set to YES.
 | 
			
		||||
 | 
			
		||||
#FORMULA_TRANSPARENT    = YES
 | 
			
		||||
FORMULA_TRANSPARENT    = YES
 | 
			
		||||
 | 
			
		||||
# The FORMULA_MACROFILE can contain LaTeX \newcommand and \renewcommand commands
 | 
			
		||||
# to create new LaTeX commands to be used in formulas as building blocks. See
 | 
			
		||||
@@ -1694,29 +1612,11 @@ FORMULA_MACROFILE      =
 | 
			
		||||
 | 
			
		||||
USE_MATHJAX            = NO
 | 
			
		||||
 | 
			
		||||
# With MATHJAX_VERSION it is possible to specify the MathJax version to be used.
 | 
			
		||||
# Note that the different versions of MathJax have different requirements with
 | 
			
		||||
# regards to the different settings, so it is possible that also other MathJax
 | 
			
		||||
# settings have to be changed when switching between the different MathJax
 | 
			
		||||
# versions.
 | 
			
		||||
# Possible values are: MathJax_2 and MathJax_3.
 | 
			
		||||
# The default value is: MathJax_2.
 | 
			
		||||
# This tag requires that the tag USE_MATHJAX is set to YES.
 | 
			
		||||
 | 
			
		||||
MATHJAX_VERSION        = MathJax_2
 | 
			
		||||
 | 
			
		||||
# When MathJax is enabled you can set the default output format to be used for
 | 
			
		||||
# the MathJax output. For more details about the output format see MathJax
 | 
			
		||||
# version 2 (see:
 | 
			
		||||
# http://docs.mathjax.org/en/v2.7-latest/output.html) and MathJax version 3
 | 
			
		||||
# (see:
 | 
			
		||||
# http://docs.mathjax.org/en/latest/web/components/output.html).
 | 
			
		||||
# the MathJax output. See the MathJax site (see:
 | 
			
		||||
# http://docs.mathjax.org/en/v2.7-latest/output.html) for more details.
 | 
			
		||||
# Possible values are: HTML-CSS (which is slower, but has the best
 | 
			
		||||
# compatibility. This is the name for Mathjax version 2, for MathJax version 3
 | 
			
		||||
# this will be translated into chtml), NativeMML (i.e. MathML. Only supported
 | 
			
		||||
# for NathJax 2. For MathJax version 3 chtml will be used instead.), chtml (This
 | 
			
		||||
# is the name for Mathjax version 3, for MathJax version 2 this will be
 | 
			
		||||
# translated into HTML-CSS) and SVG.
 | 
			
		||||
# compatibility), NativeMML (i.e. MathML) and SVG.
 | 
			
		||||
# The default value is: HTML-CSS.
 | 
			
		||||
# This tag requires that the tag USE_MATHJAX is set to YES.
 | 
			
		||||
 | 
			
		||||
@@ -1729,21 +1629,15 @@ MATHJAX_FORMAT         = HTML-CSS
 | 
			
		||||
# MATHJAX_RELPATH should be ../mathjax. The default value points to the MathJax
 | 
			
		||||
# Content Delivery Network so you can quickly see the result without installing
 | 
			
		||||
# MathJax. However, it is strongly recommended to install a local copy of
 | 
			
		||||
# MathJax from https://www.mathjax.org before deployment. The default value is:
 | 
			
		||||
# - in case of MathJax version 2: https://cdn.jsdelivr.net/npm/mathjax@2
 | 
			
		||||
# - in case of MathJax version 3: https://cdn.jsdelivr.net/npm/mathjax@3
 | 
			
		||||
# MathJax from https://www.mathjax.org before deployment.
 | 
			
		||||
# The default value is: https://cdn.jsdelivr.net/npm/mathjax@2.
 | 
			
		||||
# This tag requires that the tag USE_MATHJAX is set to YES.
 | 
			
		||||
 | 
			
		||||
MATHJAX_RELPATH        = https://cdn.jsdelivr.net/npm/mathjax@2
 | 
			
		||||
 | 
			
		||||
# The MATHJAX_EXTENSIONS tag can be used to specify one or more MathJax
 | 
			
		||||
# extension names that should be enabled during MathJax rendering. For example
 | 
			
		||||
# for MathJax version 2 (see https://docs.mathjax.org/en/v2.7-latest/tex.html
 | 
			
		||||
# #tex-and-latex-extensions):
 | 
			
		||||
# MATHJAX_EXTENSIONS = TeX/AMSmath TeX/AMSsymbols
 | 
			
		||||
# For example for MathJax version 3 (see
 | 
			
		||||
# http://docs.mathjax.org/en/latest/input/tex/extensions/index.html):
 | 
			
		||||
# MATHJAX_EXTENSIONS = ams
 | 
			
		||||
# This tag requires that the tag USE_MATHJAX is set to YES.
 | 
			
		||||
 | 
			
		||||
MATHJAX_EXTENSIONS     =
 | 
			
		||||
@@ -1923,31 +1817,29 @@ PAPER_TYPE             = a4
 | 
			
		||||
 | 
			
		||||
EXTRA_PACKAGES         =
 | 
			
		||||
 | 
			
		||||
# The LATEX_HEADER tag can be used to specify a user-defined LaTeX header for
 | 
			
		||||
# the generated LaTeX document. The header should contain everything until the
 | 
			
		||||
# first chapter. If it is left blank doxygen will generate a standard header. It
 | 
			
		||||
# is highly recommended to start with a default header using
 | 
			
		||||
# doxygen -w latex new_header.tex new_footer.tex new_stylesheet.sty
 | 
			
		||||
# and then modify the file new_header.tex. See also section "Doxygen usage" for
 | 
			
		||||
# information on how to generate the default header that doxygen normally uses.
 | 
			
		||||
# The LATEX_HEADER tag can be used to specify a personal LaTeX header for the
 | 
			
		||||
# generated LaTeX document. The header should contain everything until the first
 | 
			
		||||
# chapter. If it is left blank doxygen will generate a standard header. See
 | 
			
		||||
# section "Doxygen usage" for information on how to let doxygen write the
 | 
			
		||||
# default header to a separate file.
 | 
			
		||||
#
 | 
			
		||||
# Note: Only use a user-defined header if you know what you are doing!
 | 
			
		||||
# Note: The header is subject to change so you typically have to regenerate the
 | 
			
		||||
# default header when upgrading to a newer version of doxygen. The following
 | 
			
		||||
# commands have a special meaning inside the header (and footer): For a
 | 
			
		||||
# description of the possible markers and block names see the documentation.
 | 
			
		||||
# Note: Only use a user-defined header if you know what you are doing! The
 | 
			
		||||
# following commands have a special meaning inside the header: $title,
 | 
			
		||||
# $datetime, $date, $doxygenversion, $projectname, $projectnumber,
 | 
			
		||||
# $projectbrief, $projectlogo. Doxygen will replace $title with the empty
 | 
			
		||||
# string, for the replacement values of the other commands the user is referred
 | 
			
		||||
# to HTML_HEADER.
 | 
			
		||||
# This tag requires that the tag GENERATE_LATEX is set to YES.
 | 
			
		||||
 | 
			
		||||
LATEX_HEADER           =
 | 
			
		||||
 | 
			
		||||
# The LATEX_FOOTER tag can be used to specify a user-defined LaTeX footer for
 | 
			
		||||
# the generated LaTeX document. The footer should contain everything after the
 | 
			
		||||
# last chapter. If it is left blank doxygen will generate a standard footer. See
 | 
			
		||||
# The LATEX_FOOTER tag can be used to specify a personal LaTeX footer for the
 | 
			
		||||
# generated LaTeX document. The footer should contain everything after the last
 | 
			
		||||
# chapter. If it is left blank doxygen will generate a standard footer. See
 | 
			
		||||
# LATEX_HEADER for more information on how to generate a default footer and what
 | 
			
		||||
# special commands can be used inside the footer. See also section "Doxygen
 | 
			
		||||
# usage" for information on how to generate the default footer that doxygen
 | 
			
		||||
# normally uses. Note: Only use a user-defined footer if you know what you are
 | 
			
		||||
# doing!
 | 
			
		||||
# special commands can be used inside the footer.
 | 
			
		||||
#
 | 
			
		||||
# Note: Only use a user-defined footer if you know what you are doing!
 | 
			
		||||
# This tag requires that the tag GENERATE_LATEX is set to YES.
 | 
			
		||||
 | 
			
		||||
LATEX_FOOTER           =
 | 
			
		||||
@@ -1992,7 +1884,8 @@ USE_PDFLATEX           = YES
 | 
			
		||||
 | 
			
		||||
# If the LATEX_BATCHMODE tag is set to YES, doxygen will add the \batchmode
 | 
			
		||||
# command to the generated LaTeX files. This will instruct LaTeX to keep running
 | 
			
		||||
# if errors occur, instead of asking the user for help.
 | 
			
		||||
# if errors occur, instead of asking the user for help. This option is also used
 | 
			
		||||
# when generating formulas in HTML.
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
# This tag requires that the tag GENERATE_LATEX is set to YES.
 | 
			
		||||
 | 
			
		||||
@@ -2005,6 +1898,16 @@ LATEX_BATCHMODE        = NO
 | 
			
		||||
 | 
			
		||||
LATEX_HIDE_INDICES     = NO
 | 
			
		||||
 | 
			
		||||
# If the LATEX_SOURCE_CODE tag is set to YES then doxygen will include source
 | 
			
		||||
# code with syntax highlighting in the LaTeX output.
 | 
			
		||||
#
 | 
			
		||||
# Note that which sources are shown also depends on other settings such as
 | 
			
		||||
# SOURCE_BROWSER.
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
# This tag requires that the tag GENERATE_LATEX is set to YES.
 | 
			
		||||
 | 
			
		||||
LATEX_SOURCE_CODE      = NO
 | 
			
		||||
 | 
			
		||||
# The LATEX_BIB_STYLE tag can be used to specify the style to use for the
 | 
			
		||||
# bibliography, e.g. plainnat, or ieeetr. See
 | 
			
		||||
# https://en.wikipedia.org/wiki/BibTeX and \cite for more info.
 | 
			
		||||
@@ -2019,7 +1922,7 @@ LATEX_BIB_STYLE        = plain
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
# This tag requires that the tag GENERATE_LATEX is set to YES.
 | 
			
		||||
 | 
			
		||||
#LATEX_TIMESTAMP        = NO
 | 
			
		||||
LATEX_TIMESTAMP        = NO
 | 
			
		||||
 | 
			
		||||
# The LATEX_EMOJI_DIRECTORY tag is used to specify the (relative or absolute)
 | 
			
		||||
# path from which the emoji images will be read. If a relative path is entered,
 | 
			
		||||
@@ -2085,6 +1988,16 @@ RTF_STYLESHEET_FILE    =
 | 
			
		||||
 | 
			
		||||
RTF_EXTENSIONS_FILE    =
 | 
			
		||||
 | 
			
		||||
# If the RTF_SOURCE_CODE tag is set to YES then doxygen will include source code
 | 
			
		||||
# with syntax highlighting in the RTF output.
 | 
			
		||||
#
 | 
			
		||||
# Note that which sources are shown also depends on other settings such as
 | 
			
		||||
# SOURCE_BROWSER.
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
# This tag requires that the tag GENERATE_RTF is set to YES.
 | 
			
		||||
 | 
			
		||||
RTF_SOURCE_CODE        = NO
 | 
			
		||||
 | 
			
		||||
#---------------------------------------------------------------------------
 | 
			
		||||
# Configuration options related to the man page output
 | 
			
		||||
#---------------------------------------------------------------------------
 | 
			
		||||
@@ -2181,6 +2094,15 @@ GENERATE_DOCBOOK       = NO
 | 
			
		||||
 | 
			
		||||
DOCBOOK_OUTPUT         = docbook
 | 
			
		||||
 | 
			
		||||
# If the DOCBOOK_PROGRAMLISTING tag is set to YES, doxygen will include the
 | 
			
		||||
# program listings (including syntax highlighting and cross-referencing
 | 
			
		||||
# information) to the DOCBOOK output. Note that enabling this will significantly
 | 
			
		||||
# increase the size of the DOCBOOK output.
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
# This tag requires that the tag GENERATE_DOCBOOK is set to YES.
 | 
			
		||||
 | 
			
		||||
DOCBOOK_PROGRAMLISTING = NO
 | 
			
		||||
 | 
			
		||||
#---------------------------------------------------------------------------
 | 
			
		||||
# Configuration options for the AutoGen Definitions output
 | 
			
		||||
#---------------------------------------------------------------------------
 | 
			
		||||
@@ -2267,8 +2189,7 @@ SEARCH_INCLUDES        = YES
 | 
			
		||||
 | 
			
		||||
# The INCLUDE_PATH tag can be used to specify one or more directories that
 | 
			
		||||
# contain include files that are not input files but should be processed by the
 | 
			
		||||
# preprocessor. Note that the INCLUDE_PATH is not recursive, so the setting of
 | 
			
		||||
# RECURSIVE has no effect here.
 | 
			
		||||
# preprocessor.
 | 
			
		||||
# This tag requires that the tag SEARCH_INCLUDES is set to YES.
 | 
			
		||||
 | 
			
		||||
INCLUDE_PATH           =
 | 
			
		||||
@@ -2360,6 +2281,15 @@ EXTERNAL_PAGES         = YES
 | 
			
		||||
# Configuration options related to the dot tool
 | 
			
		||||
#---------------------------------------------------------------------------
 | 
			
		||||
 | 
			
		||||
# If the CLASS_DIAGRAMS tag is set to YES, doxygen will generate a class diagram
 | 
			
		||||
# (in HTML and LaTeX) for classes with base or super classes. Setting the tag to
 | 
			
		||||
# NO turns the diagrams off. Note that this option also works with HAVE_DOT
 | 
			
		||||
# disabled, but it is recommended to install and use dot, since it yields more
 | 
			
		||||
# powerful graphs.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
 | 
			
		||||
CLASS_DIAGRAMS         = YES
 | 
			
		||||
 | 
			
		||||
# You can include diagrams made with dia in doxygen documentation. Doxygen will
 | 
			
		||||
# then run dia to produce the diagram and insert it in the documentation. The
 | 
			
		||||
# DIA_PATH tag allows you to specify the directory where the dia binary resides.
 | 
			
		||||
@@ -2378,7 +2308,7 @@ HIDE_UNDOC_RELATIONS   = YES
 | 
			
		||||
# http://www.graphviz.org/), a graph visualization toolkit from AT&T and Lucent
 | 
			
		||||
# Bell Labs. The other options in this section have no effect if this option is
 | 
			
		||||
# set to NO
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
 | 
			
		||||
HAVE_DOT               = YES
 | 
			
		||||
 | 
			
		||||
@@ -2400,30 +2330,27 @@ DOT_NUM_THREADS        = 0
 | 
			
		||||
# The default value is: Helvetica.
 | 
			
		||||
# This tag requires that the tag HAVE_DOT is set to YES.
 | 
			
		||||
 | 
			
		||||
#DOT_FONTNAME           = Helvetica
 | 
			
		||||
DOT_FONTNAME           = Helvetica
 | 
			
		||||
 | 
			
		||||
# The DOT_FONTSIZE tag can be used to set the size (in points) of the font of
 | 
			
		||||
# dot graphs.
 | 
			
		||||
# Minimum value: 4, maximum value: 24, default value: 10.
 | 
			
		||||
# This tag requires that the tag HAVE_DOT is set to YES.
 | 
			
		||||
 | 
			
		||||
#DOT_FONTSIZE           = 10
 | 
			
		||||
DOT_FONTSIZE           = 10
 | 
			
		||||
 | 
			
		||||
# By default doxygen will tell dot to use the default font as specified with
 | 
			
		||||
# DOT_FONTNAME. If you specify a different font using DOT_FONTNAME you can set
 | 
			
		||||
# the path where dot can find it using this tag.
 | 
			
		||||
# This tag requires that the tag HAVE_DOT is set to YES.
 | 
			
		||||
 | 
			
		||||
#DOT_FONTPATH           =
 | 
			
		||||
DOT_FONTPATH           =
 | 
			
		||||
 | 
			
		||||
# If the CLASS_GRAPH tag is set to YES (or GRAPH) then doxygen will generate a
 | 
			
		||||
# graph for each documented class showing the direct and indirect inheritance
 | 
			
		||||
# relations. In case HAVE_DOT is set as well dot will be used to draw the graph,
 | 
			
		||||
# otherwise the built-in generator will be used. If the CLASS_GRAPH tag is set
 | 
			
		||||
# to TEXT the direct and indirect inheritance relations will be shown as texts /
 | 
			
		||||
# links.
 | 
			
		||||
# Possible values are: NO, YES, TEXT and GRAPH.
 | 
			
		||||
# If the CLASS_GRAPH tag is set to YES then doxygen will generate a graph for
 | 
			
		||||
# each documented class showing the direct and indirect inheritance relations.
 | 
			
		||||
# Setting this tag to YES will force the CLASS_DIAGRAMS tag to NO.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
# This tag requires that the tag HAVE_DOT is set to YES.
 | 
			
		||||
 | 
			
		||||
CLASS_GRAPH            = YES
 | 
			
		||||
 | 
			
		||||
@@ -2437,8 +2364,7 @@ CLASS_GRAPH            = YES
 | 
			
		||||
COLLABORATION_GRAPH    = YES
 | 
			
		||||
 | 
			
		||||
# If the GROUP_GRAPHS tag is set to YES then doxygen will generate a graph for
 | 
			
		||||
# groups, showing the direct groups dependencies. See also the chapter Grouping
 | 
			
		||||
# in the manual.
 | 
			
		||||
# groups, showing the direct groups dependencies.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
# This tag requires that the tag HAVE_DOT is set to YES.
 | 
			
		||||
 | 
			
		||||
@@ -2553,13 +2479,6 @@ GRAPHICAL_HIERARCHY    = YES
 | 
			
		||||
 | 
			
		||||
DIRECTORY_GRAPH        = YES
 | 
			
		||||
 | 
			
		||||
# The DIR_GRAPH_MAX_DEPTH tag can be used to limit the maximum number of levels
 | 
			
		||||
# of child directories generated in directory dependency graphs by dot.
 | 
			
		||||
# Minimum value: 1, maximum value: 25, default value: 1.
 | 
			
		||||
# This tag requires that the tag DIRECTORY_GRAPH is set to YES.
 | 
			
		||||
 | 
			
		||||
DIR_GRAPH_MAX_DEPTH    = 1
 | 
			
		||||
 | 
			
		||||
# The DOT_IMAGE_FORMAT tag can be used to set the image format of the images
 | 
			
		||||
# generated by dot. For an explanation of the image formats see the section
 | 
			
		||||
# output formats in the documentation of the dot tool (Graphviz (see:
 | 
			
		||||
@@ -2567,7 +2486,9 @@ DIR_GRAPH_MAX_DEPTH    = 1
 | 
			
		||||
# Note: If you choose svg you need to set HTML_FILE_EXTENSION to xhtml in order
 | 
			
		||||
# to make the SVG files visible in IE 9+ (other browsers do not have this
 | 
			
		||||
# requirement).
 | 
			
		||||
# Possible values are: png, jpg, gif, svg, png:gd, png:gd:gd, png:cairo,
 | 
			
		||||
# Possible values are: png, png:cairo, png:cairo:cairo, png:cairo:gd, png:gd,
 | 
			
		||||
# png:gd:gd, jpg, jpg:cairo, jpg:cairo:gd, jpg:gd, jpg:gd:gd, gif, gif:cairo,
 | 
			
		||||
# gif:cairo:gd, gif:gd, gif:gd:gd, svg, png:gd, png:gd:gd, png:cairo,
 | 
			
		||||
# png:cairo:gd, png:cairo:cairo, png:cairo:gdiplus, png:gdiplus and
 | 
			
		||||
# png:gdiplus:gdiplus.
 | 
			
		||||
# The default value is: png.
 | 
			
		||||
@@ -2613,10 +2534,10 @@ MSCFILE_DIRS           =
 | 
			
		||||
DIAFILE_DIRS           =
 | 
			
		||||
 | 
			
		||||
# When using plantuml, the PLANTUML_JAR_PATH tag should be used to specify the
 | 
			
		||||
# path where java can find the plantuml.jar file or to the filename of jar file
 | 
			
		||||
# to be used. If left blank, it is assumed PlantUML is not used or called during
 | 
			
		||||
# a preprocessing step. Doxygen will generate a warning when it encounters a
 | 
			
		||||
# \startuml command in this case and will not generate output for the diagram.
 | 
			
		||||
# path where java can find the plantuml.jar file. If left blank, it is assumed
 | 
			
		||||
# PlantUML is not used or called during a preprocessing step. Doxygen will
 | 
			
		||||
# generate a warning when it encounters a \startuml command in this case and
 | 
			
		||||
# will not generate output for the diagram.
 | 
			
		||||
 | 
			
		||||
PLANTUML_JAR_PATH      =
 | 
			
		||||
 | 
			
		||||
@@ -2664,7 +2585,7 @@ MAX_DOT_GRAPH_DEPTH    = 0
 | 
			
		||||
# The default value is: NO.
 | 
			
		||||
# This tag requires that the tag HAVE_DOT is set to YES.
 | 
			
		||||
 | 
			
		||||
#DOT_TRANSPARENT        = NO
 | 
			
		||||
DOT_TRANSPARENT        = NO
 | 
			
		||||
 | 
			
		||||
# Set the DOT_MULTI_TARGETS tag to YES to allow dot to generate multiple output
 | 
			
		||||
# files in one run (i.e. multiple -o and -T options on the command line). This
 | 
			
		||||
@@ -2678,8 +2599,6 @@ DOT_MULTI_TARGETS      = NO
 | 
			
		||||
# If the GENERATE_LEGEND tag is set to YES doxygen will generate a legend page
 | 
			
		||||
# explaining the meaning of the various boxes and arrows in the dot generated
 | 
			
		||||
# graphs.
 | 
			
		||||
# Note: This tag requires that UML_LOOK isn't set, i.e. the doxygen internal
 | 
			
		||||
# graphical representation for inheritance and collaboration diagrams is used.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
# This tag requires that the tag HAVE_DOT is set to YES.
 | 
			
		||||
 | 
			
		||||
@@ -2688,8 +2607,8 @@ GENERATE_LEGEND        = YES
 | 
			
		||||
# If the DOT_CLEANUP tag is set to YES, doxygen will remove the intermediate
 | 
			
		||||
# files that are used to generate the various graphs.
 | 
			
		||||
#
 | 
			
		||||
# Note: This setting is not only used for dot files but also for msc temporary
 | 
			
		||||
# files.
 | 
			
		||||
# Note: This setting is not only used for dot files but also for msc and
 | 
			
		||||
# plantuml temporary files.
 | 
			
		||||
# The default value is: YES.
 | 
			
		||||
 | 
			
		||||
DOT_CLEANUP            = YES
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										809
									
								
								GNUmakefile
									
									
									
									
									
								
							
							
						
						
									
										809
									
								
								GNUmakefile
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										69
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										69
									
								
								README.md
									
									
									
									
									
								
							@@ -1,68 +1,47 @@
 | 
			
		||||
# Tilde Friends
 | 
			
		||||
 | 
			
		||||
Tilde Friends participates in the Secure Scuttlebutt decentralized social
 | 
			
		||||
network while also functioning as a platform for making, sharing, and running
 | 
			
		||||
web applications.
 | 
			
		||||
Tilde Friends is a tool for making and sharing.
 | 
			
		||||
 | 
			
		||||
A public instance lives at https://www.tildefriends.net/.
 | 
			
		||||
 | 
			
		||||
It is both a peer-to-peer social network client, participating in Secure
 | 
			
		||||
Scuttlebutt, as well as a platform for writing and running web applications.
 | 
			
		||||
 | 
			
		||||
## Goals
 | 
			
		||||
 | 
			
		||||
1. Be the fanciest, best-maintained Secure Scuttlebutt client in town.
 | 
			
		||||
1. Make it easy to make, share, and run all sorts of applications while
 | 
			
		||||
   respecting the privacy and safety of your data.
 | 
			
		||||
 | 
			
		||||
## Getting the Source
 | 
			
		||||
 | 
			
		||||
Tilde Friends uses git submodules, so either:
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
git clone --recurse-submodules https://dev.tildefriends.net/cory/tildefriends.git
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
or:
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
git clone https://dev.tildefriends.net/cory/tildefriends.git
 | 
			
		||||
cd tildefriends
 | 
			
		||||
git submodule update --init --recursive
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
The `.tar.xz` source releases are all-inclusive.
 | 
			
		||||
1. Make it easy and fun to run all sorts of web applications.
 | 
			
		||||
2. Provide security that is easy to understand and protects your data.
 | 
			
		||||
3. Make creating and sharing web applications accessible to anyone with a
 | 
			
		||||
   browser.
 | 
			
		||||
 | 
			
		||||
## Building
 | 
			
		||||
 | 
			
		||||
Builds on Linux (x86_64 and aarch64), MacOS, OpenBSD, and Haiku. It's possible
 | 
			
		||||
to build for Android, iOS, and Windows on Linux, if you have the right
 | 
			
		||||
dependencies in the right places.
 | 
			
		||||
Builds on Linux (x86_64 and aarch64), MacOS, OpenBSD, and Haiku. Builds for
 | 
			
		||||
all of those host platforms plus mingw64, iOS, and android.
 | 
			
		||||
 | 
			
		||||
### Requirements
 | 
			
		||||
 | 
			
		||||
On MacOS, Xcode's command-line tools are expected to be available.
 | 
			
		||||
 | 
			
		||||
### Build Commands
 | 
			
		||||
 | 
			
		||||
Run `make` with no arguments to see available build targets and options. `make
 | 
			
		||||
debug` is a good place to start.
 | 
			
		||||
 | 
			
		||||
To build in docker, `docker build .`.
 | 
			
		||||
 | 
			
		||||
`make format` and `make prettier` will normalize formatting to the coding
 | 
			
		||||
standard.
 | 
			
		||||
1. Requires openssl (`libssl-dev`, in debian-speak). All other dependencies
 | 
			
		||||
   are kept up to date in the tree.
 | 
			
		||||
2. To build, run `make debug` or `make release`. An executable will be
 | 
			
		||||
   generated in a subdirectory of `out/`.
 | 
			
		||||
3. It's possible to build for Android, iOS, and Windows on Linux, if you have
 | 
			
		||||
   the right dependencies in the right places. `make windebug winrelease
 | 
			
		||||
iosdebug-ipa iosrelease-ipa release-apk`.
 | 
			
		||||
4. To build in docker, `docker build .`.
 | 
			
		||||
5. `make format` will normalize formatting to the coding standard.
 | 
			
		||||
 | 
			
		||||
## Running
 | 
			
		||||
 | 
			
		||||
By default, running the built `out/debug/tildefriends` executable will start a
 | 
			
		||||
web server at <http://localhost:12345/>. `tildefriends -h` lists further
 | 
			
		||||
options.
 | 
			
		||||
By default, running the built `tildefriends` executable will start a web server
 | 
			
		||||
at <http://localhost:12345/>. `tildefriends -h` lists further options.
 | 
			
		||||
 | 
			
		||||
The first user to create an account and log in will be granted administrative
 | 
			
		||||
privileges. Further administration can be done in the `admin` app at
 | 
			
		||||
privileges. Further administration can be done at
 | 
			
		||||
<http://localhost:12345/~core/admin/>.
 | 
			
		||||
 | 
			
		||||
## Documentation
 | 
			
		||||
 | 
			
		||||
Docs live here: <https://docs.tildefriends.net/>.
 | 
			
		||||
Docs are a work in progress:
 | 
			
		||||
<https://www.tildefriends.net/~cory/wiki/#test-wiki/tf-app-quick-reference>.
 | 
			
		||||
 | 
			
		||||
## License
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🎛",
 | 
			
		||||
	"previous": "&kmKNyb/uaXNb24gCinJtfS8iWx4cLUWdtl0y2DwEUas=.sha256"
 | 
			
		||||
	"emoji": "🎛"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,38 +4,9 @@
 | 
			
		||||
		<script>
 | 
			
		||||
			const g_data = $data;
 | 
			
		||||
		</script>
 | 
			
		||||
		<link rel="stylesheet" href="w3.css" />
 | 
			
		||||
		<!-- prettier-ignore -->
 | 
			
		||||
		<style>
 | 
			
		||||
			/* 2018 Valiant Poppy */
 | 
			
		||||
			.w3-theme-l5 {color:#000 !important; background-color:#fbf3f3 !important}
 | 
			
		||||
			.w3-theme-l4 {color:#000 !important; background-color:#f3d7d6 !important}
 | 
			
		||||
			.w3-theme-l3 {color:#000 !important; background-color:#e6afae !important}
 | 
			
		||||
			.w3-theme-l2 {color:#fff !important; background-color:#da8785 !important}
 | 
			
		||||
			.w3-theme-l1 {color:#fff !important; background-color:#cd5f5d !important}
 | 
			
		||||
			.w3-theme-d1 {color:#fff !important; background-color:#a93634 !important}
 | 
			
		||||
			.w3-theme-d2 {color:#fff !important; background-color:#96302e !important}
 | 
			
		||||
			.w3-theme-d3 {color:#fff !important; background-color:#832a28 !important}
 | 
			
		||||
			.w3-theme-d4 {color:#fff !important; background-color:#702423 !important}
 | 
			
		||||
			.w3-theme-d5 {color:#fff !important; background-color:#5e1e1d !important}
 | 
			
		||||
 | 
			
		||||
			.w3-theme-light {color:#000 !important; background-color:#fbf3f3 !important}
 | 
			
		||||
			.w3-theme-dark {color:#fff !important; background-color:#5e1e1d !important}
 | 
			
		||||
			.w3-theme-action {color:#fff !important; background-color:#5e1e1d !important}
 | 
			
		||||
 | 
			
		||||
			.w3-theme {color:#fff !important; background-color:#bd3d3a !important}
 | 
			
		||||
			.w3-text-theme {color:#bd3d3a !important}
 | 
			
		||||
			.w3-border-theme {border-color:#bd3d3a !important}
 | 
			
		||||
 | 
			
		||||
			.w3-hover-theme:hover {color:#fff !important; background-color:#bd3d3a !important}
 | 
			
		||||
			.w3-hover-text-theme:hover {color:#bd3d3a !important}
 | 
			
		||||
			.w3-hover-border-theme:hover {border-color:#bd3d3a !important}
 | 
			
		||||
		</style>
 | 
			
		||||
	</head>
 | 
			
		||||
	<body class="w3-theme-l4">
 | 
			
		||||
		<header class="w3-row w3-padding w3-header w3-theme-l1">
 | 
			
		||||
			<h1>Tilde Friends Administration</h1>
 | 
			
		||||
		</header>
 | 
			
		||||
	<body style="color: #fff; width: 100%">
 | 
			
		||||
		<h1>Tilde Friends Administration</h1>
 | 
			
		||||
	</body>
 | 
			
		||||
	<script type="module" src="script.js"></script>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -27,87 +27,64 @@ function global_settings_set(key, value) {
 | 
			
		||||
		});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function title_case(name) {
 | 
			
		||||
	return name
 | 
			
		||||
		.split('_')
 | 
			
		||||
		.map((x) => x.charAt(0).toUpperCase() + x.substring(1))
 | 
			
		||||
		.join(' ');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
window.addEventListener('load', function () {
 | 
			
		||||
	const permission_template = (permission) => html` <code>${permission}</code>`;
 | 
			
		||||
	function input_template(key, description) {
 | 
			
		||||
		if (description.type === 'boolean') {
 | 
			
		||||
			return html`
 | 
			
		||||
				<li class="w3-row">
 | 
			
		||||
					<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${title_case(key)}</label>
 | 
			
		||||
					<div class="w3-quarter w3-padding">${description.description}</div>
 | 
			
		||||
					<div class="w3-quarter w3-padding w3-center"><input class="w3-check" type="checkbox" ?checked=${description.value} id=${'gs_' + key}></input></div>
 | 
			
		||||
					<button class="w3-quarter w3-button w3-theme-action" @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.firstChild.checked)}>Set</button>
 | 
			
		||||
				</li>
 | 
			
		||||
				<div style="margin-top: 1em">
 | 
			
		||||
					<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
 | 
			
		||||
					<div>
 | 
			
		||||
						<input type="checkbox" ?checked=${description.value} id=${'gs_' + key}></input>
 | 
			
		||||
						<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.checked)}>Set</button>
 | 
			
		||||
						<div>${description.description}</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			`;
 | 
			
		||||
		} else if (description.type === 'textarea') {
 | 
			
		||||
			return html`
 | 
			
		||||
				<li class="w3-row">
 | 
			
		||||
					<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold"
 | 
			
		||||
						>${title_case(key)}</label
 | 
			
		||||
					>
 | 
			
		||||
					<div class="w3-rest w3-padding">${description.description}</div>
 | 
			
		||||
					<textarea
 | 
			
		||||
						class="w3-input"
 | 
			
		||||
						style="vertical-align: top; resize: vertical"
 | 
			
		||||
						id=${'gs_' + key}
 | 
			
		||||
					>
 | 
			
		||||
${description.value}</textarea
 | 
			
		||||
					>
 | 
			
		||||
					<button
 | 
			
		||||
						class="w3-button w3-right w3-quarter w3-theme-action"
 | 
			
		||||
						@click=${(e) =>
 | 
			
		||||
							global_settings_set(
 | 
			
		||||
								key,
 | 
			
		||||
								e.srcElement.previousElementSibling.value
 | 
			
		||||
							)}
 | 
			
		||||
					>
 | 
			
		||||
						Set
 | 
			
		||||
					</button>
 | 
			
		||||
				</li>
 | 
			
		||||
				<div style="margin-top: 1em"">
 | 
			
		||||
					<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
 | 
			
		||||
					<div style="width: 100%; padding: 0; margin: 0">
 | 
			
		||||
						<div style="width: 90%; padding: 0 margin: 0">
 | 
			
		||||
							<textarea style="vertical-align: top; width: 100%" rows=20 cols=80 id=${'gs_' + key}>${description.value}</textarea>
 | 
			
		||||
						</div>
 | 
			
		||||
						<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.firstElementChild.value)}>Set</button>
 | 
			
		||||
						<div>${description.description}</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			`;
 | 
			
		||||
		} else if (description.type != 'hidden') {
 | 
			
		||||
		} else {
 | 
			
		||||
			return html`
 | 
			
		||||
				<li class="w3-row">
 | 
			
		||||
					<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${title_case(key)}</label>
 | 
			
		||||
					<div class="w3-quarter w3-padding">${description.description}</div>
 | 
			
		||||
					<input class="w3-input w3-quarter" type="text" value="${description.value}" id=${'gs_' + key}></input>
 | 
			
		||||
					<button class="w3-button w3-quarter w3-theme-action" @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.value)}>Set</button>
 | 
			
		||||
				</li>
 | 
			
		||||
				<div style="margin-top: 1em">
 | 
			
		||||
					<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
 | 
			
		||||
					<div>
 | 
			
		||||
						<input type="text" value="${description.value}" id=${'gs_' + key}></input>
 | 
			
		||||
						<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.value)}>Set</button>
 | 
			
		||||
						<div>${description.description}</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	const user_template = (user, permissions) => html`
 | 
			
		||||
		<li class="w3-card w3-margin">
 | 
			
		||||
			<button
 | 
			
		||||
				class="w3-button w3-theme-action"
 | 
			
		||||
				@click=${(e) => delete_user(user)}
 | 
			
		||||
			>
 | 
			
		||||
				Delete
 | 
			
		||||
			</button>
 | 
			
		||||
		<li>
 | 
			
		||||
			<button @click=${(e) => delete_user(user)}>Delete</button>
 | 
			
		||||
			${user}: ${permissions.map((x) => permission_template(x))}
 | 
			
		||||
		</li>
 | 
			
		||||
	`;
 | 
			
		||||
	const users_template = (users) =>
 | 
			
		||||
		html` <header class="w3-container w3-theme-l2"><h2>Users</h2></header>
 | 
			
		||||
			<ul class="w3-ul">
 | 
			
		||||
		html`<h2>Users</h2>
 | 
			
		||||
			<ul>
 | 
			
		||||
				${Object.entries(users).map((u) => user_template(u[0], u[1]))}
 | 
			
		||||
			</ul>`;
 | 
			
		||||
	const page_template = (data) =>
 | 
			
		||||
		html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%">
 | 
			
		||||
			<header class="w3-container w3-theme-l2"><h2>Global Settings</h2></header>
 | 
			
		||||
			<div class="w3-container">
 | 
			
		||||
				<ul class="w3-ul">
 | 
			
		||||
					${Object.keys(data.settings)
 | 
			
		||||
						.sort()
 | 
			
		||||
						.map((x) => html`${input_template(x, data.settings[x])}`)}
 | 
			
		||||
				</ul>
 | 
			
		||||
			<h2>Global Settings</h2>
 | 
			
		||||
			<div>
 | 
			
		||||
				${Object.keys(data.settings)
 | 
			
		||||
					.sort()
 | 
			
		||||
					.map((x) => html`${input_template(x, data.settings[x])}`)}
 | 
			
		||||
			</div>
 | 
			
		||||
			${users_template(data.users)}
 | 
			
		||||
		</div> `;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,251 +0,0 @@
 | 
			
		||||
/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
 | 
			
		||||
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
 | 
			
		||||
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
 | 
			
		||||
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
 | 
			
		||||
article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
 | 
			
		||||
audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
 | 
			
		||||
audio:not([controls]){display:none;height:0}[hidden],template{display:none}
 | 
			
		||||
a{background-color:transparent}a:active,a:hover{outline-width:0}
 | 
			
		||||
abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
 | 
			
		||||
b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
 | 
			
		||||
small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
 | 
			
		||||
sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none}
 | 
			
		||||
code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible}
 | 
			
		||||
button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
 | 
			
		||||
button,input{overflow:visible}button,select{text-transform:none}
 | 
			
		||||
button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}
 | 
			
		||||
button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
 | 
			
		||||
button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
 | 
			
		||||
fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
 | 
			
		||||
legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
 | 
			
		||||
[type=checkbox],[type=radio]{padding:0}
 | 
			
		||||
[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
 | 
			
		||||
[type=search]{-webkit-appearance:textfield;outline-offset:-2px}
 | 
			
		||||
[type=search]::-webkit-search-decoration{-webkit-appearance:none}
 | 
			
		||||
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
 | 
			
		||||
/* End extract */
 | 
			
		||||
html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden}
 | 
			
		||||
h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
 | 
			
		||||
.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
 | 
			
		||||
h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
 | 
			
		||||
hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit}
 | 
			
		||||
.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
 | 
			
		||||
.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
 | 
			
		||||
.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
 | 
			
		||||
.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
 | 
			
		||||
.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
 | 
			
		||||
.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
 | 
			
		||||
.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap}
 | 
			
		||||
.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
 | 
			
		||||
.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}   
 | 
			
		||||
.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
 | 
			
		||||
.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
 | 
			
		||||
.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
 | 
			
		||||
.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
 | 
			
		||||
.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
 | 
			
		||||
.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
 | 
			
		||||
.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
 | 
			
		||||
.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
 | 
			
		||||
.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
 | 
			
		||||
.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
 | 
			
		||||
.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
 | 
			
		||||
.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
 | 
			
		||||
.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
 | 
			
		||||
.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
 | 
			
		||||
.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
 | 
			
		||||
.w3-main,#main{transition:margin-left .4s}
 | 
			
		||||
.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
 | 
			
		||||
.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
 | 
			
		||||
.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
 | 
			
		||||
.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
 | 
			
		||||
.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
 | 
			
		||||
.w3-bar .w3-button{white-space:normal}
 | 
			
		||||
.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
 | 
			
		||||
.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
 | 
			
		||||
.w3-responsive{display:block;overflow-x:auto}
 | 
			
		||||
.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
 | 
			
		||||
.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
 | 
			
		||||
.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
 | 
			
		||||
.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
 | 
			
		||||
.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
 | 
			
		||||
.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
 | 
			
		||||
@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
 | 
			
		||||
.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
 | 
			
		||||
.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
 | 
			
		||||
@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
 | 
			
		||||
.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
 | 
			
		||||
.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
 | 
			
		||||
.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
 | 
			
		||||
.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
 | 
			
		||||
.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
 | 
			
		||||
.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
 | 
			
		||||
.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
 | 
			
		||||
@media (max-width:1205px){.w3-auto{max-width:95%}}
 | 
			
		||||
@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
 | 
			
		||||
.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}	
 | 
			
		||||
.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
 | 
			
		||||
.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
 | 
			
		||||
@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
 | 
			
		||||
@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
 | 
			
		||||
@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
 | 
			
		||||
@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
 | 
			
		||||
.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
 | 
			
		||||
.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
 | 
			
		||||
.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
 | 
			
		||||
.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
 | 
			
		||||
.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
 | 
			
		||||
.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
 | 
			
		||||
.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
 | 
			
		||||
.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
 | 
			
		||||
.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
 | 
			
		||||
.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
 | 
			
		||||
.w3-display-position{position:absolute}
 | 
			
		||||
.w3-circle{border-radius:50%}
 | 
			
		||||
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
 | 
			
		||||
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
 | 
			
		||||
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
 | 
			
		||||
.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
 | 
			
		||||
.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
 | 
			
		||||
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
 | 
			
		||||
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
 | 
			
		||||
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
 | 
			
		||||
.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
 | 
			
		||||
.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
 | 
			
		||||
.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
 | 
			
		||||
.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
 | 
			
		||||
.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
 | 
			
		||||
.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
 | 
			
		||||
.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
 | 
			
		||||
.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
 | 
			
		||||
.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
 | 
			
		||||
.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
 | 
			
		||||
.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
 | 
			
		||||
.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
 | 
			
		||||
.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
 | 
			
		||||
.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
 | 
			
		||||
.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
 | 
			
		||||
.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
 | 
			
		||||
.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
 | 
			
		||||
.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
 | 
			
		||||
.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
 | 
			
		||||
.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
 | 
			
		||||
.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
 | 
			
		||||
.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
 | 
			
		||||
.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
 | 
			
		||||
.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
 | 
			
		||||
.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
 | 
			
		||||
.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
 | 
			
		||||
.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
 | 
			
		||||
.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
 | 
			
		||||
.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
 | 
			
		||||
.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
 | 
			
		||||
.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
 | 
			
		||||
.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
 | 
			
		||||
.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
 | 
			
		||||
.w3-left{float:left!important}.w3-right{float:right!important}
 | 
			
		||||
.w3-button:hover{color:#000!important;background-color:#ccc!important}
 | 
			
		||||
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
 | 
			
		||||
.w3-hover-none:hover{box-shadow:none!important}
 | 
			
		||||
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
 | 
			
		||||
/* Colors */
 | 
			
		||||
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
 | 
			
		||||
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
 | 
			
		||||
.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
 | 
			
		||||
.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
 | 
			
		||||
.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
 | 
			
		||||
.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
 | 
			
		||||
.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
 | 
			
		||||
.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
 | 
			
		||||
.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
 | 
			
		||||
.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
 | 
			
		||||
.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
 | 
			
		||||
.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
 | 
			
		||||
.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
 | 
			
		||||
.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
 | 
			
		||||
.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
 | 
			
		||||
.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
 | 
			
		||||
.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
 | 
			
		||||
.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
 | 
			
		||||
.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
 | 
			
		||||
.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
 | 
			
		||||
.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
 | 
			
		||||
.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
 | 
			
		||||
.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
 | 
			
		||||
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
 | 
			
		||||
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
 | 
			
		||||
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
 | 
			
		||||
.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
 | 
			
		||||
.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
 | 
			
		||||
.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
 | 
			
		||||
.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
 | 
			
		||||
.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
 | 
			
		||||
.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
 | 
			
		||||
.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
 | 
			
		||||
.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
 | 
			
		||||
.w3-danger{color:#fff!important;background-color:#dd0000!important}
 | 
			
		||||
.w3-note{color:#000!important;background-color:#fff599!important}
 | 
			
		||||
.w3-info{color:#fff!important;background-color:#0a6fc2!important}
 | 
			
		||||
.w3-warning{color:#000!important;background-color:#ffb305!important}
 | 
			
		||||
.w3-success{color:#fff!important;background-color:#008a00!important}
 | 
			
		||||
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
 | 
			
		||||
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
 | 
			
		||||
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
 | 
			
		||||
.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
 | 
			
		||||
.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
 | 
			
		||||
.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
 | 
			
		||||
.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
 | 
			
		||||
.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
 | 
			
		||||
.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
 | 
			
		||||
.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
 | 
			
		||||
.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
 | 
			
		||||
.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
 | 
			
		||||
.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
 | 
			
		||||
.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
 | 
			
		||||
.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
 | 
			
		||||
.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
 | 
			
		||||
.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
 | 
			
		||||
.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
 | 
			
		||||
.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
 | 
			
		||||
.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
 | 
			
		||||
.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
 | 
			
		||||
.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
 | 
			
		||||
.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
 | 
			
		||||
.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
 | 
			
		||||
.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
 | 
			
		||||
.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
 | 
			
		||||
.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
 | 
			
		||||
.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
 | 
			
		||||
.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
 | 
			
		||||
.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
 | 
			
		||||
.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
 | 
			
		||||
.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
 | 
			
		||||
.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
 | 
			
		||||
.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
 | 
			
		||||
.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
 | 
			
		||||
.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
 | 
			
		||||
.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
 | 
			
		||||
.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
 | 
			
		||||
.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
 | 
			
		||||
.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
 | 
			
		||||
.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
 | 
			
		||||
.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
 | 
			
		||||
.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
 | 
			
		||||
.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
 | 
			
		||||
.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
 | 
			
		||||
.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
 | 
			
		||||
.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
 | 
			
		||||
.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
 | 
			
		||||
.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
 | 
			
		||||
.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
 | 
			
		||||
.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
 | 
			
		||||
.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
 | 
			
		||||
.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
 | 
			
		||||
.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
 | 
			
		||||
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
 | 
			
		||||
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
 | 
			
		||||
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
 | 
			
		||||
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "📜",
 | 
			
		||||
	"previous": "&sJqeyYjHys6Z8IqqtZ2ij2ZC1E2xieu/FU/u2hE+O1U=.sha256"
 | 
			
		||||
	"previous": "&miGORZ8BwjHg2YO0t4bms6SI28XWPYqnqOZ8u9zsbZc=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,7 @@ function* treeify(prefix, o) {
 | 
			
		||||
 | 
			
		||||
function markdown(md) {
 | 
			
		||||
	let parsed = new commonmark.Parser().parse(md ?? '*undocumented*');
 | 
			
		||||
	return new commonmark.HtmlRenderer({safe: true}).render(parsed);
 | 
			
		||||
	return new commonmark.HtmlRenderer().render(parsed);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function document(api) {
 | 
			
		||||
@@ -55,9 +55,6 @@ app.setDocument(`<head>
 | 
			
		||||
</head>
 | 
			
		||||
<body style="color:#fff">
 | 
			
		||||
	${markdown(docs.docs.global)}
 | 
			
		||||
	<!--
 | 
			
		||||
	${Object.keys(docs.docs).filter(x => [...treeify('', globalThis)].indexOf(x) == -1).map(x => `<p>STALE: ${x}</p>`).join('')}
 | 
			
		||||
	-->
 | 
			
		||||
	${[...treeify('', globalThis)].map(x => document(x)).join('\n')}
 | 
			
		||||
	<a id="Database"></a>
 | 
			
		||||
	${markdown(docs.docs.database)}
 | 
			
		||||
 
 | 
			
		||||
@@ -195,6 +195,51 @@ Call a function after some delay.
 | 
			
		||||
 * *Number* **timeout** Number of milliseconds to wait before calling the callback function.
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
docs['parseHttpRequest()'] = `
 | 
			
		||||
Parses an HTTP request.
 | 
			
		||||
### Parameters
 | 
			
		||||
 * *Uint8Array* **request** The request data.  Maybe be partial or contain extra data.  The return value will
 | 
			
		||||
    indicate when and where it is complete.
 | 
			
		||||
 * *Number* **last_length** The length of the data passed on a previous attempt for the same request, or 0 initially.
 | 
			
		||||
### Returns
 | 
			
		||||
 * *Integer* **-2** if the request is incomplete.
 | 
			
		||||
 * *Integer* **-1** if the request could not be parsed.
 | 
			
		||||
 * *Object* An object with **bytes_parsed**, **minor_version**, **path**, and **headers** fields on successful parse.
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
docs['parseHttpResponse()'] = `
 | 
			
		||||
Parses an HTTP response.
 | 
			
		||||
### Parameters
 | 
			
		||||
 * *Uint8Array* **response** The response data.  Maybe be partial or contain extra data.  The return value will
 | 
			
		||||
    indicate when and where it is complete.
 | 
			
		||||
 * *Number* **last_length** The length of the data passed on a previous attempt for the same response, or 0 initially.
 | 
			
		||||
### Returns
 | 
			
		||||
 * *Integer* **-2** if the response is incomplete.
 | 
			
		||||
 * *Integer* **-1** if the response could not be parsed.
 | 
			
		||||
 * *Object* An object with **bytes_parsed**, **minor_version**, **status**, **message**, and **headers** fields on successful parse.
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
docs['sha1Digest()'] = `
 | 
			
		||||
Calculates a SHA1 digest.
 | 
			
		||||
 | 
			
		||||
Completes synchronously.
 | 
			
		||||
### Parameters
 | 
			
		||||
 * *String* **value** The value for which to calculate the digest.
 | 
			
		||||
### Returns
 | 
			
		||||
*String* The SHA1 digest of UTF-8 encoded \`value\`.
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
docs['maskBytes()'] = `
 | 
			
		||||
Masks bytes for WebSocket communication.
 | 
			
		||||
 | 
			
		||||
Completes synchronously.
 | 
			
		||||
### Parameters
 | 
			
		||||
 * *Uint8Array* **bytes** The byte array of data to mask.
 | 
			
		||||
 * *Uint32* **mask** The mask to apply.
 | 
			
		||||
### Returns
 | 
			
		||||
*Uint32Array* The masked bytes.
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
docs['exit()'] = `
 | 
			
		||||
Exits the app.  But why would you want to do that?
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "💻",
 | 
			
		||||
	"previous": "&sFRTDn/RpxP1NJeECXHrXKwCRUJsEOEDVaCMPl50zpM=.sha256"
 | 
			
		||||
	"previous": "&icsPplXHgmpkbNWyo/WTvRdT/A8BXxW4lJixOtP4ueQ=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,10 @@ async function fetch_info(apps) {
 | 
			
		||||
	return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 *
 | 
			
		||||
 *
 | 
			
		||||
 */
 | 
			
		||||
async function fetch_shared_apps() {
 | 
			
		||||
	let messages = {};
 | 
			
		||||
 | 
			
		||||
@@ -65,17 +69,17 @@ async function main() {
 | 
			
		||||
	const stylesheet = `
 | 
			
		||||
		body {
 | 
			
		||||
			color: whitesmoke;
 | 
			
		||||
			margin: 8px;
 | 
			
		||||
			font-family: sans-serif;
 | 
			
		||||
			margin: 16px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		.iconbox {
 | 
			
		||||
		.container {
 | 
			
		||||
			display: grid;
 | 
			
		||||
			grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		.iconbox::after {
 | 
			
		||||
			content: "";
 | 
			
		||||
			flex: auto;
 | 
			
		||||
			grid-template-columns: repeat(auto-fill, 64px);
 | 
			
		||||
			gap: 1em;
 | 
			
		||||
			justify-content: space-around;
 | 
			
		||||
			background-color: #ffffff10;
 | 
			
		||||
			border: 2px solid #073642;
 | 
			
		||||
			border-radius: 8px;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		.app {
 | 
			
		||||
@@ -97,28 +101,16 @@ async function main() {
 | 
			
		||||
	`;
 | 
			
		||||
 | 
			
		||||
	const body = `
 | 
			
		||||
		<h1>Welcome to Tilde Friends</h1>
 | 
			
		||||
		<h1 style="text-shadow: #808080 0 0 10px;">Welcome to Tilde Friends.</h1>
 | 
			
		||||
 | 
			
		||||
		<div class="w3-card-4 w3-margin-top">
 | 
			
		||||
			<header class="w3-container w3-light-blue">
 | 
			
		||||
				<h2>Your Apps</h2>
 | 
			
		||||
			</header>
 | 
			
		||||
			<div id="apps" class="w3-indigo iconbox"></div>
 | 
			
		||||
		</div>
 | 
			
		||||
		<h2>your apps</h2>
 | 
			
		||||
		<div id="apps" class="container"></div>
 | 
			
		||||
 | 
			
		||||
		<div class="w3-card-4 w3-margin-top">
 | 
			
		||||
			<header class="w3-container w3-light-blue">
 | 
			
		||||
				<h2>Shared Apps</h2>
 | 
			
		||||
			</header>
 | 
			
		||||
			<div id="shared_apps" class="w3-indigo iconbox"></div>
 | 
			
		||||
		</div>
 | 
			
		||||
		<h2>shared apps</h2>
 | 
			
		||||
		<div id="shared_apps" class="container"></div>
 | 
			
		||||
 | 
			
		||||
		<div class="w3-card-4 w3-margin-top">
 | 
			
		||||
			<header class="w3-container w3-light-blue">
 | 
			
		||||
				<h2>Core Apps</h2>
 | 
			
		||||
			</header>
 | 
			
		||||
			<div id="core_apps" class="w3-indigo iconbox"></div>
 | 
			
		||||
		</div>
 | 
			
		||||
		<h2>core apps</h2>
 | 
			
		||||
		<div id="core_apps" class="container"></div>
 | 
			
		||||
	`;
 | 
			
		||||
 | 
			
		||||
	const script = `
 | 
			
		||||
@@ -134,13 +126,9 @@ async function main() {
 | 
			
		||||
 | 
			
		||||
			// For each app in the provided list
 | 
			
		||||
			for (let app of Object.keys(apps).sort()) {
 | 
			
		||||
 | 
			
		||||
				// Create the item
 | 
			
		||||
				let inline = document.createElement('div');
 | 
			
		||||
				inline.style.display = 'inline-block';
 | 
			
		||||
				inline.classList.add('w3-button');
 | 
			
		||||
				list.appendChild(inline);
 | 
			
		||||
				let div = document.createElement('div');
 | 
			
		||||
				inline.appendChild(div);
 | 
			
		||||
				let div = list.appendChild(document.createElement('div'));
 | 
			
		||||
				div.classList.add('app');
 | 
			
		||||
 | 
			
		||||
				// The app's icon
 | 
			
		||||
@@ -173,13 +161,12 @@ async function main() {
 | 
			
		||||
	<!DOCTYPE html>
 | 
			
		||||
	<html>
 | 
			
		||||
		<head>
 | 
			
		||||
			<link type="text/css" rel="stylesheet" href="w3.css"></link>
 | 
			
		||||
			<style>
 | 
			
		||||
				${stylesheet}
 | 
			
		||||
			</style>
 | 
			
		||||
		</head>
 | 
			
		||||
 | 
			
		||||
		<body class="w3-darkgray">
 | 
			
		||||
		<body>
 | 
			
		||||
			${body}
 | 
			
		||||
		</body>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										251
									
								
								apps/apps/w3.css
									
									
									
									
									
								
							
							
						
						
									
										251
									
								
								apps/apps/w3.css
									
									
									
									
									
								
							@@ -1,251 +0,0 @@
 | 
			
		||||
/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
 | 
			
		||||
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
 | 
			
		||||
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
 | 
			
		||||
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
 | 
			
		||||
article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
 | 
			
		||||
audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
 | 
			
		||||
audio:not([controls]){display:none;height:0}[hidden],template{display:none}
 | 
			
		||||
a{background-color:transparent}a:active,a:hover{outline-width:0}
 | 
			
		||||
abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
 | 
			
		||||
b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
 | 
			
		||||
small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
 | 
			
		||||
sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none}
 | 
			
		||||
code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible}
 | 
			
		||||
button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
 | 
			
		||||
button,input{overflow:visible}button,select{text-transform:none}
 | 
			
		||||
button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}
 | 
			
		||||
button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
 | 
			
		||||
button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
 | 
			
		||||
fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
 | 
			
		||||
legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
 | 
			
		||||
[type=checkbox],[type=radio]{padding:0}
 | 
			
		||||
[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
 | 
			
		||||
[type=search]{-webkit-appearance:textfield;outline-offset:-2px}
 | 
			
		||||
[type=search]::-webkit-search-decoration{-webkit-appearance:none}
 | 
			
		||||
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
 | 
			
		||||
/* End extract */
 | 
			
		||||
html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden}
 | 
			
		||||
h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
 | 
			
		||||
.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
 | 
			
		||||
h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
 | 
			
		||||
hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit}
 | 
			
		||||
.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
 | 
			
		||||
.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
 | 
			
		||||
.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
 | 
			
		||||
.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
 | 
			
		||||
.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
 | 
			
		||||
.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
 | 
			
		||||
.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap}
 | 
			
		||||
.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
 | 
			
		||||
.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}   
 | 
			
		||||
.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
 | 
			
		||||
.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
 | 
			
		||||
.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
 | 
			
		||||
.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
 | 
			
		||||
.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
 | 
			
		||||
.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
 | 
			
		||||
.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
 | 
			
		||||
.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
 | 
			
		||||
.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
 | 
			
		||||
.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
 | 
			
		||||
.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
 | 
			
		||||
.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
 | 
			
		||||
.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
 | 
			
		||||
.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
 | 
			
		||||
.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
 | 
			
		||||
.w3-main,#main{transition:margin-left .4s}
 | 
			
		||||
.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
 | 
			
		||||
.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
 | 
			
		||||
.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
 | 
			
		||||
.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
 | 
			
		||||
.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
 | 
			
		||||
.w3-bar .w3-button{white-space:normal}
 | 
			
		||||
.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
 | 
			
		||||
.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
 | 
			
		||||
.w3-responsive{display:block;overflow-x:auto}
 | 
			
		||||
.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
 | 
			
		||||
.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
 | 
			
		||||
.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
 | 
			
		||||
.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
 | 
			
		||||
.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
 | 
			
		||||
.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
 | 
			
		||||
@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
 | 
			
		||||
.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
 | 
			
		||||
.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
 | 
			
		||||
@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
 | 
			
		||||
.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
 | 
			
		||||
.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
 | 
			
		||||
.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
 | 
			
		||||
.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
 | 
			
		||||
.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
 | 
			
		||||
.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
 | 
			
		||||
.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
 | 
			
		||||
@media (max-width:1205px){.w3-auto{max-width:95%}}
 | 
			
		||||
@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
 | 
			
		||||
.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}	
 | 
			
		||||
.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
 | 
			
		||||
.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
 | 
			
		||||
@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
 | 
			
		||||
@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
 | 
			
		||||
@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
 | 
			
		||||
@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
 | 
			
		||||
.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
 | 
			
		||||
.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
 | 
			
		||||
.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
 | 
			
		||||
.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
 | 
			
		||||
.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
 | 
			
		||||
.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
 | 
			
		||||
.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
 | 
			
		||||
.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
 | 
			
		||||
.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
 | 
			
		||||
.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
 | 
			
		||||
.w3-display-position{position:absolute}
 | 
			
		||||
.w3-circle{border-radius:50%}
 | 
			
		||||
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
 | 
			
		||||
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
 | 
			
		||||
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
 | 
			
		||||
.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
 | 
			
		||||
.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
 | 
			
		||||
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
 | 
			
		||||
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
 | 
			
		||||
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
 | 
			
		||||
.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
 | 
			
		||||
.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
 | 
			
		||||
.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
 | 
			
		||||
.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
 | 
			
		||||
.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
 | 
			
		||||
.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
 | 
			
		||||
.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
 | 
			
		||||
.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
 | 
			
		||||
.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
 | 
			
		||||
.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
 | 
			
		||||
.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
 | 
			
		||||
.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
 | 
			
		||||
.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
 | 
			
		||||
.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
 | 
			
		||||
.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
 | 
			
		||||
.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
 | 
			
		||||
.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
 | 
			
		||||
.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
 | 
			
		||||
.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
 | 
			
		||||
.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
 | 
			
		||||
.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
 | 
			
		||||
.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
 | 
			
		||||
.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
 | 
			
		||||
.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
 | 
			
		||||
.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
 | 
			
		||||
.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
 | 
			
		||||
.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
 | 
			
		||||
.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
 | 
			
		||||
.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
 | 
			
		||||
.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
 | 
			
		||||
.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
 | 
			
		||||
.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
 | 
			
		||||
.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
 | 
			
		||||
.w3-left{float:left!important}.w3-right{float:right!important}
 | 
			
		||||
.w3-button:hover{color:#000!important;background-color:#ccc!important}
 | 
			
		||||
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
 | 
			
		||||
.w3-hover-none:hover{box-shadow:none!important}
 | 
			
		||||
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
 | 
			
		||||
/* Colors */
 | 
			
		||||
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
 | 
			
		||||
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
 | 
			
		||||
.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
 | 
			
		||||
.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
 | 
			
		||||
.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
 | 
			
		||||
.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
 | 
			
		||||
.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
 | 
			
		||||
.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
 | 
			
		||||
.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
 | 
			
		||||
.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
 | 
			
		||||
.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
 | 
			
		||||
.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
 | 
			
		||||
.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
 | 
			
		||||
.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
 | 
			
		||||
.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
 | 
			
		||||
.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
 | 
			
		||||
.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
 | 
			
		||||
.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
 | 
			
		||||
.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
 | 
			
		||||
.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
 | 
			
		||||
.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
 | 
			
		||||
.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
 | 
			
		||||
.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
 | 
			
		||||
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
 | 
			
		||||
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
 | 
			
		||||
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
 | 
			
		||||
.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
 | 
			
		||||
.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
 | 
			
		||||
.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
 | 
			
		||||
.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
 | 
			
		||||
.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
 | 
			
		||||
.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
 | 
			
		||||
.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
 | 
			
		||||
.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
 | 
			
		||||
.w3-danger{color:#fff!important;background-color:#dd0000!important}
 | 
			
		||||
.w3-note{color:#000!important;background-color:#fff599!important}
 | 
			
		||||
.w3-info{color:#fff!important;background-color:#0a6fc2!important}
 | 
			
		||||
.w3-warning{color:#000!important;background-color:#ffb305!important}
 | 
			
		||||
.w3-success{color:#fff!important;background-color:#008a00!important}
 | 
			
		||||
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
 | 
			
		||||
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
 | 
			
		||||
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
 | 
			
		||||
.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
 | 
			
		||||
.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
 | 
			
		||||
.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
 | 
			
		||||
.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
 | 
			
		||||
.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
 | 
			
		||||
.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
 | 
			
		||||
.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
 | 
			
		||||
.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
 | 
			
		||||
.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
 | 
			
		||||
.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
 | 
			
		||||
.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
 | 
			
		||||
.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
 | 
			
		||||
.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
 | 
			
		||||
.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
 | 
			
		||||
.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
 | 
			
		||||
.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
 | 
			
		||||
.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
 | 
			
		||||
.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
 | 
			
		||||
.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
 | 
			
		||||
.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
 | 
			
		||||
.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
 | 
			
		||||
.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
 | 
			
		||||
.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
 | 
			
		||||
.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
 | 
			
		||||
.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
 | 
			
		||||
.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
 | 
			
		||||
.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
 | 
			
		||||
.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
 | 
			
		||||
.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
 | 
			
		||||
.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
 | 
			
		||||
.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
 | 
			
		||||
.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
 | 
			
		||||
.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
 | 
			
		||||
.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
 | 
			
		||||
.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
 | 
			
		||||
.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
 | 
			
		||||
.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
 | 
			
		||||
.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
 | 
			
		||||
.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
 | 
			
		||||
.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
 | 
			
		||||
.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
 | 
			
		||||
.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
 | 
			
		||||
.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
 | 
			
		||||
.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
 | 
			
		||||
.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
 | 
			
		||||
.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
 | 
			
		||||
.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
 | 
			
		||||
.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
 | 
			
		||||
.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
 | 
			
		||||
.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
 | 
			
		||||
.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
 | 
			
		||||
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
 | 
			
		||||
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
 | 
			
		||||
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
 | 
			
		||||
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🪵",
 | 
			
		||||
	"previous": "&3jabNEk6W2uolzTvfXX6fcWF50N3501vtgZ6ZxFVJ1s=.sha256"
 | 
			
		||||
	"previous": "&TIrBnpN3iz3O9L9MCCteAcVJZjA83EKdcfu4SCM76VE=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -52,8 +52,8 @@ export async function get_blog_message(id) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function markdown(md) {
 | 
			
		||||
	let reader = new commonmark.Parser();
 | 
			
		||||
	let writer = new commonmark.HtmlRenderer({safe: true});
 | 
			
		||||
	let reader = new commonmark.Parser({safe: true});
 | 
			
		||||
	let writer = new commonmark.HtmlRenderer();
 | 
			
		||||
	let parsed = reader.parse(md || '');
 | 
			
		||||
	let walker = parsed.walker();
 | 
			
		||||
	let event, node;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								apps/blog/commonmark.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								apps/blog/commonmark.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										42
									
								
								apps/blog/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										42
									
								
								apps/blog/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -1,5 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "💽",
 | 
			
		||||
	"previous": "&uQzkIe/Aj8yNhLKe3hEq+5fEJsGwIUx8NVBjJKwoV2U=.sha256"
 | 
			
		||||
	"emoji": "💽"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -51,19 +51,6 @@ async function key_list(db) {
 | 
			
		||||
	app.setDocument(doc);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function load() {
 | 
			
		||||
	if (core.user?.credentials?.session) {
 | 
			
		||||
		database_list();
 | 
			
		||||
	} else {
 | 
			
		||||
		app.setDocument(`<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
<body style="background: #888">
 | 
			
		||||
	<h1>Must be signed in to examine databases.</h1>
 | 
			
		||||
</body>
 | 
			
		||||
</html>`);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
core.register('message', async function (message) {
 | 
			
		||||
	if (message.event == 'hashChange') {
 | 
			
		||||
		let hash = message.hash.substring(1);
 | 
			
		||||
@@ -75,9 +62,9 @@ core.register('message', async function (message) {
 | 
			
		||||
		} else if (hash.length) {
 | 
			
		||||
			key_list(await database(hash.split(':').slice(1).join(':')));
 | 
			
		||||
		} else {
 | 
			
		||||
			load();
 | 
			
		||||
			database_list();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
load();
 | 
			
		||||
database_list();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "➡️",
 | 
			
		||||
	"previous": "&YDDSzbRD8NFAykYlZnk4r4hAK5qXjT5LmKE6rhS1s+A=.sha256"
 | 
			
		||||
	"emoji": "➡️"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,7 @@ async function contacts_internal(id, last_row_id, following, max_row_id) {
 | 
			
		||||
	result.blocking = result.blocking || {};
 | 
			
		||||
	let contacts = await query(
 | 
			
		||||
		`
 | 
			
		||||
				SELECT json(content) AS content FROM messages
 | 
			
		||||
				SELECT content FROM messages
 | 
			
		||||
				WHERE author = ? AND
 | 
			
		||||
				rowid > ? AND
 | 
			
		||||
				rowid <= ? AND
 | 
			
		||||
@@ -189,6 +189,50 @@ async function fetch_about(db, ids, users) {
 | 
			
		||||
	return Object.assign({}, users);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getAbout(db, id) {
 | 
			
		||||
	if (g_about_cache[id]) {
 | 
			
		||||
		return g_about_cache[id];
 | 
			
		||||
	}
 | 
			
		||||
	let o = await db.get(id + ':about');
 | 
			
		||||
	const k_version = 4;
 | 
			
		||||
	let f = o ? JSON.parse(o) : o;
 | 
			
		||||
	if (!f || f.version != k_version) {
 | 
			
		||||
		f = {about: {}, sequence: 0, version: k_version};
 | 
			
		||||
	}
 | 
			
		||||
	await ssb.sqlAsync(
 | 
			
		||||
		'SELECT ' +
 | 
			
		||||
			'  sequence, ' +
 | 
			
		||||
			'  content ' +
 | 
			
		||||
			'FROM messages ' +
 | 
			
		||||
			'WHERE ' +
 | 
			
		||||
			'  author = ?1 AND ' +
 | 
			
		||||
			'  sequence > ?2 AND ' +
 | 
			
		||||
			"  json_extract(content, '$.type') = 'about' AND " +
 | 
			
		||||
			"  json_extract(content, '$.about') = ?1 " +
 | 
			
		||||
			'UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 ' +
 | 
			
		||||
			'ORDER BY sequence',
 | 
			
		||||
		[id, f.sequence],
 | 
			
		||||
		function (row) {
 | 
			
		||||
			f.sequence = row.sequence;
 | 
			
		||||
			if (row.content) {
 | 
			
		||||
				let about = {};
 | 
			
		||||
				try {
 | 
			
		||||
					about = JSON.parse(row.content);
 | 
			
		||||
				} catch {}
 | 
			
		||||
				delete about.about;
 | 
			
		||||
				delete about.type;
 | 
			
		||||
				f.about = Object.assign(f.about, about);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	);
 | 
			
		||||
	let j = JSON.stringify(f);
 | 
			
		||||
	if (o != j) {
 | 
			
		||||
		await db.set(id + ':about', j);
 | 
			
		||||
	}
 | 
			
		||||
	g_about_cache[id] = f.about;
 | 
			
		||||
	return f.about;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getSize(db, id) {
 | 
			
		||||
	let size = 0;
 | 
			
		||||
	await ssb.sqlAsync(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🪪",
 | 
			
		||||
	"previous": "&5kw/2PgcySwOYCmAkjHTR2xTkIx3i7UjQmtQ8MfgWw8=.sha256"
 | 
			
		||||
	"previous": "&kgukkyDk1RxgfzgMH6H/0QeDPIuwPZypLuAFax21ljk=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,5 @@
 | 
			
		||||
import * as tfrpc from '/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
const is_admin = core.user?.credentials?.permissions?.administration;
 | 
			
		||||
 | 
			
		||||
tfrpc.register(async function get_private_key(id) {
 | 
			
		||||
	return bip39Words(await ssb.getPrivateKey(id));
 | 
			
		||||
});
 | 
			
		||||
@@ -17,44 +15,11 @@ tfrpc.register(async function delete_id(id) {
 | 
			
		||||
tfrpc.register(async function reload() {
 | 
			
		||||
	await main();
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function make_server(id) {
 | 
			
		||||
	return await ssb.swapWithServerIdentity(id);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	let ids = await ssb.getIdentities();
 | 
			
		||||
	let server_id = await ssb.getServerIdentity();
 | 
			
		||||
	await app.setDocument(
 | 
			
		||||
		`
 | 
			
		||||
		<head>
 | 
			
		||||
			<link rel="stylesheet" href="w3.css"></link>
 | 
			
		||||
			<style>
 | 
			
		||||
				/* "2018 Sargasso Sea" */
 | 
			
		||||
				.w3-theme-l5 {color:#000 !important; background-color:#f3f4f7 !important}
 | 
			
		||||
				.w3-theme-l4 {color:#000 !important; background-color:#d7dbe3 !important}
 | 
			
		||||
				.w3-theme-l3 {color:#000 !important; background-color:#b0b6c8 !important}
 | 
			
		||||
				.w3-theme-l2 {color:#fff !important; background-color:#8892ac !important}
 | 
			
		||||
				.w3-theme-l1 {color:#fff !important; background-color:#636f8e !important}
 | 
			
		||||
				.w3-theme-d1 {color:#fff !important; background-color:#40485c !important}
 | 
			
		||||
				.w3-theme-d2 {color:#fff !important; background-color:#394052 !important}
 | 
			
		||||
				.w3-theme-d3 {color:#fff !important; background-color:#323848 !important}
 | 
			
		||||
				.w3-theme-d4 {color:#fff !important; background-color:#2b303d !important}
 | 
			
		||||
				.w3-theme-d5 {color:#fff !important; background-color:#242833 !important}
 | 
			
		||||
 | 
			
		||||
				.w3-theme-light {color:#000 !important; background-color:#f3f4f7 !important}
 | 
			
		||||
				.w3-theme-dark {color:#fff !important; background-color:#242833 !important}
 | 
			
		||||
				.w3-theme-action {color:#fff !important; background-color:#242833 !important}
 | 
			
		||||
 | 
			
		||||
				.w3-theme {color:#fff !important; background-color:#485167 !important}
 | 
			
		||||
				.w3-text-theme {color:#485167 !important}
 | 
			
		||||
				.w3-border-theme {border-color:#485167 !important}
 | 
			
		||||
 | 
			
		||||
				.w3-hover-theme:hover {color:#fff !important; background-color:#485167 !important}
 | 
			
		||||
				.w3-hover-text-theme:hover {color:#485167 !important}
 | 
			
		||||
				.w3-hover-border-theme:hover {border-color:#485167 !important}
 | 
			
		||||
			</style>
 | 
			
		||||
		</head>
 | 
			
		||||
		<body class="w3-theme-l3">
 | 
			
		||||
		`<body style="color: #fff">
 | 
			
		||||
		<script>const handler = {};</script>
 | 
			
		||||
		<script type="module">
 | 
			
		||||
			import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
@@ -62,8 +27,7 @@ async function main() {
 | 
			
		||||
				let id = event.srcElement.dataset.id;
 | 
			
		||||
				let element = document.createElement('textarea');
 | 
			
		||||
				element.value = await tfrpc.rpc.get_private_key(id);
 | 
			
		||||
				element.style = 'width: 100%; height: auto; read-only: true; resize: none';
 | 
			
		||||
				element.classList.add('w3-input');
 | 
			
		||||
				element.style = 'width: 100%; read-only: true';
 | 
			
		||||
				element.readOnly = true;
 | 
			
		||||
				event.srcElement.parentElement.appendChild(element);
 | 
			
		||||
				event.srcElement.onclick = event => handler.hide_id(event, element);
 | 
			
		||||
@@ -84,7 +48,7 @@ async function main() {
 | 
			
		||||
					alert('Successfully created: ' + id);
 | 
			
		||||
					await tfrpc.rpc.reload();
 | 
			
		||||
				} catch (e) {
 | 
			
		||||
					alert('Error creating identity: ' + e.message);
 | 
			
		||||
					alert('Error creating identity: ' + e);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			handler.hide_id = function hide_id(event, element) {
 | 
			
		||||
@@ -104,48 +68,24 @@ async function main() {
 | 
			
		||||
					alert('Error deleting ID: ' + e);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			handler.make_server = async function make_server(event) {
 | 
			
		||||
				let id = event.srcElement.dataset.id;
 | 
			
		||||
				try {
 | 
			
		||||
					if (confirm('Are you sure you want to make "' + id + '" the server identity?\\n\\nFor it to take effect, you will need to both sign in again and restart Tilde Friends.')) {
 | 
			
		||||
						await tfrpc.rpc.make_server(id);
 | 
			
		||||
					}
 | 
			
		||||
				} catch (e) {
 | 
			
		||||
					alert('Error making server ID: ' + e);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		</script>
 | 
			
		||||
		<header class="w3-theme w3-padding"><h1>SSB Identity Management</h1></header>
 | 
			
		||||
		<div class="w3-card-4 w3-margin">
 | 
			
		||||
			<header class="w3-container w3-theme-l2"><h2>Create a new identity</h2></header>
 | 
			
		||||
			<footer class="w3-padding">
 | 
			
		||||
				<button id="create_id" onclick="handler.create_id()" class="w3-button w3-theme">Create Identity</button>
 | 
			
		||||
			</footer>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div class="w3-card-4 w3-margin">
 | 
			
		||||
			<header class="w3-container w3-theme-l2"><h2>Import an SSB Identity from 12 BIP39 English Words</h2></header>
 | 
			
		||||
			<textarea id="add_id" style="width: 100%" rows="4" class="w3-input"></textarea>
 | 
			
		||||
			<footer class="w3-padding">
 | 
			
		||||
				<button id="add" onclick="handler.add_id(event)" class="w3-button w3-theme">Import Identity</button>
 | 
			
		||||
			</footer>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div class="w3-card-4 w3-margin">
 | 
			
		||||
			<header class="w3-container w3-theme-l2"><h2>Identities</h2></header>
 | 
			
		||||
			<ul class="w3-ul">` +
 | 
			
		||||
			(ids ?? [])
 | 
			
		||||
		<h1>SSB Identity Management</h1>
 | 
			
		||||
		<h2>Create a new identity</h2>
 | 
			
		||||
		<button id="create_id" onclick="handler.create_id()">Create Identity</button>
 | 
			
		||||
		<h2>Import an SSB Identity from 12 BIP39 English Words</h2>
 | 
			
		||||
		<textarea id="add_id" style="width: 100%" rows="4"></textarea><button id="add" onclick="handler.add_id(event)">Import Identity</button>
 | 
			
		||||
		<h2>Identities</h2>
 | 
			
		||||
		<ul>` +
 | 
			
		||||
			ids
 | 
			
		||||
				.map(
 | 
			
		||||
					(
 | 
			
		||||
						id
 | 
			
		||||
					) => `<li style="overflow: hidden; text-wrap: nowrap; text-overflow: ellipsis">
 | 
			
		||||
				<button onclick="handler.export_id(event)" data-id="${id}" class="w3-button w3-theme">Export Identity</button>
 | 
			
		||||
				<button onclick="handler.delete_id(event)" data-id="${id}" class="w3-button w3-theme">Delete Identity</button>
 | 
			
		||||
				${is_admin && id != server_id ? `<button onclick="handler.make_server(event)" data-id="${id}" class="w3-button w3-theme">Make Server Identity</button>` : ''}
 | 
			
		||||
				${id}${id == server_id ? ' <div class="w3-tag w3-theme-l4 w3-round">🖥 local server</div>' : ''}
 | 
			
		||||
			</li>`
 | 
			
		||||
					(id) => `<li>
 | 
			
		||||
			<button onclick="handler.export_id(event)" data-id="${id}">Export Identity</button>
 | 
			
		||||
			<button onclick="handler.delete_id(event)" data-id="${id}">Delete Identity</button>
 | 
			
		||||
			${id}
 | 
			
		||||
		</li>`
 | 
			
		||||
				)
 | 
			
		||||
				.join('\n') +
 | 
			
		||||
			`	</ul>
 | 
			
		||||
		</div>
 | 
			
		||||
	</body>`
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,251 +0,0 @@
 | 
			
		||||
/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
 | 
			
		||||
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
 | 
			
		||||
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
 | 
			
		||||
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
 | 
			
		||||
article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
 | 
			
		||||
audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
 | 
			
		||||
audio:not([controls]){display:none;height:0}[hidden],template{display:none}
 | 
			
		||||
a{background-color:transparent}a:active,a:hover{outline-width:0}
 | 
			
		||||
abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
 | 
			
		||||
b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
 | 
			
		||||
small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
 | 
			
		||||
sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none}
 | 
			
		||||
code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible}
 | 
			
		||||
button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
 | 
			
		||||
button,input{overflow:visible}button,select{text-transform:none}
 | 
			
		||||
button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}
 | 
			
		||||
button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
 | 
			
		||||
button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
 | 
			
		||||
fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
 | 
			
		||||
legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
 | 
			
		||||
[type=checkbox],[type=radio]{padding:0}
 | 
			
		||||
[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
 | 
			
		||||
[type=search]{-webkit-appearance:textfield;outline-offset:-2px}
 | 
			
		||||
[type=search]::-webkit-search-decoration{-webkit-appearance:none}
 | 
			
		||||
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
 | 
			
		||||
/* End extract */
 | 
			
		||||
html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden}
 | 
			
		||||
h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
 | 
			
		||||
.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
 | 
			
		||||
h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
 | 
			
		||||
hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit}
 | 
			
		||||
.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
 | 
			
		||||
.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
 | 
			
		||||
.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
 | 
			
		||||
.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
 | 
			
		||||
.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
 | 
			
		||||
.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
 | 
			
		||||
.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap}
 | 
			
		||||
.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
 | 
			
		||||
.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}   
 | 
			
		||||
.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
 | 
			
		||||
.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
 | 
			
		||||
.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
 | 
			
		||||
.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
 | 
			
		||||
.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
 | 
			
		||||
.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
 | 
			
		||||
.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
 | 
			
		||||
.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
 | 
			
		||||
.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
 | 
			
		||||
.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
 | 
			
		||||
.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
 | 
			
		||||
.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
 | 
			
		||||
.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
 | 
			
		||||
.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
 | 
			
		||||
.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
 | 
			
		||||
.w3-main,#main{transition:margin-left .4s}
 | 
			
		||||
.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
 | 
			
		||||
.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
 | 
			
		||||
.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
 | 
			
		||||
.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
 | 
			
		||||
.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
 | 
			
		||||
.w3-bar .w3-button{white-space:normal}
 | 
			
		||||
.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
 | 
			
		||||
.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
 | 
			
		||||
.w3-responsive{display:block;overflow-x:auto}
 | 
			
		||||
.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
 | 
			
		||||
.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
 | 
			
		||||
.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
 | 
			
		||||
.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
 | 
			
		||||
.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
 | 
			
		||||
.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
 | 
			
		||||
@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
 | 
			
		||||
.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
 | 
			
		||||
.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
 | 
			
		||||
@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
 | 
			
		||||
.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
 | 
			
		||||
.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
 | 
			
		||||
.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
 | 
			
		||||
.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
 | 
			
		||||
.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
 | 
			
		||||
.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
 | 
			
		||||
.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
 | 
			
		||||
@media (max-width:1205px){.w3-auto{max-width:95%}}
 | 
			
		||||
@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
 | 
			
		||||
.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}	
 | 
			
		||||
.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
 | 
			
		||||
.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
 | 
			
		||||
@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
 | 
			
		||||
@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
 | 
			
		||||
@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
 | 
			
		||||
@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
 | 
			
		||||
.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
 | 
			
		||||
.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
 | 
			
		||||
.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
 | 
			
		||||
.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
 | 
			
		||||
.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
 | 
			
		||||
.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
 | 
			
		||||
.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
 | 
			
		||||
.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
 | 
			
		||||
.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
 | 
			
		||||
.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
 | 
			
		||||
.w3-display-position{position:absolute}
 | 
			
		||||
.w3-circle{border-radius:50%}
 | 
			
		||||
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
 | 
			
		||||
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
 | 
			
		||||
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
 | 
			
		||||
.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
 | 
			
		||||
.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
 | 
			
		||||
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
 | 
			
		||||
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
 | 
			
		||||
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
 | 
			
		||||
.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
 | 
			
		||||
.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
 | 
			
		||||
.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
 | 
			
		||||
.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
 | 
			
		||||
.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
 | 
			
		||||
.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
 | 
			
		||||
.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
 | 
			
		||||
.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
 | 
			
		||||
.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
 | 
			
		||||
.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
 | 
			
		||||
.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
 | 
			
		||||
.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
 | 
			
		||||
.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
 | 
			
		||||
.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
 | 
			
		||||
.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
 | 
			
		||||
.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
 | 
			
		||||
.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
 | 
			
		||||
.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
 | 
			
		||||
.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
 | 
			
		||||
.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
 | 
			
		||||
.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
 | 
			
		||||
.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
 | 
			
		||||
.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
 | 
			
		||||
.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
 | 
			
		||||
.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
 | 
			
		||||
.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
 | 
			
		||||
.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
 | 
			
		||||
.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
 | 
			
		||||
.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
 | 
			
		||||
.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
 | 
			
		||||
.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
 | 
			
		||||
.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
 | 
			
		||||
.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
 | 
			
		||||
.w3-left{float:left!important}.w3-right{float:right!important}
 | 
			
		||||
.w3-button:hover{color:#000!important;background-color:#ccc!important}
 | 
			
		||||
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
 | 
			
		||||
.w3-hover-none:hover{box-shadow:none!important}
 | 
			
		||||
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
 | 
			
		||||
/* Colors */
 | 
			
		||||
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
 | 
			
		||||
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
 | 
			
		||||
.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
 | 
			
		||||
.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
 | 
			
		||||
.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
 | 
			
		||||
.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
 | 
			
		||||
.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
 | 
			
		||||
.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
 | 
			
		||||
.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
 | 
			
		||||
.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
 | 
			
		||||
.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
 | 
			
		||||
.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
 | 
			
		||||
.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
 | 
			
		||||
.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
 | 
			
		||||
.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
 | 
			
		||||
.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
 | 
			
		||||
.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
 | 
			
		||||
.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
 | 
			
		||||
.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
 | 
			
		||||
.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
 | 
			
		||||
.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
 | 
			
		||||
.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
 | 
			
		||||
.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
 | 
			
		||||
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
 | 
			
		||||
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
 | 
			
		||||
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
 | 
			
		||||
.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
 | 
			
		||||
.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
 | 
			
		||||
.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
 | 
			
		||||
.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
 | 
			
		||||
.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
 | 
			
		||||
.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
 | 
			
		||||
.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
 | 
			
		||||
.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
 | 
			
		||||
.w3-danger{color:#fff!important;background-color:#dd0000!important}
 | 
			
		||||
.w3-note{color:#000!important;background-color:#fff599!important}
 | 
			
		||||
.w3-info{color:#fff!important;background-color:#0a6fc2!important}
 | 
			
		||||
.w3-warning{color:#000!important;background-color:#ffb305!important}
 | 
			
		||||
.w3-success{color:#fff!important;background-color:#008a00!important}
 | 
			
		||||
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
 | 
			
		||||
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
 | 
			
		||||
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
 | 
			
		||||
.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
 | 
			
		||||
.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
 | 
			
		||||
.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
 | 
			
		||||
.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
 | 
			
		||||
.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
 | 
			
		||||
.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
 | 
			
		||||
.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
 | 
			
		||||
.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
 | 
			
		||||
.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
 | 
			
		||||
.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
 | 
			
		||||
.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
 | 
			
		||||
.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
 | 
			
		||||
.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
 | 
			
		||||
.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
 | 
			
		||||
.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
 | 
			
		||||
.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
 | 
			
		||||
.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
 | 
			
		||||
.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
 | 
			
		||||
.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
 | 
			
		||||
.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
 | 
			
		||||
.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
 | 
			
		||||
.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
 | 
			
		||||
.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
 | 
			
		||||
.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
 | 
			
		||||
.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
 | 
			
		||||
.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
 | 
			
		||||
.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
 | 
			
		||||
.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
 | 
			
		||||
.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
 | 
			
		||||
.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
 | 
			
		||||
.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
 | 
			
		||||
.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
 | 
			
		||||
.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
 | 
			
		||||
.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
 | 
			
		||||
.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
 | 
			
		||||
.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
 | 
			
		||||
.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
 | 
			
		||||
.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
 | 
			
		||||
.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
 | 
			
		||||
.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
 | 
			
		||||
.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
 | 
			
		||||
.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
 | 
			
		||||
.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
 | 
			
		||||
.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
 | 
			
		||||
.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
 | 
			
		||||
.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
 | 
			
		||||
.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
 | 
			
		||||
.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
 | 
			
		||||
.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
 | 
			
		||||
.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
 | 
			
		||||
.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
 | 
			
		||||
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
 | 
			
		||||
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
 | 
			
		||||
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
 | 
			
		||||
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "💡",
 | 
			
		||||
	"previous": "&eN6DNPpQUNhGvxneLuLPgsOXR6qyFZ7u+MAz0b4fa7k=.sha256"
 | 
			
		||||
}
 | 
			
		||||
@@ -1,16 +0,0 @@
 | 
			
		||||
import * as tfrpc from '/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	await app.setDocument(utf8Decode(getFile('index.html')));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
tfrpc.register(async function complete() {
 | 
			
		||||
	if (
 | 
			
		||||
		core.user?.credentials?.permissions?.administration &&
 | 
			
		||||
		(await core.globalSettingsGet('index')) == '/~core/intro/'
 | 
			
		||||
	) {
 | 
			
		||||
		return await core.globalSettingsSet('index', '/~core/ssb/');
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
@@ -1,286 +0,0 @@
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html style="height: 100%; margin: 0; padding: 0; box-sizing: border-box">
 | 
			
		||||
	<head>
 | 
			
		||||
		<meta name="viewport" content="width=device-width, initial-scale=1" />
 | 
			
		||||
		<link rel="stylesheet" type="text/css" href="w3.css" />
 | 
			
		||||
		<style>
 | 
			
		||||
			.slide {
 | 
			
		||||
				display: none;
 | 
			
		||||
				margin-left: auto;
 | 
			
		||||
				margin-right: auto;
 | 
			
		||||
			}
 | 
			
		||||
			.dot {
 | 
			
		||||
				width: 1em;
 | 
			
		||||
				height: 1em;
 | 
			
		||||
				cursor: pointer;
 | 
			
		||||
			}
 | 
			
		||||
			.w3-left,
 | 
			
		||||
			.w3-right {
 | 
			
		||||
				cursor: pointer;
 | 
			
		||||
			}
 | 
			
		||||
		</style>
 | 
			
		||||
	</head>
 | 
			
		||||
	<body
 | 
			
		||||
		style="
 | 
			
		||||
			width: 100%;
 | 
			
		||||
			height: 100%;
 | 
			
		||||
			max-width: 100%;
 | 
			
		||||
			max-height: 100%;
 | 
			
		||||
			margin: 0;
 | 
			
		||||
			padding: 0;
 | 
			
		||||
			flex-direction: column;
 | 
			
		||||
			align-items: center;
 | 
			
		||||
		"
 | 
			
		||||
		class="w3-flex w3-dark-gray w3-center"
 | 
			
		||||
	>
 | 
			
		||||
		<div
 | 
			
		||||
			style="
 | 
			
		||||
				flex: 1 1 auto;
 | 
			
		||||
				overflow: auto;
 | 
			
		||||
				contain: content;
 | 
			
		||||
				padding-top: 16px;
 | 
			
		||||
				padding-bottom: 16px;
 | 
			
		||||
			"
 | 
			
		||||
		>
 | 
			
		||||
			<div class="slide">
 | 
			
		||||
				<div
 | 
			
		||||
					class="w3-content w3-xlarge w3-card-4 w3-blue w3-panel w3-padding-32 w3-round-xlarge"
 | 
			
		||||
					style="margin: 32px"
 | 
			
		||||
				>
 | 
			
		||||
					<div>
 | 
			
		||||
						<div>Welcome to</div>
 | 
			
		||||
						<div>~😎 Tilde Friends.</div>
 | 
			
		||||
					</div>
 | 
			
		||||
					<footer>
 | 
			
		||||
						<button class="w3-button w3-yellow proceed">Next</button>
 | 
			
		||||
					</footer>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="slide w3-card-4 w3-gray" style="width: 90%">
 | 
			
		||||
				<header class="w3-container w3-blue w3-xlarge">
 | 
			
		||||
					<h1>This brief tutorial will introduce:</h1>
 | 
			
		||||
				</header>
 | 
			
		||||
				<ul class="w3-large w3-left-align">
 | 
			
		||||
					<li><b>Secure Scuttlebutt</b>, a decentralized social network.</li>
 | 
			
		||||
					<li>
 | 
			
		||||
						<b>Tilde Friends</b>, the application platform that you are using
 | 
			
		||||
						right now.
 | 
			
		||||
					</li>
 | 
			
		||||
					<li>
 | 
			
		||||
						<b>How to get started</b> if you want to get gossiping right away.
 | 
			
		||||
					</li>
 | 
			
		||||
				</ul>
 | 
			
		||||
				<footer class="w3-center w3-xlarge w3-padding">
 | 
			
		||||
					<button class="w3-button w3-yellow proceed">Onward</button>
 | 
			
		||||
				</footer>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="slide w3-gray" style="width: 90%">
 | 
			
		||||
				<div class="w3-card-4 w3-xlarge">
 | 
			
		||||
					<header class="w3-container w3-blue">
 | 
			
		||||
						<h1>💻Secure Scuttlebutt in a Nutshell🦀</h1>
 | 
			
		||||
					</header>
 | 
			
		||||
					<div class="w3-container w3-large w3-left-align">
 | 
			
		||||
						<p>
 | 
			
		||||
							Secure Scuttlebutt is a social network whose technical operation
 | 
			
		||||
							attempts to mirror human social interaction.
 | 
			
		||||
						</p>
 | 
			
		||||
						<ul>
 | 
			
		||||
							<li>
 | 
			
		||||
								You can create your own account and post to your own feed on
 | 
			
		||||
								your own device. This is all <b>local</b> with no external
 | 
			
		||||
								communication. This puts you fully in control of your own words
 | 
			
		||||
								and actions.
 | 
			
		||||
							</li>
 | 
			
		||||
							<li>
 | 
			
		||||
								Before you can interact with others, you need to
 | 
			
		||||
								<b>connect over the network</b>, either directly to your friends
 | 
			
		||||
								(i.e., peer-to-peer between your phones on coffee shop Wi-Fi) or
 | 
			
		||||
								to 🚪<i>rooms</i> and 🍻<i>pubs</i> (hint: search the web for
 | 
			
		||||
								<i>#ssbroom</i>).
 | 
			
		||||
							</li>
 | 
			
		||||
							<li>
 | 
			
		||||
								Who you choose to <b>follow</b> determines what you see, with
 | 
			
		||||
								most people choosing to see messages from friends and friends of
 | 
			
		||||
								those friends. If you encounter content you'd rather not see,
 | 
			
		||||
								<b>block</b> the offending account to improve the experience for
 | 
			
		||||
								you and your followers.
 | 
			
		||||
							</li>
 | 
			
		||||
							<li>
 | 
			
		||||
								Your feed is an <b>immutable</b> log of your activity. Post with
 | 
			
		||||
								care, because like your words in real life, posts can't be taken
 | 
			
		||||
								back.
 | 
			
		||||
							</li>
 | 
			
		||||
						</ul>
 | 
			
		||||
					</div>
 | 
			
		||||
					<footer class="w3-center w3-xlarge w3-padding">
 | 
			
		||||
						<a
 | 
			
		||||
							class="w3-button w3-light-gray"
 | 
			
		||||
							href="https://scuttlebutt.nz/"
 | 
			
		||||
							target="_blank"
 | 
			
		||||
							>See scuttlebutt.nz</a
 | 
			
		||||
						>
 | 
			
		||||
						<button class="w3-button w3-yellow proceed">Got It</button>
 | 
			
		||||
					</footer>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="slide w3-gray" style="width: 90%">
 | 
			
		||||
				<div class="w3-card-4 w3-xlarge">
 | 
			
		||||
					<header class="w3-container w3-blue w3-center">
 | 
			
		||||
						<h1>~😎 Let's Talk Tilde Friends ~😎</h1>
 | 
			
		||||
					</header>
 | 
			
		||||
					<div class="w3-container w3-large w3-left-align">
 | 
			
		||||
						<p>
 | 
			
		||||
							Tilde Friends is an application platform that is an application of
 | 
			
		||||
							its own.
 | 
			
		||||
						</p>
 | 
			
		||||
						<ul>
 | 
			
		||||
							<li>
 | 
			
		||||
								This intro is a Tilde Friends app. You can click <b>edit</b> at
 | 
			
		||||
								the top to look under the hood and make changes.
 | 
			
		||||
							</li>
 | 
			
		||||
							<li>
 | 
			
		||||
								It is already possible to make and share new applications using
 | 
			
		||||
								only Tilde Friends and Secure Scuttlebutt without having to set
 | 
			
		||||
								up development environments, configure web servers, register
 | 
			
		||||
								domain names, or pay for hosting services.
 | 
			
		||||
							</li>
 | 
			
		||||
							<li>
 | 
			
		||||
								But it's also set up so that you can't just break an app that
 | 
			
		||||
								everybody is using or do malicious things with personal content.
 | 
			
		||||
								There are <b>protections</b> in place like an operating system.
 | 
			
		||||
								The intent is also for it to be <b>safe</b> to run strange apps
 | 
			
		||||
								without worrying about adverse effects.
 | 
			
		||||
							</li>
 | 
			
		||||
							<li>
 | 
			
		||||
								But this is all a big 🚧work in progress🚧 and
 | 
			
		||||
								<b>experiment</b>. Let's see where it takes us.
 | 
			
		||||
							</li>
 | 
			
		||||
						</ul>
 | 
			
		||||
					</div>
 | 
			
		||||
					<footer class="w3-center w3-xlarge w3-padding">
 | 
			
		||||
						<button class="w3-button w3-yellow proceed">Okay</button>
 | 
			
		||||
					</footer>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="slide w3-gray" style="width: 90%">
 | 
			
		||||
				<div class="w3-card-4 w3-xlarge">
 | 
			
		||||
					<header class="w3-container w3-blue w3-center">
 | 
			
		||||
						<h1>🦀Let's Get this Tilde Friends Party Started🎉</h1>
 | 
			
		||||
					</header>
 | 
			
		||||
					<div class="w3-container w3-large w3-left-align">
 | 
			
		||||
						<p>The button below will take you to the Secure Scuttlebutt app.</p>
 | 
			
		||||
						<ul>
 | 
			
		||||
							<li>
 | 
			
		||||
								Remember:
 | 
			
		||||
								<ol>
 | 
			
		||||
									<li>You are in charge. This is all on your device.</li>
 | 
			
		||||
									<li>
 | 
			
		||||
										Make network connections to exchange messages with others.
 | 
			
		||||
									</li>
 | 
			
		||||
									<li>
 | 
			
		||||
										Follow more accounts to see more content, and block those
 | 
			
		||||
										posting content you'd rather not see.
 | 
			
		||||
									</li>
 | 
			
		||||
									<li>
 | 
			
		||||
										Be respectful, and consider the consequences of what you
 | 
			
		||||
										post.
 | 
			
		||||
									</li>
 | 
			
		||||
									<li>
 | 
			
		||||
										This is all under active development. Exercise patience, and
 | 
			
		||||
										report issues encountered.
 | 
			
		||||
									</li>
 | 
			
		||||
								</ol>
 | 
			
		||||
							</li>
 | 
			
		||||
							<li>
 | 
			
		||||
								To see this tutorial again later, select <b>apps</b> ->
 | 
			
		||||
								<b>Core Apps</b> -> <b>intro</b>.
 | 
			
		||||
							</li>
 | 
			
		||||
						</ul>
 | 
			
		||||
					</div>
 | 
			
		||||
					<footer class="w3-center w3-xlarge w3-padding">
 | 
			
		||||
						<button class="w3-button w3-yellow" id="complete">Let's Go!</button>
 | 
			
		||||
					</footer>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div
 | 
			
		||||
			class="w3-text-white w3-xlarge w3-center w3-flex"
 | 
			
		||||
			style="
 | 
			
		||||
				width: 100%;
 | 
			
		||||
				flex: 0 1;
 | 
			
		||||
				flex-direction: row;
 | 
			
		||||
				align-items: center;
 | 
			
		||||
				gap: 8px;
 | 
			
		||||
			"
 | 
			
		||||
		>
 | 
			
		||||
			<div class="w3-jumbo" id="left" style="flex: 1 0; cursor: pointer">
 | 
			
		||||
				❮
 | 
			
		||||
			</div>
 | 
			
		||||
			<span class="w3-badge dot w3-border w3-hover-yellow"></span>
 | 
			
		||||
			<span class="w3-badge dot w3-border w3-hover-yellow"></span>
 | 
			
		||||
			<span class="w3-badge dot w3-border w3-hover-yellow"></span>
 | 
			
		||||
			<span class="w3-badge dot w3-border w3-hover-yellow"></span>
 | 
			
		||||
			<span class="w3-badge dot w3-border w3-hover-yellow"></span>
 | 
			
		||||
			<div class="w3-jumbo" style="flex: 1 0; cursor: pointer" id="right">
 | 
			
		||||
				❯
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
		<script type="module">
 | 
			
		||||
			import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
			let index = 0;
 | 
			
		||||
			function set(i) {
 | 
			
		||||
				show(i - index);
 | 
			
		||||
			}
 | 
			
		||||
			function show(delta) {
 | 
			
		||||
				let slides = [...document.getElementsByClassName('slide')];
 | 
			
		||||
				let dots = [...document.getElementsByClassName('dot')];
 | 
			
		||||
				index = (index + delta + slides.length) % slides.length;
 | 
			
		||||
				for (let slide of slides) {
 | 
			
		||||
					slide.style.display =
 | 
			
		||||
						slides.indexOf(slide) == index ? 'block' : 'none';
 | 
			
		||||
				}
 | 
			
		||||
				for (let dot of dots) {
 | 
			
		||||
					if (dots.indexOf(dot) == index) {
 | 
			
		||||
						dot.classList.add('w3-white');
 | 
			
		||||
					} else {
 | 
			
		||||
						dot.classList.remove('w3-white');
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				document.getElementById('left').style.visibility =
 | 
			
		||||
					index == 0 ? 'hidden' : 'visible';
 | 
			
		||||
				document.getElementById('right').style.visibility =
 | 
			
		||||
					index == slides.length - 1 ? 'hidden' : 'visible';
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			let dots = [...document.getElementsByClassName('dot')];
 | 
			
		||||
			for (let dot of dots) {
 | 
			
		||||
				dot.onclick = () => set(dots.indexOf(dot));
 | 
			
		||||
			}
 | 
			
		||||
			for (let button of document.getElementsByClassName('proceed')) {
 | 
			
		||||
				button.onclick = () => show(1);
 | 
			
		||||
			}
 | 
			
		||||
			document.getElementById('left').onclick = () => show(-1);
 | 
			
		||||
			document.getElementById('right').onclick = () => show(1);
 | 
			
		||||
			document.getElementById('complete').onclick = function () {
 | 
			
		||||
				console.log('completing');
 | 
			
		||||
				tfrpc.rpc.complete().finally(function () {
 | 
			
		||||
					console.log('completed');
 | 
			
		||||
					let a = document.createElement('a');
 | 
			
		||||
					a.href = '/~core/ssb/';
 | 
			
		||||
					a.target = '_top';
 | 
			
		||||
					document.body.appendChild(a);
 | 
			
		||||
					a.click();
 | 
			
		||||
				});
 | 
			
		||||
			};
 | 
			
		||||
			window.addEventListener('keyup', function (event) {
 | 
			
		||||
				if (event.key == 'ArrowLeft') {
 | 
			
		||||
					show(-1);
 | 
			
		||||
				} else if (event.key == 'ArrowRight') {
 | 
			
		||||
					show(1);
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
			show(0);
 | 
			
		||||
		</script>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
@@ -1,251 +0,0 @@
 | 
			
		||||
/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
 | 
			
		||||
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
 | 
			
		||||
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
 | 
			
		||||
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
 | 
			
		||||
article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
 | 
			
		||||
audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
 | 
			
		||||
audio:not([controls]){display:none;height:0}[hidden],template{display:none}
 | 
			
		||||
a{background-color:transparent}a:active,a:hover{outline-width:0}
 | 
			
		||||
abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
 | 
			
		||||
b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
 | 
			
		||||
small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
 | 
			
		||||
sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none}
 | 
			
		||||
code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible}
 | 
			
		||||
button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
 | 
			
		||||
button,input{overflow:visible}button,select{text-transform:none}
 | 
			
		||||
button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}
 | 
			
		||||
button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
 | 
			
		||||
button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
 | 
			
		||||
fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
 | 
			
		||||
legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
 | 
			
		||||
[type=checkbox],[type=radio]{padding:0}
 | 
			
		||||
[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
 | 
			
		||||
[type=search]{-webkit-appearance:textfield;outline-offset:-2px}
 | 
			
		||||
[type=search]::-webkit-search-decoration{-webkit-appearance:none}
 | 
			
		||||
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
 | 
			
		||||
/* End extract */
 | 
			
		||||
html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden}
 | 
			
		||||
h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
 | 
			
		||||
.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
 | 
			
		||||
h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
 | 
			
		||||
hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit}
 | 
			
		||||
.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
 | 
			
		||||
.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
 | 
			
		||||
.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
 | 
			
		||||
.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
 | 
			
		||||
.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
 | 
			
		||||
.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
 | 
			
		||||
.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap}
 | 
			
		||||
.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
 | 
			
		||||
.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}   
 | 
			
		||||
.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
 | 
			
		||||
.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
 | 
			
		||||
.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
 | 
			
		||||
.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
 | 
			
		||||
.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
 | 
			
		||||
.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
 | 
			
		||||
.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
 | 
			
		||||
.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
 | 
			
		||||
.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
 | 
			
		||||
.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
 | 
			
		||||
.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
 | 
			
		||||
.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
 | 
			
		||||
.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
 | 
			
		||||
.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
 | 
			
		||||
.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
 | 
			
		||||
.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
 | 
			
		||||
.w3-main,#main{transition:margin-left .4s}
 | 
			
		||||
.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
 | 
			
		||||
.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
 | 
			
		||||
.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
 | 
			
		||||
.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
 | 
			
		||||
.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
 | 
			
		||||
.w3-bar .w3-button{white-space:normal}
 | 
			
		||||
.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
 | 
			
		||||
.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
 | 
			
		||||
.w3-responsive{display:block;overflow-x:auto}
 | 
			
		||||
.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
 | 
			
		||||
.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
 | 
			
		||||
.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
 | 
			
		||||
.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
 | 
			
		||||
.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
 | 
			
		||||
.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
 | 
			
		||||
@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
 | 
			
		||||
.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
 | 
			
		||||
.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
 | 
			
		||||
@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
 | 
			
		||||
.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
 | 
			
		||||
.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
 | 
			
		||||
.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
 | 
			
		||||
.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
 | 
			
		||||
.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
 | 
			
		||||
.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
 | 
			
		||||
.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
 | 
			
		||||
@media (max-width:1205px){.w3-auto{max-width:95%}}
 | 
			
		||||
@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
 | 
			
		||||
.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}	
 | 
			
		||||
.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
 | 
			
		||||
.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
 | 
			
		||||
@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
 | 
			
		||||
@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
 | 
			
		||||
@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
 | 
			
		||||
@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
 | 
			
		||||
.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
 | 
			
		||||
.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
 | 
			
		||||
.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
 | 
			
		||||
.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
 | 
			
		||||
.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
 | 
			
		||||
.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
 | 
			
		||||
.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
 | 
			
		||||
.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
 | 
			
		||||
.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
 | 
			
		||||
.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
 | 
			
		||||
.w3-display-position{position:absolute}
 | 
			
		||||
.w3-circle{border-radius:50%}
 | 
			
		||||
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
 | 
			
		||||
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
 | 
			
		||||
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
 | 
			
		||||
.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
 | 
			
		||||
.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
 | 
			
		||||
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
 | 
			
		||||
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
 | 
			
		||||
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
 | 
			
		||||
.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
 | 
			
		||||
.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
 | 
			
		||||
.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
 | 
			
		||||
.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
 | 
			
		||||
.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
 | 
			
		||||
.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
 | 
			
		||||
.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
 | 
			
		||||
.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
 | 
			
		||||
.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
 | 
			
		||||
.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
 | 
			
		||||
.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
 | 
			
		||||
.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
 | 
			
		||||
.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
 | 
			
		||||
.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
 | 
			
		||||
.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
 | 
			
		||||
.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
 | 
			
		||||
.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
 | 
			
		||||
.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
 | 
			
		||||
.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
 | 
			
		||||
.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
 | 
			
		||||
.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
 | 
			
		||||
.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
 | 
			
		||||
.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
 | 
			
		||||
.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
 | 
			
		||||
.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
 | 
			
		||||
.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
 | 
			
		||||
.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
 | 
			
		||||
.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
 | 
			
		||||
.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
 | 
			
		||||
.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
 | 
			
		||||
.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
 | 
			
		||||
.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
 | 
			
		||||
.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
 | 
			
		||||
.w3-left{float:left!important}.w3-right{float:right!important}
 | 
			
		||||
.w3-button:hover{color:#000!important;background-color:#ccc!important}
 | 
			
		||||
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
 | 
			
		||||
.w3-hover-none:hover{box-shadow:none!important}
 | 
			
		||||
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
 | 
			
		||||
/* Colors */
 | 
			
		||||
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
 | 
			
		||||
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
 | 
			
		||||
.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
 | 
			
		||||
.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
 | 
			
		||||
.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
 | 
			
		||||
.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
 | 
			
		||||
.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
 | 
			
		||||
.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
 | 
			
		||||
.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
 | 
			
		||||
.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
 | 
			
		||||
.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
 | 
			
		||||
.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
 | 
			
		||||
.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
 | 
			
		||||
.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
 | 
			
		||||
.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
 | 
			
		||||
.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
 | 
			
		||||
.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
 | 
			
		||||
.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
 | 
			
		||||
.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
 | 
			
		||||
.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
 | 
			
		||||
.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
 | 
			
		||||
.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
 | 
			
		||||
.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
 | 
			
		||||
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
 | 
			
		||||
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
 | 
			
		||||
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
 | 
			
		||||
.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
 | 
			
		||||
.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
 | 
			
		||||
.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
 | 
			
		||||
.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
 | 
			
		||||
.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
 | 
			
		||||
.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
 | 
			
		||||
.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
 | 
			
		||||
.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
 | 
			
		||||
.w3-danger{color:#fff!important;background-color:#dd0000!important}
 | 
			
		||||
.w3-note{color:#000!important;background-color:#fff599!important}
 | 
			
		||||
.w3-info{color:#fff!important;background-color:#0a6fc2!important}
 | 
			
		||||
.w3-warning{color:#000!important;background-color:#ffb305!important}
 | 
			
		||||
.w3-success{color:#fff!important;background-color:#008a00!important}
 | 
			
		||||
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
 | 
			
		||||
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
 | 
			
		||||
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
 | 
			
		||||
.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
 | 
			
		||||
.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
 | 
			
		||||
.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
 | 
			
		||||
.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
 | 
			
		||||
.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
 | 
			
		||||
.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
 | 
			
		||||
.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
 | 
			
		||||
.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
 | 
			
		||||
.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
 | 
			
		||||
.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
 | 
			
		||||
.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
 | 
			
		||||
.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
 | 
			
		||||
.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
 | 
			
		||||
.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
 | 
			
		||||
.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
 | 
			
		||||
.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
 | 
			
		||||
.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
 | 
			
		||||
.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
 | 
			
		||||
.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
 | 
			
		||||
.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
 | 
			
		||||
.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
 | 
			
		||||
.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
 | 
			
		||||
.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
 | 
			
		||||
.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
 | 
			
		||||
.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
 | 
			
		||||
.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
 | 
			
		||||
.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
 | 
			
		||||
.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
 | 
			
		||||
.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
 | 
			
		||||
.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
 | 
			
		||||
.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
 | 
			
		||||
.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
 | 
			
		||||
.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
 | 
			
		||||
.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
 | 
			
		||||
.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
 | 
			
		||||
.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
 | 
			
		||||
.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
 | 
			
		||||
.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
 | 
			
		||||
.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
 | 
			
		||||
.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
 | 
			
		||||
.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
 | 
			
		||||
.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
 | 
			
		||||
.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
 | 
			
		||||
.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
 | 
			
		||||
.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
 | 
			
		||||
.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
 | 
			
		||||
.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
 | 
			
		||||
.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
 | 
			
		||||
.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
 | 
			
		||||
.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
 | 
			
		||||
.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
 | 
			
		||||
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
 | 
			
		||||
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
 | 
			
		||||
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
 | 
			
		||||
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🦟",
 | 
			
		||||
	"previous": "&O0huuEgL/UQC9bkUfhPOyZFo9eRiz+koOkba6QwCGNA=.sha256"
 | 
			
		||||
	"previous": "&TegdzvFE+im94shygaHkgDYSaSrwY2h0OKUXSRPBQDM=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -67,6 +67,9 @@ tfrpc.register(function getHash(id, message) {
 | 
			
		||||
tfrpc.register(function setHash(hash) {
 | 
			
		||||
	return app.setHash(hash);
 | 
			
		||||
});
 | 
			
		||||
ssb.addEventListener('message', async function (id) {
 | 
			
		||||
	await tfrpc.rpc.notifyNewMessage(id);
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function store_blob(blob) {
 | 
			
		||||
	if (Array.isArray(blob)) {
 | 
			
		||||
		blob = Uint8Array.from(blob);
 | 
			
		||||
@@ -82,18 +85,13 @@ tfrpc.register(async function store_message(message) {
 | 
			
		||||
tfrpc.register(function apps() {
 | 
			
		||||
	return core.apps();
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(function getActiveIdentity() {
 | 
			
		||||
	return ssb.getActiveIdentity();
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function try_decrypt(id, content) {
 | 
			
		||||
	return await ssb.privateMessageDecrypt(id, content);
 | 
			
		||||
});
 | 
			
		||||
core.register('onMessage', async function (id) {
 | 
			
		||||
	await tfrpc.rpc.notifyNewMessage(id);
 | 
			
		||||
});
 | 
			
		||||
core.register('onBroadcastsChanged', async function () {
 | 
			
		||||
ssb.addEventListener('broadcasts', async function () {
 | 
			
		||||
	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts());
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
core.register('onConnectionsChanged', async function () {
 | 
			
		||||
	await tfrpc.rpc.set('connections', await ssb.connections());
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								apps/issues/commonmark.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								apps/issues/commonmark.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										42
									
								
								apps/issues/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										42
									
								
								apps/issues/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -4,6 +4,48 @@ import * as tfutils from './tf-utils.js';
 | 
			
		||||
 | 
			
		||||
const k_project = '%Hr+4xEVtjplidSKBlRWi4Aw/0Tfw7B+1OR9BzlDKmOI=.sha256';
 | 
			
		||||
 | 
			
		||||
class TfIdPickerElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			ids: {type: Array},
 | 
			
		||||
			selected: {type: String},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.load();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load() {
 | 
			
		||||
		this.selected = await tfrpc.rpc.localStorageGet('whoami');
 | 
			
		||||
		this.ids = (await tfrpc.rpc.getIdentities()) || [];
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	changed(event) {
 | 
			
		||||
		this.selected = event.srcElement.value;
 | 
			
		||||
		tfrpc.rpc.localStorageSet('whoami', this.selected);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		if (this.ids) {
 | 
			
		||||
			return html`
 | 
			
		||||
				<select @change=${this.changed} style="max-width: 100%">
 | 
			
		||||
					${this.ids.map(
 | 
			
		||||
						(id) =>
 | 
			
		||||
							html`<option ?selected=${id == this.selected} value=${id}>
 | 
			
		||||
								${id}
 | 
			
		||||
							</option>`
 | 
			
		||||
					)}
 | 
			
		||||
				</select>
 | 
			
		||||
			`;
 | 
			
		||||
		} else {
 | 
			
		||||
			return html`<div>Loading...</div>`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
customElements.define('tf-id-picker', TfIdPickerElement);
 | 
			
		||||
 | 
			
		||||
class TfComposeElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
@@ -63,10 +105,10 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
		let issues = {};
 | 
			
		||||
		let messages = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
			WITH issues AS (SELECT messages.id, json(messages.content) AS content, messages.author, messages.timestamp FROM messages_refs JOIN messages ON
 | 
			
		||||
			WITH issues AS (SELECT messages.* FROM messages_refs JOIN messages ON
 | 
			
		||||
				messages.id = messages_refs.message
 | 
			
		||||
				WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'),
 | 
			
		||||
			edits AS (SELECT messages.id, json(messages.content) AS content, messages.author, messages.timestamp FROM issues JOIN messages_refs ON
 | 
			
		||||
			edits AS (SELECT messages.* FROM issues JOIN messages_refs ON
 | 
			
		||||
				issues.id = messages_refs.ref JOIN messages ON
 | 
			
		||||
				messages.id = messages_refs.message
 | 
			
		||||
				WHERE json_extract(messages.content, '$.type') IN ('issue-edit', 'post'))
 | 
			
		||||
@@ -164,7 +206,7 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
		if (
 | 
			
		||||
			confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`)
 | 
			
		||||
		) {
 | 
			
		||||
			let whoami = await tfrpc.rpc.getActiveIdentity();
 | 
			
		||||
			let whoami = this.shadowRoot.getElementById('picker').selected;
 | 
			
		||||
			await tfrpc.rpc.appendMessage(whoami, {
 | 
			
		||||
				type: 'issue-edit',
 | 
			
		||||
				issues: [
 | 
			
		||||
@@ -179,7 +221,7 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async create_issue(event) {
 | 
			
		||||
		let whoami = await tfrpc.rpc.getActiveIdentity();
 | 
			
		||||
		let whoami = this.shadowRoot.getElementById('picker').selected;
 | 
			
		||||
		await tfrpc.rpc.appendMessage(whoami, {
 | 
			
		||||
			type: 'issue',
 | 
			
		||||
			project: k_project,
 | 
			
		||||
@@ -189,7 +231,7 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async reply_to_issue(event) {
 | 
			
		||||
		let whoami = await tfrpc.rpc.getActiveIdentity();
 | 
			
		||||
		let whoami = this.shadowRoot.getElementById('picker').selected;
 | 
			
		||||
		await tfrpc.rpc.appendMessage(whoami, {
 | 
			
		||||
			type: 'post',
 | 
			
		||||
			text: event.detail.value,
 | 
			
		||||
@@ -207,7 +249,10 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let header = html` <h1>Tilde Friends Issues</h1> `;
 | 
			
		||||
		let header = html`
 | 
			
		||||
			<h1>Tilde Friends Issues</h1>
 | 
			
		||||
			<tf-id-picker id="picker"></tf-id-picker>
 | 
			
		||||
		`;
 | 
			
		||||
		if (this.selected) {
 | 
			
		||||
			return html`
 | 
			
		||||
				${header}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,5 @@
 | 
			
		||||
import * as linkify from './commonmark-linkify.js';
 | 
			
		||||
 | 
			
		||||
var reUnsafeProtocol = /^javascript:|vbscript:|file:|data:/i;
 | 
			
		||||
var reSafeDataProtocol = /^data:image\/(?:png|gif|jpeg|webp)/i;
 | 
			
		||||
var potentiallyUnsafe = function (url) {
 | 
			
		||||
	return reUnsafeProtocol.test(url) && !reSafeDataProtocol.test(url);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function image(node, entering) {
 | 
			
		||||
	if (
 | 
			
		||||
		node.firstChild?.type === 'text' &&
 | 
			
		||||
@@ -67,8 +61,8 @@ function image(node, entering) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function markdown(md) {
 | 
			
		||||
	var reader = new commonmark.Parser();
 | 
			
		||||
	var writer = new commonmark.HtmlRenderer({safe: true});
 | 
			
		||||
	var reader = new commonmark.Parser({safe: true});
 | 
			
		||||
	var writer = new commonmark.HtmlRenderer();
 | 
			
		||||
	writer.image = image;
 | 
			
		||||
	var parsed = reader.parse(md || '');
 | 
			
		||||
	parsed = linkify.transform(parsed);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "📝",
 | 
			
		||||
	"previous": "&5LpOTEnor/rYFk3axyfmmehAoq9aEwNQRH4jwNhRQ7o=.sha256"
 | 
			
		||||
	"previous": "&b//KqE4Vx6kOSBRODK1p/8wjOLKZJ+CBB5IkaBt5YsM=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -55,7 +55,7 @@ function new_message() {
 | 
			
		||||
	return g_new_message_promise;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
core.register('onMessage', function (id) {
 | 
			
		||||
ssb.addEventListener('message', function (id) {
 | 
			
		||||
	let resolve = g_new_message_resolve;
 | 
			
		||||
	g_new_message_promise = new Promise(function (resolve, reject) {
 | 
			
		||||
		g_new_message_resolve = resolve;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								apps/journal/commonmark.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								apps/journal/commonmark.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										42
									
								
								apps/journal/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										42
									
								
								apps/journal/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -18,8 +18,8 @@ class TfJournalEntryElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	markdown(md) {
 | 
			
		||||
		var reader = new commonmark.Parser();
 | 
			
		||||
		var writer = new commonmark.HtmlRenderer({safe: true});
 | 
			
		||||
		var reader = new commonmark.Parser({safe: true});
 | 
			
		||||
		var writer = new commonmark.HtmlRenderer();
 | 
			
		||||
		var parsed = reader.parse(md || '');
 | 
			
		||||
		return writer.render(parsed);
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🚪",
 | 
			
		||||
	"previous": "&DJwkqNfYWtW9yBtJQMseEXm7l04Enpi+yAxZulLq9Vk=.sha256"
 | 
			
		||||
	"emoji": "📦",
 | 
			
		||||
	"previous": "&IU+TwyM7TznD8NBfnw7tgW2zxVlMqTVxSqWFjuosLwo=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,7 @@
 | 
			
		||||
async function main() {
 | 
			
		||||
	print(core.url);
 | 
			
		||||
	let host = core.url.match(/.*?\/\/([^:/]*)/)[1];
 | 
			
		||||
	let port = await ssb.port();
 | 
			
		||||
	let id = (await ssb.getServerIdentity()).substring(1).split('.')[0];
 | 
			
		||||
	let room = `net:${host}:${port}~shs:${id}:SSB+Room+PSK3TLYC2T86EHQCUHBUHASCASE18JBV24=`;
 | 
			
		||||
	let host = core.url.match(/.*\/\/(.*?)\//)[1];
 | 
			
		||||
	let id = (await ssb.getServerIdentity()).substring(1);
 | 
			
		||||
	let room = `net:${host}:${ssb.port}~shs:${id}:SSB+Room+SK3TLYC2T86EHQCUHBUHASCASE18JBV24=`;
 | 
			
		||||
	await app.setDocument(`
 | 
			
		||||
		<body style="color: #fff">
 | 
			
		||||
			<h1>Server</h1>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								apps/sneaker/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										42
									
								
								apps/sneaker/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🦀",
 | 
			
		||||
	"previous": "&Gic1e3jOZ7z5131jSCclbFXRpjyu8JlWJrjE7Fvn5dc=.sha256"
 | 
			
		||||
	"emoji": "🐌",
 | 
			
		||||
	"previous": "&Xs1X5TzLCk6KVr+5IDc80JAHYxJyoD10cXKBUYpFqWQ=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -21,6 +21,9 @@ tfrpc.register(async function createIdentity() {
 | 
			
		||||
tfrpc.register(async function getServerIdentity() {
 | 
			
		||||
	return ssb.getServerIdentity();
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function setServerFollowingMe(id, following) {
 | 
			
		||||
	return ssb.setServerFollowingMe(id, following);
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function getIdentities() {
 | 
			
		||||
	return ssb.getIdentities();
 | 
			
		||||
});
 | 
			
		||||
@@ -73,12 +76,9 @@ tfrpc.register(function getHash(id, message) {
 | 
			
		||||
tfrpc.register(function setHash(hash) {
 | 
			
		||||
	return app.setHash(hash);
 | 
			
		||||
});
 | 
			
		||||
core.register('onMessage', async function (id) {
 | 
			
		||||
ssb.addEventListener('message', async function (id) {
 | 
			
		||||
	await tfrpc.rpc.notifyNewMessage(id);
 | 
			
		||||
});
 | 
			
		||||
core.register('onBlob', async function (id) {
 | 
			
		||||
	await tfrpc.rpc.notifyNewBlob(id);
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function store_blob(blob) {
 | 
			
		||||
	if (Array.isArray(blob)) {
 | 
			
		||||
		blob = Uint8Array.from(blob);
 | 
			
		||||
@@ -100,35 +100,13 @@ tfrpc.register(async function try_decrypt(id, content) {
 | 
			
		||||
tfrpc.register(async function encrypt(id, recipients, content) {
 | 
			
		||||
	return await ssb.privateMessageEncrypt(id, recipients, content);
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function getActiveIdentity() {
 | 
			
		||||
	return await ssb.getActiveIdentity();
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function sync() {
 | 
			
		||||
	return await ssb.sync();
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function url() {
 | 
			
		||||
	return core.url;
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function globalSettingsGet(key) {
 | 
			
		||||
	return core.globalSettingsGet(key);
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function globalSettingsSet(key, value) {
 | 
			
		||||
	return core.globalSettingsSet(key, value);
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(function isAdministrator() {
 | 
			
		||||
	return core.user?.credentials?.permissions?.administration;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
core.register('onBroadcastsChanged', async function () {
 | 
			
		||||
ssb.addEventListener('broadcasts', async function () {
 | 
			
		||||
	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts());
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
core.register('onConnectionsChanged', async function () {
 | 
			
		||||
	await tfrpc.rpc.set('connections', await ssb.connections());
 | 
			
		||||
});
 | 
			
		||||
core.register('setActiveIdentity', async function (id) {
 | 
			
		||||
	await tfrpc.rpc.set('identity', id);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	if (typeof database !== 'undefined') {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,94 +1,90 @@
 | 
			
		||||
function textNode(text) {
 | 
			
		||||
	const node = new commonmark.Node('text', undefined);
 | 
			
		||||
	node.literal = text;
 | 
			
		||||
	return node;
 | 
			
		||||
  const node = new commonmark.Node("text", undefined);
 | 
			
		||||
  node.literal = text;
 | 
			
		||||
  return node;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function linkNode(text, link) {
 | 
			
		||||
	const linkNode = new commonmark.Node('link', undefined);
 | 
			
		||||
	if (link.startsWith('#')) {
 | 
			
		||||
		linkNode.destination = `#${encodeURIComponent(link)}`;
 | 
			
		||||
	} else {
 | 
			
		||||
		linkNode.destination = link;
 | 
			
		||||
	}
 | 
			
		||||
	linkNode.appendChild(textNode(text));
 | 
			
		||||
	return linkNode;
 | 
			
		||||
  const linkNode = new commonmark.Node("link", undefined);
 | 
			
		||||
  linkNode.destination = `#q=${encodeURIComponent(link)}`;
 | 
			
		||||
  linkNode.appendChild(textNode(text));
 | 
			
		||||
  return linkNode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function splitMatches(text, regexp) {
 | 
			
		||||
	// Regexp must be sticky.
 | 
			
		||||
	regexp = new RegExp(regexp, 'gm');
 | 
			
		||||
  // Regexp must be sticky.
 | 
			
		||||
  regexp = new RegExp(regexp, "gm");
 | 
			
		||||
 | 
			
		||||
	let i = 0;
 | 
			
		||||
	const result = [];
 | 
			
		||||
  let i = 0;
 | 
			
		||||
  const result = [];
 | 
			
		||||
 | 
			
		||||
	let match = regexp.exec(text);
 | 
			
		||||
	while (match) {
 | 
			
		||||
		const matchText = match[0];
 | 
			
		||||
  let match = regexp.exec(text);
 | 
			
		||||
  while (match) {
 | 
			
		||||
    const matchText = match[0];
 | 
			
		||||
 | 
			
		||||
		if (match.index > i) {
 | 
			
		||||
			result.push([text.substring(i, match.index), false]);
 | 
			
		||||
		}
 | 
			
		||||
    if (match.index > i) {
 | 
			
		||||
      result.push([text.substring(i, match.index), false]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
		result.push([matchText, true]);
 | 
			
		||||
		i = match.index + matchText.length;
 | 
			
		||||
    result.push([matchText, true]);
 | 
			
		||||
    i = match.index + matchText.length;
 | 
			
		||||
 | 
			
		||||
		match = regexp.exec(text);
 | 
			
		||||
	}
 | 
			
		||||
    match = regexp.exec(text);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
	if (i < text.length) {
 | 
			
		||||
		result.push([text.substring(i, text.length), false]);
 | 
			
		||||
	}
 | 
			
		||||
  if (i < text.length) {
 | 
			
		||||
    result.push([text.substring(i, text.length), false]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
	return result;
 | 
			
		||||
  return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const regex = new RegExp('(?:https?://[^ ]+[^ .,])|(?:(?<!\\w)#[\\w-]+)|(?:@[A-Za-z0-9+/]+=.ed25519)|(?:[%&][A-Za-z0-9+/]+=.sha256)');
 | 
			
		||||
const regex = new RegExp("(?<!\\w)#[\\w-]+");
 | 
			
		||||
 | 
			
		||||
function split(textNodes) {
 | 
			
		||||
	const text = textNodes.map((n) => n.literal).join('');
 | 
			
		||||
	const parts = splitMatches(text, regex);
 | 
			
		||||
  const text = textNodes.map(n => n.literal).join("");
 | 
			
		||||
  const parts = splitMatches(text, regex);
 | 
			
		||||
 | 
			
		||||
	return parts.map((part) => {
 | 
			
		||||
		if (part[1]) {
 | 
			
		||||
			return linkNode(part[0], part[0]);
 | 
			
		||||
		} else {
 | 
			
		||||
			return textNode(part[0]);
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
  return parts.map(part => {
 | 
			
		||||
    if (part[1]) {
 | 
			
		||||
      return linkNode(part[0], part[0]);
 | 
			
		||||
    } else {
 | 
			
		||||
      return textNode(part[0]);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function transform(parsed) {
 | 
			
		||||
	const walker = parsed.walker();
 | 
			
		||||
	let event;
 | 
			
		||||
  const walker = parsed.walker();
 | 
			
		||||
  let event;
 | 
			
		||||
 | 
			
		||||
	let nodes = [];
 | 
			
		||||
	while ((event = walker.next())) {
 | 
			
		||||
		const node = event.node;
 | 
			
		||||
		if (event.entering && node.type === 'text') {
 | 
			
		||||
			nodes.push(node);
 | 
			
		||||
		} else {
 | 
			
		||||
			if (nodes.length > 0) {
 | 
			
		||||
				split(nodes)
 | 
			
		||||
					.reverse()
 | 
			
		||||
					.forEach((newNode) => {
 | 
			
		||||
						nodes[0].insertAfter(newNode);
 | 
			
		||||
					});
 | 
			
		||||
  let nodes = [];
 | 
			
		||||
  while ((event = walker.next())) {
 | 
			
		||||
    const node = event.node;
 | 
			
		||||
    if (event.entering && node.type === "text") {
 | 
			
		||||
      nodes.push(node);
 | 
			
		||||
    } else {
 | 
			
		||||
      if (nodes.length > 0) {
 | 
			
		||||
        split(nodes)
 | 
			
		||||
          .reverse()
 | 
			
		||||
          .forEach(newNode => {
 | 
			
		||||
            nodes[0].insertAfter(newNode);
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
				nodes.forEach((n) => n.unlink());
 | 
			
		||||
				nodes = [];
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
        nodes.forEach(n => n.unlink());
 | 
			
		||||
        nodes = [];
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
	if (nodes.length > 0) {
 | 
			
		||||
		split(nodes)
 | 
			
		||||
			.reverse()
 | 
			
		||||
			.forEach((newNode) => {
 | 
			
		||||
				nodes[0].insertAfter(newNode);
 | 
			
		||||
			});
 | 
			
		||||
		nodes.forEach((n) => n.unlink());
 | 
			
		||||
	}
 | 
			
		||||
  if (nodes.length > 0) {
 | 
			
		||||
    split(nodes)
 | 
			
		||||
      .reverse()
 | 
			
		||||
      .forEach(newNode => {
 | 
			
		||||
        nodes[0].insertAfter(newNode);
 | 
			
		||||
      });
 | 
			
		||||
    nodes.forEach(n => n.unlink());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
	return parsed;
 | 
			
		||||
  return parsed;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										91
									
								
								apps/ssb/commonmark-linkify.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								apps/ssb/commonmark-linkify.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,91 @@
 | 
			
		||||
function textNode(text) {
 | 
			
		||||
  const node = new commonmark.Node("text", undefined);
 | 
			
		||||
  node.literal = text;
 | 
			
		||||
  return node;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function linkNode(text, url) {
 | 
			
		||||
  const urlNode = new commonmark.Node("link", undefined);
 | 
			
		||||
  urlNode.destination = url;
 | 
			
		||||
  urlNode.appendChild(textNode(text));
 | 
			
		||||
 | 
			
		||||
  return urlNode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function splitMatches(text, regexp) {
 | 
			
		||||
  // Regexp must be sticky.
 | 
			
		||||
  regexp = new RegExp(regexp, "gm");
 | 
			
		||||
 | 
			
		||||
  let i = 0;
 | 
			
		||||
  const result = [];
 | 
			
		||||
 | 
			
		||||
  let match = regexp.exec(text);
 | 
			
		||||
  while (match) {
 | 
			
		||||
    const matchText = match[0];
 | 
			
		||||
 | 
			
		||||
    if (match.index > i) {
 | 
			
		||||
      result.push([text.substring(i, match.index), false]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    result.push([matchText, true]);
 | 
			
		||||
    i = match.index + matchText.length;
 | 
			
		||||
 | 
			
		||||
    match = regexp.exec(text);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (i < text.length) {
 | 
			
		||||
    result.push([text.substring(i, text.length), false]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const urlRegexp = new RegExp("https?://[^ ]+[^ .,]");
 | 
			
		||||
 | 
			
		||||
function splitURLs(textNodes) {
 | 
			
		||||
  const text = textNodes.map(n => n.literal).join("");
 | 
			
		||||
  const parts = splitMatches(text, urlRegexp);
 | 
			
		||||
 | 
			
		||||
  return parts.map(part => {
 | 
			
		||||
    if (part[1]) {
 | 
			
		||||
      return linkNode(part[0], part[0]);
 | 
			
		||||
    } else {
 | 
			
		||||
      return textNode(part[0]);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function transform(parsed) {
 | 
			
		||||
  const walker = parsed.walker();
 | 
			
		||||
  let event;
 | 
			
		||||
 | 
			
		||||
  let nodes = [];
 | 
			
		||||
  while ((event = walker.next())) {
 | 
			
		||||
    const node = event.node;
 | 
			
		||||
    if (event.entering && node.type === "text") {
 | 
			
		||||
      nodes.push(node);
 | 
			
		||||
    } else {
 | 
			
		||||
      if (nodes.length > 0) {
 | 
			
		||||
        splitURLs(nodes)
 | 
			
		||||
          .reverse()
 | 
			
		||||
          .forEach(newNode => {
 | 
			
		||||
            nodes[0].insertAfter(newNode);
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
        nodes.forEach(n => n.unlink());
 | 
			
		||||
        nodes = [];
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (nodes.length > 0) {
 | 
			
		||||
    splitURLs(nodes)
 | 
			
		||||
      .reverse()
 | 
			
		||||
      .forEach(newNode => {
 | 
			
		||||
        nodes[0].insertAfter(newNode);
 | 
			
		||||
      });
 | 
			
		||||
    nodes.forEach(n => n.unlink());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return parsed;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										2
									
								
								apps/ssb/commonmark.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								apps/ssb/commonmark.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -1,7 +1,3 @@
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {html, render} from './lit-all.min.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
let g_emojis;
 | 
			
		||||
 | 
			
		||||
function get_emojis() {
 | 
			
		||||
@@ -14,161 +10,105 @@ function get_emojis() {
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function picker(callback, anchor, author, recent) {
 | 
			
		||||
	let json = await get_emojis();
 | 
			
		||||
export function picker(callback, anchor) {
 | 
			
		||||
	get_emojis().then(function (json) {
 | 
			
		||||
		let div = document.createElement('div');
 | 
			
		||||
		div.id = 'emoji_picker';
 | 
			
		||||
		div.style.color = '#000';
 | 
			
		||||
		div.style.background = '#fff';
 | 
			
		||||
		div.style.border = '1px solid #000';
 | 
			
		||||
		div.style.display = 'block';
 | 
			
		||||
		div.style.position = 'absolute';
 | 
			
		||||
		div.style.minWidth = 'min(16em, 90vw)';
 | 
			
		||||
		div.style.width = 'min(16em, 90vw)';
 | 
			
		||||
		div.style.maxWidth = 'min(16em, 90vw)';
 | 
			
		||||
		div.style.maxHeight = '16em';
 | 
			
		||||
		div.style.overflow = 'scroll';
 | 
			
		||||
		div.style.fontWeight = 'bold';
 | 
			
		||||
		div.style.fontSize = 'xx-large';
 | 
			
		||||
		let input = document.createElement('input');
 | 
			
		||||
		input.type = 'text';
 | 
			
		||||
		input.style.display = 'block';
 | 
			
		||||
		input.style.boxSizing = 'border-box';
 | 
			
		||||
		input.style.width = '100%';
 | 
			
		||||
		input.style.margin = '0';
 | 
			
		||||
		input.style.position = 'relative';
 | 
			
		||||
		div.appendChild(input);
 | 
			
		||||
		let list = document.createElement('div');
 | 
			
		||||
		div.appendChild(list);
 | 
			
		||||
		div.addEventListener('mousedown', function (event) {
 | 
			
		||||
			event.stopPropagation();
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
	let div = document.createElement('div');
 | 
			
		||||
	div.id = 'emoji_picker';
 | 
			
		||||
	div.style.color = '#000';
 | 
			
		||||
	div.style.background = '#fff';
 | 
			
		||||
	div.style.border = '1px solid #000';
 | 
			
		||||
	div.style.display = 'flex';
 | 
			
		||||
	div.style.overflow = 'scroll';
 | 
			
		||||
	div.style.fontWeight = 'bold';
 | 
			
		||||
	div.style.fontSize = 'xx-large';
 | 
			
		||||
	div.style.flex = '1 1';
 | 
			
		||||
	div.style.flexDirection = 'column';
 | 
			
		||||
	let input = document.createElement('input');
 | 
			
		||||
	input.type = 'text';
 | 
			
		||||
	input.style.display = 'block';
 | 
			
		||||
	input.style.boxSizing = 'border-box';
 | 
			
		||||
	input.style.width = '100%';
 | 
			
		||||
	input.style.margin = '0';
 | 
			
		||||
	input.style.position = 'relative';
 | 
			
		||||
	div.appendChild(input);
 | 
			
		||||
	let list = document.createElement('div');
 | 
			
		||||
	list.style.overflow = 'scroll';
 | 
			
		||||
	div.appendChild(list);
 | 
			
		||||
	div.addEventListener('mousedown', function (event) {
 | 
			
		||||
		event.stopPropagation();
 | 
			
		||||
	});
 | 
			
		||||
		function cleanup() {
 | 
			
		||||
			console.log('emoji cleanup');
 | 
			
		||||
			div.parentElement.removeChild(div);
 | 
			
		||||
			window.removeEventListener('keydown', key_down);
 | 
			
		||||
			console.log('removing click');
 | 
			
		||||
			document.body.removeEventListener('mousedown', cleanup);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	function key_down(event) {
 | 
			
		||||
		if (event.key == 'Escape') {
 | 
			
		||||
		function key_down(event) {
 | 
			
		||||
			if (event.key == 'Escape') {
 | 
			
		||||
				cleanup();
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		function chosen(event) {
 | 
			
		||||
			console.log(event.srcElement.innerText);
 | 
			
		||||
			callback(event.srcElement.innerText);
 | 
			
		||||
			cleanup();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function chosen(event) {
 | 
			
		||||
		console.log(event.srcElement.innerText);
 | 
			
		||||
		callback(event.srcElement.innerText);
 | 
			
		||||
		cleanup();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function refresh() {
 | 
			
		||||
		while (list.firstChild) {
 | 
			
		||||
			list.removeChild(list.firstChild);
 | 
			
		||||
		}
 | 
			
		||||
		let search = input.value.toLowerCase();
 | 
			
		||||
		let any_at_all = false;
 | 
			
		||||
		if (recent) {
 | 
			
		||||
			let emoji_to_name = {};
 | 
			
		||||
			for (let row of Object.values(json)) {
 | 
			
		||||
				for (let entry of Object.entries(row)) {
 | 
			
		||||
					emoji_to_name[entry[1]] = entry[0];
 | 
			
		||||
		function refresh() {
 | 
			
		||||
			while (list.firstChild) {
 | 
			
		||||
				list.removeChild(list.firstChild);
 | 
			
		||||
			}
 | 
			
		||||
			let search = input.value.toLowerCase();
 | 
			
		||||
			let any_at_all = false;
 | 
			
		||||
			for (let row of Object.entries(json)) {
 | 
			
		||||
				let header = document.createElement('div');
 | 
			
		||||
				header.appendChild(document.createTextNode(row[0]));
 | 
			
		||||
				list.appendChild(header);
 | 
			
		||||
				let any = false;
 | 
			
		||||
				for (let entry of Object.entries(row[1])) {
 | 
			
		||||
					if (
 | 
			
		||||
						search &&
 | 
			
		||||
						search.length &&
 | 
			
		||||
						entry[0].toLowerCase().indexOf(search) == -1
 | 
			
		||||
					) {
 | 
			
		||||
						continue;
 | 
			
		||||
					}
 | 
			
		||||
					let emoji = document.createElement('span');
 | 
			
		||||
					const k_size = '1.25em';
 | 
			
		||||
					emoji.style.display = 'inline-block';
 | 
			
		||||
					emoji.style.overflow = 'hidden';
 | 
			
		||||
					emoji.style.cursor = 'pointer';
 | 
			
		||||
					emoji.onclick = chosen;
 | 
			
		||||
					emoji.title = entry[0];
 | 
			
		||||
					emoji.appendChild(document.createTextNode(entry[1]));
 | 
			
		||||
					list.appendChild(emoji);
 | 
			
		||||
					any = true;
 | 
			
		||||
					any_at_all = true;
 | 
			
		||||
				}
 | 
			
		||||
				if (!any) {
 | 
			
		||||
					list.removeChild(header);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			let header = document.createElement('div');
 | 
			
		||||
			header.appendChild(document.createTextNode('Recent'));
 | 
			
		||||
			list.appendChild(header);
 | 
			
		||||
			let any = false;
 | 
			
		||||
			for (let entry of recent) {
 | 
			
		||||
				if (
 | 
			
		||||
					search &&
 | 
			
		||||
					search.length &&
 | 
			
		||||
					(emoji_to_name[entry] || '').toLowerCase().indexOf(search) == -1
 | 
			
		||||
				) {
 | 
			
		||||
					continue;
 | 
			
		||||
				}
 | 
			
		||||
				let emoji = document.createElement('span');
 | 
			
		||||
				const k_size = '1.25em';
 | 
			
		||||
				emoji.style.display = 'inline-block';
 | 
			
		||||
				emoji.style.overflow = 'hidden';
 | 
			
		||||
				emoji.style.cursor = 'pointer';
 | 
			
		||||
				emoji.onclick = chosen;
 | 
			
		||||
				emoji.title = emoji_to_name[entry] || entry;
 | 
			
		||||
				emoji.appendChild(document.createTextNode(entry));
 | 
			
		||||
				list.appendChild(emoji);
 | 
			
		||||
				any = true;
 | 
			
		||||
			}
 | 
			
		||||
			if (!any) {
 | 
			
		||||
				list.removeChild(header);
 | 
			
		||||
			if (!any_at_all) {
 | 
			
		||||
				list.appendChild(document.createTextNode('No matches found.'));
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		for (let row of Object.entries(json)) {
 | 
			
		||||
			let header = document.createElement('div');
 | 
			
		||||
			header.appendChild(document.createTextNode(row[0]));
 | 
			
		||||
			list.appendChild(header);
 | 
			
		||||
			let any = false;
 | 
			
		||||
			for (let entry of Object.entries(row[1])) {
 | 
			
		||||
				if (
 | 
			
		||||
					search &&
 | 
			
		||||
					search.length &&
 | 
			
		||||
					entry[0].toLowerCase().indexOf(search) == -1
 | 
			
		||||
				) {
 | 
			
		||||
					continue;
 | 
			
		||||
				}
 | 
			
		||||
				let emoji = document.createElement('span');
 | 
			
		||||
				const k_size = '1.25em';
 | 
			
		||||
				emoji.style.display = 'inline-block';
 | 
			
		||||
				emoji.style.overflow = 'hidden';
 | 
			
		||||
				emoji.style.cursor = 'pointer';
 | 
			
		||||
				emoji.onclick = chosen;
 | 
			
		||||
				emoji.title = entry[0];
 | 
			
		||||
				emoji.appendChild(document.createTextNode(entry[1]));
 | 
			
		||||
				list.appendChild(emoji);
 | 
			
		||||
				any = true;
 | 
			
		||||
				any_at_all = true;
 | 
			
		||||
			}
 | 
			
		||||
			if (!any) {
 | 
			
		||||
				list.removeChild(header);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if (!any_at_all) {
 | 
			
		||||
			list.appendChild(document.createTextNode('No matches found.'));
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	refresh();
 | 
			
		||||
	input.oninput = refresh;
 | 
			
		||||
	let parent = document.createElement('div');
 | 
			
		||||
	function cleanup() {
 | 
			
		||||
		parent.parentElement.removeChild(parent);
 | 
			
		||||
		window.removeEventListener('keydown', key_down);
 | 
			
		||||
		document.body.removeEventListener('mousedown', cleanup);
 | 
			
		||||
	}
 | 
			
		||||
	let modal = html`
 | 
			
		||||
		<style>
 | 
			
		||||
			${styles}
 | 
			
		||||
		</style>
 | 
			
		||||
		<style>
 | 
			
		||||
			${generate_theme()}
 | 
			
		||||
		</style>
 | 
			
		||||
		<div
 | 
			
		||||
			class="w3-modal"
 | 
			
		||||
			style="display: block; box-sizing: border-box; z-index: 10"
 | 
			
		||||
		>
 | 
			
		||||
			<div class="w3-modal-content w3-card-4">
 | 
			
		||||
				<div
 | 
			
		||||
					class="w3-content w3-theme-d1"
 | 
			
		||||
					style="display: flex; flex-direction: column; max-height: 80vh"
 | 
			
		||||
				>
 | 
			
		||||
					<header class="w3-container" style="flex: 0 0">
 | 
			
		||||
						<h1>Choose a Reaction</h1>
 | 
			
		||||
						<span class="w3-button w3-display-topright" @click=${cleanup}
 | 
			
		||||
							>×</span
 | 
			
		||||
						>
 | 
			
		||||
					</header>
 | 
			
		||||
					${div}
 | 
			
		||||
					<footer class="w3-container w3-padding" style="flex: 0 0">
 | 
			
		||||
						<button class="w3-button" @click=${cleanup}>Close</button>
 | 
			
		||||
					</footer>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	`;
 | 
			
		||||
	document.body.appendChild(parent);
 | 
			
		||||
	render(modal, parent);
 | 
			
		||||
	input.focus();
 | 
			
		||||
	document.body.addEventListener('mousedown', cleanup);
 | 
			
		||||
	window.addEventListener('keydown', key_down);
 | 
			
		||||
		refresh();
 | 
			
		||||
		input.oninput = refresh;
 | 
			
		||||
		document.body.appendChild(div);
 | 
			
		||||
		div.style.position = 'fixed';
 | 
			
		||||
		div.style.top = '50%';
 | 
			
		||||
		div.style.left = '50%';
 | 
			
		||||
		div.style.transform = 'translate(-50%, -50%)';
 | 
			
		||||
		input.focus();
 | 
			
		||||
		console.log('adding click');
 | 
			
		||||
		document.body.addEventListener('mousedown', cleanup);
 | 
			
		||||
		window.addEventListener('keydown', key_down);
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -1,5 +1,5 @@
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html>
 | 
			
		||||
<html style="color: #fff">
 | 
			
		||||
	<head>
 | 
			
		||||
		<title>Tilde Friends</title>
 | 
			
		||||
		<base target="_top" />
 | 
			
		||||
@@ -10,14 +10,14 @@
 | 
			
		||||
			}
 | 
			
		||||
		</style>
 | 
			
		||||
	</head>
 | 
			
		||||
	<body style="margin: 0; padding: 0">
 | 
			
		||||
		<tf-app></tf-app>
 | 
			
		||||
		<tf-reactions-modal id="reactions_modal"></tf-reactions-modal>
 | 
			
		||||
	<body style="background-color: #223a5e">
 | 
			
		||||
		<tf-app class="w3-deep-purple" />
 | 
			
		||||
		<script>
 | 
			
		||||
			window.litDisableBundleWarning = true;
 | 
			
		||||
		</script>
 | 
			
		||||
		<script src="filesaver.min.js"></script>
 | 
			
		||||
		<script src="commonmark.min.js"></script>
 | 
			
		||||
		<script src="commonmark-linkify.js" type="module"></script>
 | 
			
		||||
		<script src="commonmark-hashtag.js" type="module"></script>
 | 
			
		||||
		<script src="script.js" type="module"></script>
 | 
			
		||||
	</body>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								apps/ssb/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										42
									
								
								apps/ssb/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							@@ -1,25 +1,17 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
import * as tf_id_picker from './tf-id-picker.js';
 | 
			
		||||
import * as tf_app from './tf-app.js';
 | 
			
		||||
import * as tf_message from './tf-message.js';
 | 
			
		||||
import * as tf_user from './tf-user.js';
 | 
			
		||||
import * as tf_compose from './tf-compose.js';
 | 
			
		||||
import * as tf_news from './tf-news.js';
 | 
			
		||||
import * as tf_profile from './tf-profile.js';
 | 
			
		||||
import * as tf_reactions_modal from './tf-reactions-modal.js';
 | 
			
		||||
import * as tf_tab_mentions from './tf-tab-mentions.js';
 | 
			
		||||
import * as tf_tab_news from './tf-tab-news.js';
 | 
			
		||||
import * as tf_tab_news_feed from './tf-tab-news-feed.js';
 | 
			
		||||
import * as tf_tab_search from './tf-tab-search.js';
 | 
			
		||||
import * as tf_tab_connections from './tf-tab-connections.js';
 | 
			
		||||
import * as tf_tab_query from './tf-tab-query.js';
 | 
			
		||||
import * as tf_tag from './tf-tag.js';
 | 
			
		||||
import * as tf_styles from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
window.addEventListener('load', function () {
 | 
			
		||||
	let style = document.createElement('style');
 | 
			
		||||
	style.innerText = tf_styles.styles;
 | 
			
		||||
	Promise.resolve(tf_styles.generate_theme()).then(function (x) {
 | 
			
		||||
		style.innerText += x;
 | 
			
		||||
	});
 | 
			
		||||
	document.body.appendChild(style);
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,7 +1,7 @@
 | 
			
		||||
import {LitElement, html, unsafeHTML, live} from './lit-all.min.js';
 | 
			
		||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
 | 
			
		||||
import * as tfutils from './tf-utils.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
import Tribute from './tribute.esm.js';
 | 
			
		||||
 | 
			
		||||
class TfComposeElement extends LitElement {
 | 
			
		||||
@@ -13,10 +13,6 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			branch: {type: String},
 | 
			
		||||
			apps: {type: Object},
 | 
			
		||||
			drafts: {type: Object},
 | 
			
		||||
			author: {type: String},
 | 
			
		||||
			channel: {type: String},
 | 
			
		||||
			new_thread: {type: Boolean},
 | 
			
		||||
			recipients: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -29,8 +25,6 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		this.branch = undefined;
 | 
			
		||||
		this.apps = undefined;
 | 
			
		||||
		this.drafts = {};
 | 
			
		||||
		this.author = undefined;
 | 
			
		||||
		this.new_thread = false;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	process_text(text) {
 | 
			
		||||
@@ -70,7 +64,7 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			updated = true;
 | 
			
		||||
		}
 | 
			
		||||
		if (updated) {
 | 
			
		||||
			setTimeout(() => this.notify(draft), 0);
 | 
			
		||||
			this.requestUpdate();
 | 
			
		||||
		}
 | 
			
		||||
		return tfutils.markdown(text);
 | 
			
		||||
	}
 | 
			
		||||
@@ -78,12 +72,14 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
	input(event) {
 | 
			
		||||
		let edit = this.renderRoot.getElementById('edit');
 | 
			
		||||
		let preview = this.renderRoot.getElementById('preview');
 | 
			
		||||
		preview.innerHTML = this.process_text(edit.innerText);
 | 
			
		||||
		preview.innerHTML = this.process_text(edit.value);
 | 
			
		||||
		let content_warning = this.renderRoot.getElementById('content_warning');
 | 
			
		||||
		let draft = this.get_draft();
 | 
			
		||||
		draft.text = edit.innerText;
 | 
			
		||||
		draft.content_warning = content_warning?.value;
 | 
			
		||||
		setTimeout(() => this.notify(draft), 0);
 | 
			
		||||
		let content_warning_preview = this.renderRoot.getElementById(
 | 
			
		||||
			'content_warning_preview'
 | 
			
		||||
		);
 | 
			
		||||
		if (content_warning && content_warning_preview) {
 | 
			
		||||
			content_warning_preview.innerText = content_warning.value;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	notify(draft) {
 | 
			
		||||
@@ -92,15 +88,21 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				composed: true,
 | 
			
		||||
				detail: {
 | 
			
		||||
					id:
 | 
			
		||||
						this.branch ??
 | 
			
		||||
						(this.recipients ? this.recipients.join(',') : undefined),
 | 
			
		||||
					id: this.branch,
 | 
			
		||||
					draft: draft,
 | 
			
		||||
				},
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	change() {
 | 
			
		||||
		let draft = this.get_draft();
 | 
			
		||||
		draft.text = this.renderRoot.getElementById('edit')?.value;
 | 
			
		||||
		draft.content_warning =
 | 
			
		||||
			this.renderRoot.getElementById('content_warning')?.value;
 | 
			
		||||
		this.notify(draft);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	convert_to_format(buffer, type, mime_type) {
 | 
			
		||||
		return new Promise(function (resolve, reject) {
 | 
			
		||||
			let img = new Image();
 | 
			
		||||
@@ -167,7 +169,8 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
				size: buffer.length ?? buffer.byteLength,
 | 
			
		||||
			};
 | 
			
		||||
			let edit = self.renderRoot.getElementById('edit');
 | 
			
		||||
			edit.innerText += `\n`;
 | 
			
		||||
			edit.value += `\n`;
 | 
			
		||||
			self.change();
 | 
			
		||||
			self.input();
 | 
			
		||||
		} catch (e) {
 | 
			
		||||
			alert(e?.message);
 | 
			
		||||
@@ -186,13 +189,6 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
				break;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		event.preventDefault();
 | 
			
		||||
		document.execCommand(
 | 
			
		||||
			'insertText',
 | 
			
		||||
			false,
 | 
			
		||||
			event.clipboardData.getData('text/plain')
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async submit() {
 | 
			
		||||
@@ -201,27 +197,12 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		let edit = this.renderRoot.getElementById('edit');
 | 
			
		||||
		let message = {
 | 
			
		||||
			type: 'post',
 | 
			
		||||
			text: edit.innerText,
 | 
			
		||||
			channel: this.channel,
 | 
			
		||||
			text: edit.value,
 | 
			
		||||
		};
 | 
			
		||||
		if (this.root || this.branch) {
 | 
			
		||||
			message.root = this.new_thread ? (this.branch ?? this.root) : this.root;
 | 
			
		||||
			message.root = this.root;
 | 
			
		||||
			message.branch = this.branch;
 | 
			
		||||
		}
 | 
			
		||||
		let reply = Object.fromEntries(
 | 
			
		||||
			(
 | 
			
		||||
				await tfrpc.rpc.query(
 | 
			
		||||
					`
 | 
			
		||||
				SELECT messages.id, messages.author FROM messages
 | 
			
		||||
				JOIN json_each(?) AS refs ON messages.id = refs.value
 | 
			
		||||
			`,
 | 
			
		||||
					[JSON.stringify([this.root, this.branch])]
 | 
			
		||||
				)
 | 
			
		||||
			).map((row) => [row.id, row.author])
 | 
			
		||||
		);
 | 
			
		||||
		if (Object.keys(reply).length) {
 | 
			
		||||
			message.reply = reply;
 | 
			
		||||
		}
 | 
			
		||||
		if (Object.values(draft.mentions || {}).length) {
 | 
			
		||||
			message.mentions = Object.values(draft.mentions);
 | 
			
		||||
		}
 | 
			
		||||
@@ -243,27 +224,35 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			console.log('encrypted as', message);
 | 
			
		||||
		}
 | 
			
		||||
		try {
 | 
			
		||||
			await tfrpc.rpc.appendMessage(this.whoami, message);
 | 
			
		||||
			self.notify(undefined);
 | 
			
		||||
			await tfrpc.rpc.appendMessage(this.whoami, message).then(function () {
 | 
			
		||||
				edit.value = '';
 | 
			
		||||
				self.change();
 | 
			
		||||
				self.notify(undefined);
 | 
			
		||||
				self.requestUpdate();
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			alert(error.message);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	discard() {
 | 
			
		||||
		let edit = this.renderRoot.getElementById('edit');
 | 
			
		||||
		edit.value = '';
 | 
			
		||||
		this.change();
 | 
			
		||||
		let preview = this.renderRoot.getElementById('preview');
 | 
			
		||||
		preview.innerHTML = '';
 | 
			
		||||
		this.notify(undefined);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	attach() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		let edit = this.renderRoot.getElementById('edit');
 | 
			
		||||
		let input = document.createElement('input');
 | 
			
		||||
		input.type = 'file';
 | 
			
		||||
		input.addEventListener('change', function (event) {
 | 
			
		||||
			input.parentNode.removeChild(input);
 | 
			
		||||
		input.onchange = function (event) {
 | 
			
		||||
			let file = event.target.files[0];
 | 
			
		||||
			self.add_file(file);
 | 
			
		||||
		});
 | 
			
		||||
		document.body.appendChild(input);
 | 
			
		||||
		};
 | 
			
		||||
		input.click();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -273,9 +262,9 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		try {
 | 
			
		||||
			let rows = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
				SELECT json(messages.content) AS content FROM messages_fts(?)
 | 
			
		||||
				SELECT json(messages.content) FROM messages_fts(?)
 | 
			
		||||
				JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
				WHERE json(messages.content) LIKE ?
 | 
			
		||||
				WHERE messages.content LIKE ?
 | 
			
		||||
				ORDER BY timestamp DESC LIMIT 10
 | 
			
		||||
			`,
 | 
			
		||||
				['"' + text.replace('"', '""') + '"', `%%`]
 | 
			
		||||
@@ -294,65 +283,41 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	get_values() {
 | 
			
		||||
		let values = Object.entries(this.users).map((x) => ({
 | 
			
		||||
			key: x[1].name ?? x[0],
 | 
			
		||||
			value: x[0],
 | 
			
		||||
		}));
 | 
			
		||||
		if (this.author) {
 | 
			
		||||
			values = [].concat(
 | 
			
		||||
				[
 | 
			
		||||
					{
 | 
			
		||||
						key: this.users[this.author]?.name,
 | 
			
		||||
						value: this.author,
 | 
			
		||||
					},
 | 
			
		||||
				],
 | 
			
		||||
				values
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
		return values;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	firstUpdated() {
 | 
			
		||||
		let tribute = new Tribute({
 | 
			
		||||
			iframe: this.shadowRoot,
 | 
			
		||||
			collection: [
 | 
			
		||||
				{
 | 
			
		||||
					values: this.get_values(),
 | 
			
		||||
					values: Object.entries(this.users).map((x) => ({
 | 
			
		||||
						key: x[1].name,
 | 
			
		||||
						value: x[0],
 | 
			
		||||
					})),
 | 
			
		||||
					selectTemplate: function (item) {
 | 
			
		||||
						return item
 | 
			
		||||
							? `[@${item.original.key}](${item.original.value})`
 | 
			
		||||
							: undefined;
 | 
			
		||||
						return `[@${item.original.key}](${item.original.value})`;
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				{
 | 
			
		||||
					trigger: '&',
 | 
			
		||||
					values: this.autocomplete,
 | 
			
		||||
					selectTemplate: function (item) {
 | 
			
		||||
						return item
 | 
			
		||||
							? ``
 | 
			
		||||
							: undefined;
 | 
			
		||||
						return ``;
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			],
 | 
			
		||||
		});
 | 
			
		||||
		tribute.attach(this.renderRoot.getElementById('edit'));
 | 
			
		||||
		this._tribute = tribute;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updated() {
 | 
			
		||||
		super.updated();
 | 
			
		||||
		let edit = this.renderRoot.getElementById('edit');
 | 
			
		||||
		if (this.last_updated_text !== edit.innerText) {
 | 
			
		||||
		if (this.last_updated_text !== edit.value) {
 | 
			
		||||
			let preview = this.renderRoot.getElementById('preview');
 | 
			
		||||
			preview.innerHTML = this.process_text(edit.innerText);
 | 
			
		||||
			this.last_updated_text = edit.innerText;
 | 
			
		||||
			preview.innerHTML = this.process_text(edit.value);
 | 
			
		||||
			this.last_updated_text = edit.value;
 | 
			
		||||
		}
 | 
			
		||||
		this._tribute.collection[0].values = this.get_values();
 | 
			
		||||
		let encrypt = this.renderRoot.getElementById('encrypt_to');
 | 
			
		||||
		if (encrypt) {
 | 
			
		||||
			let tribute = new Tribute({
 | 
			
		||||
				iframe: this.shadowRoot,
 | 
			
		||||
				values: Object.entries(this.users).map((x) => ({
 | 
			
		||||
					key: x[1].name,
 | 
			
		||||
					value: x[0],
 | 
			
		||||
@@ -368,7 +333,8 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
	remove_mention(id) {
 | 
			
		||||
		let draft = this.get_draft();
 | 
			
		||||
		delete draft.mentions[id];
 | 
			
		||||
		setTimeout(() => this.notify(draft), 0);
 | 
			
		||||
		this.notify(draft);
 | 
			
		||||
		this.requestUpdate();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_mention(mention) {
 | 
			
		||||
@@ -376,7 +342,7 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		return html` <div style="display: flex; flex-direction: row">
 | 
			
		||||
			<div style="align-self: center; margin: 0.5em">
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-theme-d1"
 | 
			
		||||
					class="w3-button w3-dark-grey"
 | 
			
		||||
					title="Remove ${mention.name} mention"
 | 
			
		||||
					@click=${() => self.remove_mention(mention.link)}
 | 
			
		||||
				>
 | 
			
		||||
@@ -430,16 +396,16 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		if (this.apps) {
 | 
			
		||||
			return html`
 | 
			
		||||
				<div class="w3-card-4 w3-margin w3-padding">
 | 
			
		||||
					<select id="select" class="w3-select w3-theme-d1">
 | 
			
		||||
					<select id="select" class="w3-select w3-dark-grey">
 | 
			
		||||
						${Object.keys(self.apps).map(
 | 
			
		||||
							(app) => html`<option value=${app}>${app}</option>`
 | 
			
		||||
						)}
 | 
			
		||||
					</select>
 | 
			
		||||
					<button class="w3-button w3-theme-d1" @click=${attach_selected_app}>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${attach_selected_app}>
 | 
			
		||||
						Attach
 | 
			
		||||
					</button>
 | 
			
		||||
					<button
 | 
			
		||||
						class="w3-button w3-theme-d1"
 | 
			
		||||
						class="w3-button w3-dark-grey"
 | 
			
		||||
						@click=${() => (this.apps = null)}
 | 
			
		||||
					>
 | 
			
		||||
						Cancel
 | 
			
		||||
@@ -455,15 +421,12 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			self.apps = await tfrpc.rpc.apps();
 | 
			
		||||
		}
 | 
			
		||||
		if (!this.apps) {
 | 
			
		||||
			return html`<button
 | 
			
		||||
				class="w3-button w3-bar-item w3-theme-d1"
 | 
			
		||||
				@click=${attach_app}
 | 
			
		||||
			>
 | 
			
		||||
			return html`<button class="w3-button w3-dark-grey" @click=${attach_app}>
 | 
			
		||||
				Attach App
 | 
			
		||||
			</button>`;
 | 
			
		||||
		} else {
 | 
			
		||||
			return html`<button
 | 
			
		||||
				class="w3-button w3-bar-item w3-theme-d1"
 | 
			
		||||
				class="w3-button w3-dark-grey"
 | 
			
		||||
				@click=${() => (this.apps = null)}
 | 
			
		||||
			>
 | 
			
		||||
				Discard App
 | 
			
		||||
@@ -484,38 +447,23 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		if (draft.content_warning !== undefined) {
 | 
			
		||||
			return html`
 | 
			
		||||
				<div class="w3-container w3-padding">
 | 
			
		||||
					<input type="text" class="w3-input w3-border w3-theme-d1" id="content_warning" placeholder="Enter a content warning here." @input=${self.input} value=${draft.content_warning}></input>
 | 
			
		||||
					<p>
 | 
			
		||||
						<input type="checkbox" class="w3-check w3-dark-grey" id="cw" @change=${() => self.set_content_warning(undefined)} checked="checked"></input>
 | 
			
		||||
						<label for="cw">CW</label>
 | 
			
		||||
					</p>
 | 
			
		||||
					<input type="text" class="w3-input w3-border w3-dark-grey" id="content_warning" placeholder="Enter a content warning here." @input=${this.input} @change=${this.change} value=${draft.content_warning}></input>
 | 
			
		||||
				</div>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_new_thread() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		if (
 | 
			
		||||
			this.root !== undefined &&
 | 
			
		||||
			this.branch !== undefined &&
 | 
			
		||||
			this.root != this.branch
 | 
			
		||||
		) {
 | 
			
		||||
		} else {
 | 
			
		||||
			return html`
 | 
			
		||||
				<input type="checkbox" class="w3-check w3-theme-d1" id="new_thread" @change=${() => (self.new_thread = !self.new_thread)} ?checked=${self.new_thread}></input>
 | 
			
		||||
				<label for="new_thread">New Thread</label>
 | 
			
		||||
				<input type="checkbox" class="w3-check w3-dark-grey" id="cw" @change=${() => self.set_content_warning('')}></input>
 | 
			
		||||
				<label for="cw">CW</label>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	get_draft() {
 | 
			
		||||
		let key =
 | 
			
		||||
			this.branch ||
 | 
			
		||||
			(this.recipients ? this.recipients.join(',') : undefined) ||
 | 
			
		||||
			'';
 | 
			
		||||
		let draft = this.drafts[key] || {};
 | 
			
		||||
		if (this.recipients && !draft.encrypt_to?.length) {
 | 
			
		||||
			draft.encrypt_to = [
 | 
			
		||||
				...new Set(this.recipients).union(new Set(draft.encrypt_to ?? [])),
 | 
			
		||||
			];
 | 
			
		||||
		}
 | 
			
		||||
		return draft;
 | 
			
		||||
		return this.drafts[this.branch || ''] || {};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	update_encrypt(event) {
 | 
			
		||||
@@ -537,15 +485,15 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		return html`
 | 
			
		||||
			<div style="display: flex; flex-direction: row; width: 100%">
 | 
			
		||||
				<label for="encrypt_to">🔐 To:</label>
 | 
			
		||||
				<input type="text" id="encrypt_to" class="w3-input w3-theme-d1 w3-border" style="display: flex; flex: 1 1" @input=${this.update_encrypt}></input>
 | 
			
		||||
				<button class="w3-button w3-theme-d1" @click=${() => this.set_encrypt(undefined)}>🚮</button>
 | 
			
		||||
				<input type="text" id="encrypt_to" style="display: flex; flex: 1 1" @input=${this.update_encrypt}></input>
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${() => this.set_encrypt(undefined)}>🚮</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<ul>
 | 
			
		||||
				${draft.encrypt_to.map(
 | 
			
		||||
					(x) => html`
 | 
			
		||||
					<li>
 | 
			
		||||
						<tf-user id=${x} .users=${this.users}></tf-user>
 | 
			
		||||
						<input type="button" class="w3-button w3-theme-d1" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter((id) => id != x))}></input>
 | 
			
		||||
						<input type="button" class="w3-button w3-dark-grey" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter((id) => id != x))}></input>
 | 
			
		||||
					</li>`
 | 
			
		||||
				)}
 | 
			
		||||
			</ul>
 | 
			
		||||
@@ -559,37 +507,12 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		this.requestUpdate();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	toggle_menu(event) {
 | 
			
		||||
		event.srcElement.parentNode
 | 
			
		||||
			.querySelector('.w3-dropdown-content')
 | 
			
		||||
			.classList.toggle('w3-show');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	connectedCallback() {
 | 
			
		||||
		super.connectedCallback();
 | 
			
		||||
		this._click_callback = this.document_click.bind(this);
 | 
			
		||||
		document.body.addEventListener('mouseup', this._click_callback);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	disconnectedCallback() {
 | 
			
		||||
		super.disconnectedCallback();
 | 
			
		||||
		document.body.removeEventListener('mouseup', this._click_callback);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	document_click(event) {
 | 
			
		||||
		let content = this.renderRoot.querySelector('.w3-dropdown-content');
 | 
			
		||||
		let target = event.target;
 | 
			
		||||
		if (content && !content.contains(target)) {
 | 
			
		||||
			content.classList.remove('w3-show');
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		let draft = self.get_draft();
 | 
			
		||||
		let content_warning =
 | 
			
		||||
			draft.content_warning !== undefined
 | 
			
		||||
				? html`<div class="w3-panel w3-round-xlarge w3-theme-d2">
 | 
			
		||||
				? html`<div class="w3-panel w3-round-xlarge w3-blue">
 | 
			
		||||
						<p id="content_warning_preview">${draft.content_warning}</p>
 | 
			
		||||
					</div>`
 | 
			
		||||
				: undefined;
 | 
			
		||||
@@ -597,102 +520,56 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			draft.encrypt_to !== undefined
 | 
			
		||||
				? undefined
 | 
			
		||||
				: html`<button
 | 
			
		||||
						class="w3-button w3-bar-item w3-theme-d1"
 | 
			
		||||
						class="w3-button w3-dark-grey"
 | 
			
		||||
						@click=${() => this.set_encrypt([])}
 | 
			
		||||
					>
 | 
			
		||||
						🔐 Encrypt
 | 
			
		||||
						🔐
 | 
			
		||||
					</button>`;
 | 
			
		||||
		let result = html`
 | 
			
		||||
			<style>
 | 
			
		||||
				${generate_theme()}
 | 
			
		||||
			</style>
 | 
			
		||||
			<style>
 | 
			
		||||
				.w3-input:empty::before {
 | 
			
		||||
					content: attr(placeholder);
 | 
			
		||||
				}
 | 
			
		||||
				.w3-input:empty:focus::before {
 | 
			
		||||
					content: '';
 | 
			
		||||
				}
 | 
			
		||||
			</style>
 | 
			
		||||
			<div
 | 
			
		||||
				class="w3-card-4 w3-theme-d4 w3-padding w3-margin-top w3-margin-bottom"
 | 
			
		||||
				class="w3-card-4 w3-blue-grey w3-padding"
 | 
			
		||||
				style="box-sizing: border-box"
 | 
			
		||||
			>
 | 
			
		||||
				<header class="w3-container">
 | 
			
		||||
					${this.channel !== undefined
 | 
			
		||||
						? html`<p>To #${this.channel}:</p>`
 | 
			
		||||
						: undefined}
 | 
			
		||||
					${this.render_encrypt()}
 | 
			
		||||
				</header>
 | 
			
		||||
				<div class="w3-container" style="padding: 0 0 16px 0">
 | 
			
		||||
					<div class="w3-half">
 | 
			
		||||
						<span
 | 
			
		||||
							class="w3-input w3-theme-d1 w3-border"
 | 
			
		||||
							style="resize: vertical; width: 100%; white-space: pre-wrap"
 | 
			
		||||
							placeholder="Write a post here."
 | 
			
		||||
							id="edit"
 | 
			
		||||
							@input=${this.input}
 | 
			
		||||
							@paste=${this.paste}
 | 
			
		||||
							contenteditable="plaintext-only"
 | 
			
		||||
							.innerText=${live(draft.text ?? '')}
 | 
			
		||||
						></span>
 | 
			
		||||
				${this.render_encrypt()}
 | 
			
		||||
				<div style="display: flex; flex-direction: row; width: 100%; gap: 4px">
 | 
			
		||||
					<div style="flex: 1 0 50%">
 | 
			
		||||
						<p>
 | 
			
		||||
							<textarea
 | 
			
		||||
								class="w3-input w3-dark-grey w3-border"
 | 
			
		||||
								style="resize: vertical"
 | 
			
		||||
								placeholder="Write a post here."
 | 
			
		||||
								id="edit"
 | 
			
		||||
								@input=${this.input}
 | 
			
		||||
								@change=${this.change}
 | 
			
		||||
								@paste=${this.paste}
 | 
			
		||||
							>
 | 
			
		||||
${draft.text}</textarea
 | 
			
		||||
							>
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div class="w3-half w3-container">
 | 
			
		||||
					<div style="flex: 1 0 50%">
 | 
			
		||||
						${content_warning}
 | 
			
		||||
						<p id="preview"></p>
 | 
			
		||||
						<div id="preview"></div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				${Object.values(draft.mentions || {}).map((x) =>
 | 
			
		||||
					self.render_mention(x)
 | 
			
		||||
				)}
 | 
			
		||||
				<footer>
 | 
			
		||||
					${this.render_attach_app()} ${this.render_content_warning()}
 | 
			
		||||
					${this.render_new_thread()}
 | 
			
		||||
					<button
 | 
			
		||||
						class="w3-button w3-theme-d1"
 | 
			
		||||
						id="submit"
 | 
			
		||||
						@click=${this.submit}
 | 
			
		||||
					>
 | 
			
		||||
						Submit
 | 
			
		||||
					</button>
 | 
			
		||||
					<div class="w3-dropdown-click">
 | 
			
		||||
						<button class="w3-button w3-theme-d1" @click=${this.toggle_menu}>
 | 
			
		||||
							⚙️
 | 
			
		||||
						</button>
 | 
			
		||||
						<div class="w3-dropdown-content w3-bar-block">
 | 
			
		||||
							${this.get_draft().content_warning === undefined
 | 
			
		||||
								? html`
 | 
			
		||||
										<button
 | 
			
		||||
											class="w3-button w3-bar-item w3-theme-d1"
 | 
			
		||||
											@click=${() => self.set_content_warning('')}
 | 
			
		||||
										>
 | 
			
		||||
											Add Content Warning
 | 
			
		||||
										</button>
 | 
			
		||||
									`
 | 
			
		||||
								: html`
 | 
			
		||||
										<button
 | 
			
		||||
											class="w3-button w3-bar-item w3-theme-d1"
 | 
			
		||||
											@click=${() => self.set_content_warning(undefined)}
 | 
			
		||||
										>
 | 
			
		||||
											Remove Content Warning
 | 
			
		||||
										</button>
 | 
			
		||||
									`}
 | 
			
		||||
							<button
 | 
			
		||||
								class="w3-button w3-bar-item w3-theme-d1"
 | 
			
		||||
								@click=${this.attach}
 | 
			
		||||
							>
 | 
			
		||||
								Attach
 | 
			
		||||
							</button>
 | 
			
		||||
							${this.render_attach_app_button()} ${encrypt}
 | 
			
		||||
							<button
 | 
			
		||||
								class="w3-button w3-bar-item w3-theme-d1"
 | 
			
		||||
								@click=${this.discard}
 | 
			
		||||
							>
 | 
			
		||||
								Discard
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</footer>
 | 
			
		||||
				${this.render_attach_app()} ${this.render_content_warning()}
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-dark-grey"
 | 
			
		||||
					id="submit"
 | 
			
		||||
					@click=${this.submit}
 | 
			
		||||
				>
 | 
			
		||||
					Submit
 | 
			
		||||
				</button>
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${this.attach}>
 | 
			
		||||
					Attach
 | 
			
		||||
				</button>
 | 
			
		||||
				${this.render_attach_app_button()} ${encrypt}
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${this.discard}>
 | 
			
		||||
					Discard
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
		return result;
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										54
									
								
								apps/ssb/tf-id-picker.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								apps/ssb/tf-id-picker.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,54 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 ** Provide a list of IDs, and this lets the user pick one.
 | 
			
		||||
 */
 | 
			
		||||
class TfIdentityPickerElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			ids: {type: Array},
 | 
			
		||||
			selected: {type: String},
 | 
			
		||||
			users: {type: Object},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static styles = styles;
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.ids = [];
 | 
			
		||||
		this.users = {};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	changed(event) {
 | 
			
		||||
		this.selected = event.srcElement.value;
 | 
			
		||||
		this.dispatchEvent(
 | 
			
		||||
			new Event('change', {
 | 
			
		||||
				srcElement: this,
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		return html`
 | 
			
		||||
			<select
 | 
			
		||||
				class="w3-select w3-dark-grey w3-padding w3-border"
 | 
			
		||||
				@change=${this.changed}
 | 
			
		||||
				style="max-width: 100%; overflow: hidden"
 | 
			
		||||
			>
 | 
			
		||||
				${(this.ids ?? []).map(
 | 
			
		||||
					(id) =>
 | 
			
		||||
						html`<option ?selected=${id == this.selected} value=${id}>
 | 
			
		||||
							${this.users[id]?.name
 | 
			
		||||
								? this.users[id]?.name + ' - '
 | 
			
		||||
								: undefined}<small>${id}</small>
 | 
			
		||||
						</option>`
 | 
			
		||||
				)}
 | 
			
		||||
			</select>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-id-picker', TfIdentityPickerElement);
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,6 +1,6 @@
 | 
			
		||||
import {LitElement, html, unsafeHTML, repeat, until} from './lit-all.min.js';
 | 
			
		||||
import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfNewsElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
@@ -11,10 +11,6 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
			following: {type: Array},
 | 
			
		||||
			drafts: {type: Object},
 | 
			
		||||
			expanded: {type: Object},
 | 
			
		||||
			channel: {type: String},
 | 
			
		||||
			channel_unread: {type: Number},
 | 
			
		||||
			recent_reactions: {type: Array},
 | 
			
		||||
			hash: {type: String},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -29,8 +25,6 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
		this.following = [];
 | 
			
		||||
		this.drafts = {};
 | 
			
		||||
		this.expanded = {};
 | 
			
		||||
		this.channel_unread = -1;
 | 
			
		||||
		this.recent_reactions = [];
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	process_messages(messages) {
 | 
			
		||||
@@ -39,13 +33,12 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
		console.log('processing', messages.length, 'messages');
 | 
			
		||||
 | 
			
		||||
		function ensure_message(id, rowid) {
 | 
			
		||||
		function ensure_message(id) {
 | 
			
		||||
			let found = messages_by_id[id];
 | 
			
		||||
			if (found) {
 | 
			
		||||
				return found;
 | 
			
		||||
			} else {
 | 
			
		||||
				let added = {
 | 
			
		||||
					rowid: rowid,
 | 
			
		||||
					id: id,
 | 
			
		||||
					placeholder: true,
 | 
			
		||||
					content: '"placeholder"',
 | 
			
		||||
@@ -60,7 +53,7 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
		function link_message(message) {
 | 
			
		||||
			if (message.content.type === 'vote') {
 | 
			
		||||
				let parent = ensure_message(message.content.vote.link, message.rowid);
 | 
			
		||||
				let parent = ensure_message(message.content.vote.link);
 | 
			
		||||
				if (!parent.votes) {
 | 
			
		||||
					parent.votes = [];
 | 
			
		||||
				}
 | 
			
		||||
@@ -69,14 +62,14 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
			} else if (message.content.type == 'post') {
 | 
			
		||||
				if (message.content.root) {
 | 
			
		||||
					if (typeof message.content.root === 'string') {
 | 
			
		||||
						let m = ensure_message(message.content.root, message.rowid);
 | 
			
		||||
						let m = ensure_message(message.content.root);
 | 
			
		||||
						if (!m.child_messages) {
 | 
			
		||||
							m.child_messages = [];
 | 
			
		||||
						}
 | 
			
		||||
						m.child_messages.push(message);
 | 
			
		||||
						message.parent_message = message.content.root;
 | 
			
		||||
					} else {
 | 
			
		||||
						let m = ensure_message(message.content.root[0], message.rowid);
 | 
			
		||||
						let m = ensure_message(message.content.root[0]);
 | 
			
		||||
						if (!m.child_messages) {
 | 
			
		||||
							m.child_messages = [];
 | 
			
		||||
						}
 | 
			
		||||
@@ -160,120 +153,43 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
		return recursive_sort(roots, true);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	group_messages(messages) {
 | 
			
		||||
	group_following(messages) {
 | 
			
		||||
		let result = [];
 | 
			
		||||
		let group = [];
 | 
			
		||||
		let type = undefined;
 | 
			
		||||
		for (let message of messages) {
 | 
			
		||||
			if (
 | 
			
		||||
				message?.content?.type === 'contact' ||
 | 
			
		||||
				message?.content?.type === 'channel'
 | 
			
		||||
			) {
 | 
			
		||||
				if (type && message.content.type !== type) {
 | 
			
		||||
					if (group.length == 1) {
 | 
			
		||||
						result.push(group[0]);
 | 
			
		||||
						group = [];
 | 
			
		||||
					} else if (group.length > 1) {
 | 
			
		||||
						result.push({
 | 
			
		||||
							rowid: Math.max(...group.map((x) => x.rowid)),
 | 
			
		||||
							type: `${type}_group`,
 | 
			
		||||
							messages: group,
 | 
			
		||||
						});
 | 
			
		||||
						group = [];
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				type = message.content.type;
 | 
			
		||||
			if (message?.content?.type === 'contact') {
 | 
			
		||||
				group.push(message);
 | 
			
		||||
			} else {
 | 
			
		||||
				if (group.length == 1) {
 | 
			
		||||
					result.push(group[0]);
 | 
			
		||||
					group = [];
 | 
			
		||||
				} else if (group.length > 1) {
 | 
			
		||||
				if (group.length > 0) {
 | 
			
		||||
					result.push({
 | 
			
		||||
						rowid: Math.max(...group.map((x) => x.rowid)),
 | 
			
		||||
						type: `${type}_group`,
 | 
			
		||||
						type: 'contact_group',
 | 
			
		||||
						messages: group,
 | 
			
		||||
					});
 | 
			
		||||
					group = [];
 | 
			
		||||
				}
 | 
			
		||||
				result.push(message);
 | 
			
		||||
				type = undefined;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if (group.length == 1) {
 | 
			
		||||
			result.push(group[0]);
 | 
			
		||||
			group = [];
 | 
			
		||||
		} else if (group.length > 1) {
 | 
			
		||||
			result.push({
 | 
			
		||||
				rowid: Math.max(...group.map((x) => x.rowid)),
 | 
			
		||||
				type: `${type}_group`,
 | 
			
		||||
				messages: group,
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
		return result;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	unread_allowed() {
 | 
			
		||||
		return !this.hash?.startsWith('#%') && !this.hash?.startsWith('#@');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	load_and_render(messages) {
 | 
			
		||||
		let messages_by_id = this.process_messages(messages);
 | 
			
		||||
		let final_messages = this.group_messages(
 | 
			
		||||
		let final_messages = this.group_following(
 | 
			
		||||
			this.finalize_messages(messages_by_id)
 | 
			
		||||
		);
 | 
			
		||||
		let unread_rowid = -1;
 | 
			
		||||
		if (this.unread_allowed()) {
 | 
			
		||||
			for (let message of final_messages) {
 | 
			
		||||
				if (message.rowid >= this.channel_unread) {
 | 
			
		||||
					unread_rowid = message.rowid;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return html`
 | 
			
		||||
			<style>
 | 
			
		||||
				${generate_theme()}
 | 
			
		||||
			</style>
 | 
			
		||||
			<div>
 | 
			
		||||
				${repeat(
 | 
			
		||||
					final_messages,
 | 
			
		||||
					(x) => x.id,
 | 
			
		||||
					(x) => html`
 | 
			
		||||
						<tf-message
 | 
			
		||||
			<div style="display: flex; flex-direction: column">
 | 
			
		||||
				${final_messages.map(
 | 
			
		||||
					(x) =>
 | 
			
		||||
						html`<tf-message
 | 
			
		||||
							.message=${x}
 | 
			
		||||
							whoami=${this.whoami}
 | 
			
		||||
							.users=${this.users}
 | 
			
		||||
							.drafts=${this.drafts}
 | 
			
		||||
							.expanded=${this.expanded}
 | 
			
		||||
							collapsed="true"
 | 
			
		||||
							channel=${this.channel}
 | 
			
		||||
							channel_unread=${this.channel_unread}
 | 
			
		||||
							.recent_reactions=${this.recent_reactions}
 | 
			
		||||
						></tf-message>
 | 
			
		||||
						${x.rowid == unread_rowid
 | 
			
		||||
							? html`<div style="display: flex; flex-direction: row">
 | 
			
		||||
									<div
 | 
			
		||||
										style="border-bottom: 1px solid #f00; flex: 1; align-self: center; height: 1px"
 | 
			
		||||
									></div>
 | 
			
		||||
									<button
 | 
			
		||||
										style="color: #f00; padding: 8px"
 | 
			
		||||
										class="w3-button"
 | 
			
		||||
										@click=${() =>
 | 
			
		||||
											this.dispatchEvent(
 | 
			
		||||
												new Event('mark_all_read', {
 | 
			
		||||
													bubbles: true,
 | 
			
		||||
													composed: true,
 | 
			
		||||
												})
 | 
			
		||||
											)}
 | 
			
		||||
									>
 | 
			
		||||
										unread
 | 
			
		||||
									</button>
 | 
			
		||||
									<div
 | 
			
		||||
										style="border-bottom: 1px solid #f00; flex: 1; align-self: center; height: 1px"
 | 
			
		||||
									></div>
 | 
			
		||||
								</div>`
 | 
			
		||||
							: undefined}
 | 
			
		||||
					`
 | 
			
		||||
						></tf-message>`
 | 
			
		||||
				)}
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import {LitElement, html, until, unsafeHTML} from './lit-all.min.js';
 | 
			
		||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import * as tfutils from './tf-utils.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfProfileElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
@@ -11,10 +11,9 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
			id: {type: String},
 | 
			
		||||
			users: {type: Object},
 | 
			
		||||
			size: {type: Number},
 | 
			
		||||
			sequence: {type: Number},
 | 
			
		||||
			server_follows_me: {type: Boolean},
 | 
			
		||||
			following: {type: Boolean},
 | 
			
		||||
			blocking: {type: Boolean},
 | 
			
		||||
			show_followed: {type: Boolean},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -28,7 +27,7 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
		this.id = null;
 | 
			
		||||
		this.users = {};
 | 
			
		||||
		this.size = 0;
 | 
			
		||||
		this.sequence = 0;
 | 
			
		||||
		this.server_follows_me = undefined;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load() {
 | 
			
		||||
@@ -64,8 +63,27 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async initial_load() {
 | 
			
		||||
		this.server_follows_me = undefined;
 | 
			
		||||
		let server_id = await tfrpc.rpc.getServerIdentity();
 | 
			
		||||
		let followed = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
			SELECT json_extract(content, '$.following') AS following
 | 
			
		||||
			FROM messages
 | 
			
		||||
			WHERE author = ? AND
 | 
			
		||||
			json_extract(content, '$.type') = 'contact' AND
 | 
			
		||||
			json_extract(content, '$.contact') = ? ORDER BY sequence DESC LIMIT 1
 | 
			
		||||
		`,
 | 
			
		||||
			[server_id, this.whoami]
 | 
			
		||||
		);
 | 
			
		||||
		let is_followed = false;
 | 
			
		||||
		for (let row of followed) {
 | 
			
		||||
			is_followed = row.following != 0;
 | 
			
		||||
		}
 | 
			
		||||
		this.server_follows_me = is_followed;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	modify(change) {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		tfrpc.rpc
 | 
			
		||||
			.appendMessage(
 | 
			
		||||
				this.whoami,
 | 
			
		||||
@@ -77,10 +95,6 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
					change
 | 
			
		||||
				)
 | 
			
		||||
			)
 | 
			
		||||
			.then(function () {
 | 
			
		||||
				self._follow_whoami = undefined;
 | 
			
		||||
				self.load();
 | 
			
		||||
			})
 | 
			
		||||
			.catch(function (error) {
 | 
			
		||||
				alert(error?.message);
 | 
			
		||||
			});
 | 
			
		||||
@@ -142,8 +156,7 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		let input = document.createElement('input');
 | 
			
		||||
		input.type = 'file';
 | 
			
		||||
		input.addEventListener('change', function (event) {
 | 
			
		||||
			input.parentNode.removeChild(input);
 | 
			
		||||
		input.onchange = function (event) {
 | 
			
		||||
			let file = event.target.files[0];
 | 
			
		||||
			file
 | 
			
		||||
				.arrayBuffer()
 | 
			
		||||
@@ -158,207 +171,140 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
				.catch(function (e) {
 | 
			
		||||
					alert(e.message);
 | 
			
		||||
				});
 | 
			
		||||
		});
 | 
			
		||||
		document.body.appendChild(input);
 | 
			
		||||
		};
 | 
			
		||||
		input.click();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	copy_id() {
 | 
			
		||||
		navigator.clipboard.writeText(this.id);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	show_image(link) {
 | 
			
		||||
		let div = document.createElement('div');
 | 
			
		||||
		div.style.left = 0;
 | 
			
		||||
		div.style.top = 0;
 | 
			
		||||
		div.style.width = '100%';
 | 
			
		||||
		div.style.height = '100%';
 | 
			
		||||
		div.style.position = 'fixed';
 | 
			
		||||
		div.style.background = '#000';
 | 
			
		||||
		div.style.zIndex = 100;
 | 
			
		||||
		div.style.display = 'grid';
 | 
			
		||||
		let img = document.createElement('img');
 | 
			
		||||
		img.src = link;
 | 
			
		||||
		img.style.maxWidth = '100vw';
 | 
			
		||||
		img.style.maxHeight = '100vh';
 | 
			
		||||
		img.style.display = 'block';
 | 
			
		||||
		img.style.margin = 'auto';
 | 
			
		||||
		img.style.objectFit = 'contain';
 | 
			
		||||
		img.style.width = '100vw';
 | 
			
		||||
		div.appendChild(img);
 | 
			
		||||
		function image_close(event) {
 | 
			
		||||
			document.body.removeChild(div);
 | 
			
		||||
			window.removeEventListener('keydown', image_close);
 | 
			
		||||
	async server_follow_me(follow) {
 | 
			
		||||
		try {
 | 
			
		||||
			await tfrpc.rpc.setServerFollowingMe(this.whoami, follow);
 | 
			
		||||
		} catch (e) {
 | 
			
		||||
			console.log(e);
 | 
			
		||||
		}
 | 
			
		||||
		div.onclick = image_close;
 | 
			
		||||
		window.addEventListener('keydown', image_close);
 | 
			
		||||
		document.body.appendChild(div);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	body_click(event) {
 | 
			
		||||
		if (event.srcElement.tagName == 'IMG') {
 | 
			
		||||
			this.show_image(event.srcElement.src);
 | 
			
		||||
		try {
 | 
			
		||||
			await this.initial_load();
 | 
			
		||||
		} catch (e) {
 | 
			
		||||
			console.log(e);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	toggle_account_list(event) {
 | 
			
		||||
		let content = event.srcElement.nextElementSibling;
 | 
			
		||||
		this.show_followed = !this.show_followed;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load_follows() {
 | 
			
		||||
		let accounts = await tfrpc.rpc.following([this.id], 1);
 | 
			
		||||
		return html`
 | 
			
		||||
			<div class="w3-container">
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-block w3-theme-d1 followed_accounts"
 | 
			
		||||
					@click=${this.toggle_account_list}
 | 
			
		||||
				>
 | 
			
		||||
					${this.show_followed ? 'Hide' : 'Show'} Followed Accounts
 | 
			
		||||
					(${Object.keys(accounts).length})
 | 
			
		||||
				</button>
 | 
			
		||||
				<div class=${'w3-card' + (this.show_followed ? '' : ' w3-hide')}>
 | 
			
		||||
					<ul class="w3-ul w3-theme-d4 w3-border-theme">
 | 
			
		||||
						${Object.keys(accounts).map(
 | 
			
		||||
							(x) => html`
 | 
			
		||||
								<li class="w3-border-theme">
 | 
			
		||||
									<tf-user id=${x} .users=${this.users}></tf-user>
 | 
			
		||||
								</li>
 | 
			
		||||
							`
 | 
			
		||||
						)}
 | 
			
		||||
					</ul>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		if (
 | 
			
		||||
			this.id == this.whoami &&
 | 
			
		||||
			this.editing &&
 | 
			
		||||
			this.server_follows_me === undefined
 | 
			
		||||
		) {
 | 
			
		||||
			this.initial_load();
 | 
			
		||||
		}
 | 
			
		||||
		this.load();
 | 
			
		||||
		let self = this;
 | 
			
		||||
		let profile = this.users[this.id] || {};
 | 
			
		||||
		tfrpc.rpc
 | 
			
		||||
			.query(
 | 
			
		||||
				`SELECT SUM(LENGTH(content)) AS size, MAX(sequence) AS sequence FROM messages WHERE author = ?`,
 | 
			
		||||
				`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`,
 | 
			
		||||
				[this.id]
 | 
			
		||||
			)
 | 
			
		||||
			.then(function (result) {
 | 
			
		||||
				self.size = result[0].size;
 | 
			
		||||
				self.sequence = result[0].sequence;
 | 
			
		||||
			});
 | 
			
		||||
		let edit;
 | 
			
		||||
		let follow;
 | 
			
		||||
		let block;
 | 
			
		||||
		if (this.id === this.whoami) {
 | 
			
		||||
			if (this.editing) {
 | 
			
		||||
				edit = html`
 | 
			
		||||
					<button
 | 
			
		||||
						id="save_profile"
 | 
			
		||||
						class="w3-button w3-theme-d1"
 | 
			
		||||
						@click=${this.save_edits}
 | 
			
		||||
				let server_follow;
 | 
			
		||||
				if (this.server_follows_me === true) {
 | 
			
		||||
					server_follow = html`<button
 | 
			
		||||
						class="w3-button w3-dark-grey"
 | 
			
		||||
						@click=${() => this.server_follow_me(false)}
 | 
			
		||||
					>
 | 
			
		||||
						Server, Stop Following Me
 | 
			
		||||
					</button>`;
 | 
			
		||||
				} else if (this.server_follows_me === false) {
 | 
			
		||||
					server_follow = html`<button
 | 
			
		||||
						class="w3-button w3-dark-grey"
 | 
			
		||||
						@click=${() => this.server_follow_me(true)}
 | 
			
		||||
					>
 | 
			
		||||
						Server, Follow Me
 | 
			
		||||
					</button>`;
 | 
			
		||||
				}
 | 
			
		||||
				edit = html`
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.save_edits}>
 | 
			
		||||
						Save Profile
 | 
			
		||||
					</button>
 | 
			
		||||
					<button class="w3-button w3-theme-d1" @click=${this.discard_edits}>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.discard_edits}>
 | 
			
		||||
						Discard
 | 
			
		||||
					</button>
 | 
			
		||||
					${server_follow}
 | 
			
		||||
				`;
 | 
			
		||||
			} else {
 | 
			
		||||
				edit = html`<button
 | 
			
		||||
					id="edit_profile"
 | 
			
		||||
					class="w3-button w3-theme-d1"
 | 
			
		||||
					@click=${this.edit}
 | 
			
		||||
				>
 | 
			
		||||
				edit = html`<button class="w3-button w3-dark-grey" @click=${this.edit}>
 | 
			
		||||
					Edit Profile
 | 
			
		||||
				</button>`;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if (this.id !== this.whoami && this.following !== undefined) {
 | 
			
		||||
			follow = this.following
 | 
			
		||||
				? html`<button class="w3-button w3-theme-d1" @click=${this.unfollow}>
 | 
			
		||||
				? html`<button class="w3-button w3-dark-grey" @click=${this.unfollow}>
 | 
			
		||||
						Unfollow
 | 
			
		||||
					</button>`
 | 
			
		||||
				: html`<button class="w3-button w3-theme-d1" @click=${this.follow}>
 | 
			
		||||
				: html`<button class="w3-button w3-dark-grey" @click=${this.follow}>
 | 
			
		||||
						Follow
 | 
			
		||||
					</button>`;
 | 
			
		||||
		}
 | 
			
		||||
		if (this.id !== this.whoami && this.blocking !== undefined) {
 | 
			
		||||
			block = this.blocking
 | 
			
		||||
				? html`<button class="w3-button w3-theme-d1" @click=${this.unblock}>
 | 
			
		||||
				? html`<button class="w3-button w3-dark-grey" @click=${this.unblock}>
 | 
			
		||||
						Unblock
 | 
			
		||||
					</button>`
 | 
			
		||||
				: html`<button class="w3-button w3-theme-d1" @click=${this.block}>
 | 
			
		||||
				: html`<button class="w3-button w3-dark-grey" @click=${this.block}>
 | 
			
		||||
						Block
 | 
			
		||||
					</button>`;
 | 
			
		||||
		}
 | 
			
		||||
		let edit_profile = this.editing
 | 
			
		||||
			? html`
 | 
			
		||||
			<div style="flex: 1 0 50%; display: flex; flex-direction: column; gap: 8px">
 | 
			
		||||
				<div>
 | 
			
		||||
					<label for="name">Name:</label>
 | 
			
		||||
					<input class="w3-input w3-theme-d1" type="text" id="name" value=${this.editing.name} @input=${(event) => (this.editing = Object.assign({}, this.editing, {name: event.srcElement.value}))} placeholder="Choose a name"></input>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div><label for="description">Description:</label></div>
 | 
			
		||||
				<textarea class="w3-input w3-theme-d1" style="resize: vertical" rows="8" id="description" @input=${(event) => (this.editing = Object.assign({}, this.editing, {description: event.srcElement.value}))} placeholder="Tell people a little bit about yourself here, if you like.">${this.editing.description}</textarea>
 | 
			
		||||
				<div>
 | 
			
		||||
					<label for="public_web_hosting">Public Web Hosting:</label>
 | 
			
		||||
					<input class="w3-check w3-theme-d1" type="checkbox" id="public_web_hosting" ?checked=${this.editing.publicWebHosting} @input=${(event) => (self.editing = Object.assign({}, self.editing, {publicWebHosting: event.srcElement.checked}))}></input>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div>
 | 
			
		||||
					<button class="w3-button w3-theme-d1" @click=${this.attach_image}>Attach Image</button>
 | 
			
		||||
				<div class="w3-container">
 | 
			
		||||
					<div>
 | 
			
		||||
						<label for="name">Name:</label>
 | 
			
		||||
						<input class="w3-input w3-dark-grey" type="text" id="name" value=${this.editing.name} @input=${(event) => (this.editing = Object.assign({}, this.editing, {name: event.srcElement.value}))}></input>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div><label for="description">Description:</label></div>
 | 
			
		||||
					<textarea class="w3-input w3-dark-grey" style="resize: vertical" rows="8" id="description" @input=${(event) => (this.editing = Object.assign({}, this.editing, {description: event.srcElement.value}))}>${this.editing.description}</textarea>
 | 
			
		||||
					<div>
 | 
			
		||||
						<label for="public_web_hosting">Public Web Hosting:</label>
 | 
			
		||||
						<input class="w3-check w3-dark-grey" type="checkbox" id="public_web_hosting" ?checked=${this.editing.publicWebHosting} @input=${(event) => (self.editing = Object.assign({}, self.editing, {publicWebHosting: event.srcElement.checked}))}></input>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div>
 | 
			
		||||
						<button class="w3-button w3-dark-grey" @click=${this.attach_image}>Attach Image</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>`
 | 
			
		||||
			: null;
 | 
			
		||||
		let image = profile.image;
 | 
			
		||||
		if (typeof image == 'string' && !image.startsWith('&')) {
 | 
			
		||||
			try {
 | 
			
		||||
				image = JSON.parse(image)?.link;
 | 
			
		||||
			} catch {}
 | 
			
		||||
		}
 | 
			
		||||
		let image =
 | 
			
		||||
			typeof profile.image == 'string' ? profile.image : profile.image?.link;
 | 
			
		||||
		image = this.editing?.image ?? image;
 | 
			
		||||
		let description = this.editing?.description ?? profile.description;
 | 
			
		||||
		return html`
 | 
			
		||||
			<style>${generate_theme()}</style>
 | 
			
		||||
			<div class="w3-card-4 w3-container w3-theme-d3" style="box-sizing: border-box">
 | 
			
		||||
			<header class="w3-container">
 | 
			
		||||
				<p><tf-user id=${this.id} .users=${this.users}></tf-user> (${tfutils.human_readable_size(this.size)} in ${this.sequence} messages)</p>
 | 
			
		||||
			</header>
 | 
			
		||||
			<div class="w3-container" @click=${this.body_click}>
 | 
			
		||||
				<div class="w3-margin-bottom" style="display: flex; flex-direction: row">
 | 
			
		||||
					<input type="text" class="w3-input w3-border w3-theme-d1" style="display: flex 1 1" readonly value=${this.id}></input>
 | 
			
		||||
					<button class="w3-button w3-theme-d1 w3-ripple" style="flex: 0 0 auto" @click=${this.copy_id}>Copy</button>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div style="display: flex; flex-direction: row; gap: 1em">
 | 
			
		||||
					${edit_profile}
 | 
			
		||||
					<div style="flex: 1 0 50%; contain: layout; overflow: auto; word-wrap: normal; word-break: normal">
 | 
			
		||||
						${
 | 
			
		||||
							image
 | 
			
		||||
								? html`<div><img src=${'/' + image + '/view'} style="width: 256px; height: auto"></img></div>`
 | 
			
		||||
								: html`<div>
 | 
			
		||||
										<div class="w3-jumbo">😎</div>
 | 
			
		||||
										<div><i>Profile image not set.</i></div>
 | 
			
		||||
									</div>`
 | 
			
		||||
						}
 | 
			
		||||
						<div>${unsafeHTML(tfutils.markdown(description))}</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div>
 | 
			
		||||
					Following ${profile.following} identities.
 | 
			
		||||
					Followed by ${profile.followed} identities.
 | 
			
		||||
					Blocking ${profile.blocking} identities.
 | 
			
		||||
					Blocked by ${profile.blocked} identities.
 | 
			
		||||
		return html`<div style="border: 2px solid black; background-color: rgba(255, 255, 255, 0.2); padding: 16px">
 | 
			
		||||
			<tf-user id=${this.id} .users=${this.users}></tf-user> (${tfutils.human_readable_size(this.size)})
 | 
			
		||||
			<div style="display: flex; flex-direction: row; gap: 1em">
 | 
			
		||||
				${edit_profile}
 | 
			
		||||
				<div style="flex: 1 0 50%">
 | 
			
		||||
					<div><img src=${'/' + image + '/view'} style="width: 256px; height: auto"></img></div>
 | 
			
		||||
					<div>${unsafeHTML(tfutils.markdown(description))}</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
			${until(this.load_follows(), html`<p>Loading accounts followed...</p>`)}
 | 
			
		||||
			<footer class="w3-container">
 | 
			
		||||
				<p>
 | 
			
		||||
					<a class="w3-button w3-theme-d1" href=${'#🔐' + (this.id != this.whoami ? this.id : '')}>
 | 
			
		||||
						Open Private Chat
 | 
			
		||||
					</a>
 | 
			
		||||
					${edit}
 | 
			
		||||
					${follow}
 | 
			
		||||
					${block}
 | 
			
		||||
				</p>
 | 
			
		||||
			</footer>
 | 
			
		||||
			<div>
 | 
			
		||||
				Following ${profile.following} identities.
 | 
			
		||||
				Followed by ${profile.followed} identities.
 | 
			
		||||
				Blocking ${profile.blocking} identities.
 | 
			
		||||
				Blocked by ${profile.blocked} identities.
 | 
			
		||||
			</div>
 | 
			
		||||
			<div>
 | 
			
		||||
				${edit}
 | 
			
		||||
				${follow}
 | 
			
		||||
				${block}
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,82 +0,0 @@
 | 
			
		||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfReactionsModalElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			users: {type: Object},
 | 
			
		||||
			votes: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static styles = styles;
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.votes = [];
 | 
			
		||||
		this.users = {};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	clear() {
 | 
			
		||||
		this.votes = [];
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return this.votes?.length
 | 
			
		||||
			? html` <style>
 | 
			
		||||
						${generate_theme()}
 | 
			
		||||
					</style>
 | 
			
		||||
					<div
 | 
			
		||||
						class="w3-modal w3-animate-opacity"
 | 
			
		||||
						style="display: block; box-sizing: border-box; z-index: 10"
 | 
			
		||||
						@click=${this.clear}
 | 
			
		||||
					>
 | 
			
		||||
						<div
 | 
			
		||||
							class="w3-modal-content w3-card-4 w3-theme-d1"
 | 
			
		||||
							onclick="event.stopPropagation()"
 | 
			
		||||
						>
 | 
			
		||||
							<div class="w3-container w3-padding">
 | 
			
		||||
								<header class="w3-container">
 | 
			
		||||
									<h2>Reactions</h2>
 | 
			
		||||
									<span
 | 
			
		||||
										class="w3-button w3-display-topright"
 | 
			
		||||
										@click=${this.clear}
 | 
			
		||||
										>×</span
 | 
			
		||||
									>
 | 
			
		||||
								</header>
 | 
			
		||||
								<ul class="w3-theme-dark w3-container w3-ul">
 | 
			
		||||
									${this.votes
 | 
			
		||||
										.sort((x, y) => y.timestamp - x.timestamp)
 | 
			
		||||
										.map(
 | 
			
		||||
											(x) => html`
 | 
			
		||||
												<li
 | 
			
		||||
													style="display: flex; flex-direction: row; gap: 4px"
 | 
			
		||||
												>
 | 
			
		||||
													<span style="flex-basis: 3em"
 | 
			
		||||
														>${x?.content?.vote?.expression}</span
 | 
			
		||||
													>
 | 
			
		||||
													<tf-user
 | 
			
		||||
														style="flex: 1 1; overflow: hidden"
 | 
			
		||||
														id=${x.author}
 | 
			
		||||
														.users=${this.users}
 | 
			
		||||
													></tf-user>
 | 
			
		||||
													<span
 | 
			
		||||
														style="flex-shrink: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis"
 | 
			
		||||
														>${new Date(x?.timestamp).toLocaleString()}</span
 | 
			
		||||
													>
 | 
			
		||||
												</li>
 | 
			
		||||
											`
 | 
			
		||||
										)}
 | 
			
		||||
								</ul>
 | 
			
		||||
								<footer class="w3-container w3-padding">
 | 
			
		||||
									<button class="w3-button" @click=${this.clear}>Close</button>
 | 
			
		||||
								</footer>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>`
 | 
			
		||||
			: undefined;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-reactions-modal', TfReactionsModalElement);
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,76 +1,41 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfTabConnectionsElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			broadcasts: {type: Array},
 | 
			
		||||
			identities: {type: Array},
 | 
			
		||||
			my_identities: {type: Array},
 | 
			
		||||
			connections: {type: Array},
 | 
			
		||||
			stored_connections: {type: Array},
 | 
			
		||||
			users: {type: Object},
 | 
			
		||||
			server_identity: {type: String},
 | 
			
		||||
			connect_attempt: {type: Object},
 | 
			
		||||
			connect_message: {type: String},
 | 
			
		||||
			connect_success: {type: Boolean},
 | 
			
		||||
			peer_exchange: {type: Boolean},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static styles = styles;
 | 
			
		||||
 | 
			
		||||
	static k_broadcast_emojis = {
 | 
			
		||||
		discovery: '🏓',
 | 
			
		||||
		room: '🚪',
 | 
			
		||||
		peer_exchange: '🕸',
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		let self = this;
 | 
			
		||||
		this.broadcasts = [];
 | 
			
		||||
		this.identities = [];
 | 
			
		||||
		this.my_identities = [];
 | 
			
		||||
		this.connections = [];
 | 
			
		||||
		this.stored_connections = [];
 | 
			
		||||
		this.users = {};
 | 
			
		||||
		tfrpc.rpc.getIdentities().then(function (identities) {
 | 
			
		||||
			self.my_identities = identities || [];
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.rpc.getAllIdentities().then(function (identities) {
 | 
			
		||||
			self.identities = identities || [];
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.rpc.getStoredConnections().then(function (connections) {
 | 
			
		||||
			self.stored_connections = connections || [];
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.rpc.getServerIdentity().then(function (identity) {
 | 
			
		||||
			self.server_identity = identity;
 | 
			
		||||
		});
 | 
			
		||||
		this.check_peer_exchange();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async check_peer_exchange() {
 | 
			
		||||
		if (await tfrpc.rpc.isAdministrator()) {
 | 
			
		||||
			this.peer_exchange = await tfrpc.rpc.globalSettingsGet('peer_exchange');
 | 
			
		||||
		} else {
 | 
			
		||||
			this.peer_exchange = undefined;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async enable_peer_exchange() {
 | 
			
		||||
		await tfrpc.rpc.globalSettingsSet('peer_exchange', true);
 | 
			
		||||
		await this.check_peer_exchange();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_connection_summary(connection) {
 | 
			
		||||
		if (connection.address && connection.port) {
 | 
			
		||||
			return html`<div>
 | 
			
		||||
				<small>${connection.address}:${connection.port}</small>
 | 
			
		||||
			</div>`;
 | 
			
		||||
			return html`(<small>${connection.address}:${connection.port}</small>)`;
 | 
			
		||||
		} else if (connection.tunnel) {
 | 
			
		||||
			return html`<div>room peer</div>`;
 | 
			
		||||
			return html`(room peer)`;
 | 
			
		||||
		} else {
 | 
			
		||||
			return JSON.stringify(connection);
 | 
			
		||||
		}
 | 
			
		||||
@@ -96,7 +61,7 @@ class TfTabConnectionsElement extends LitElement {
 | 
			
		||||
		return html`
 | 
			
		||||
			<li>
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-theme-d1"
 | 
			
		||||
					class="w3-button w3-dark-grey"
 | 
			
		||||
					@click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)}
 | 
			
		||||
				>
 | 
			
		||||
					Connect
 | 
			
		||||
@@ -106,53 +71,17 @@ class TfTabConnectionsElement extends LitElement {
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_message(connection) {
 | 
			
		||||
		return html`<div
 | 
			
		||||
			?hidden=${this.connect_message === undefined ||
 | 
			
		||||
			this.connect_attempt != connection}
 | 
			
		||||
			style="cursor: pointer"
 | 
			
		||||
			class=${'w3-panel ' + (this.connect_success ? 'w3-green' : 'w3-red')}
 | 
			
		||||
			@click=${() => (this.connect_attempt = undefined)}
 | 
			
		||||
		>
 | 
			
		||||
			<p>${this.connect_message}</p>
 | 
			
		||||
		</div>`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_progress(name, value, max) {
 | 
			
		||||
		if (max && value != max) {
 | 
			
		||||
			return html`
 | 
			
		||||
				<div class="w3-theme-d1 w3-small">
 | 
			
		||||
					<div
 | 
			
		||||
						class="w3-container w3-theme-l1"
 | 
			
		||||
						style="width: ${Math.floor(
 | 
			
		||||
							(100.0 * value) / max
 | 
			
		||||
						)}%; text-wrap: nowrap"
 | 
			
		||||
					>
 | 
			
		||||
						${name} ${value} / ${max} (${Math.round((100.0 * value) / max)}%)
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_broadcast(connection) {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return html`
 | 
			
		||||
			<li>
 | 
			
		||||
				<div class="w3-bar" style="overflow: hidden; overflow-wrap: nowrap">
 | 
			
		||||
					<button
 | 
			
		||||
						class="w3-bar-item w3-button w3-theme-d1"
 | 
			
		||||
						@click=${() => self.connect(connection)}
 | 
			
		||||
					>
 | 
			
		||||
						Connect
 | 
			
		||||
					</button>
 | 
			
		||||
					<div class="w3-bar-item">
 | 
			
		||||
						${TfTabConnectionsElement.k_broadcast_emojis[connection.origin]}
 | 
			
		||||
						<tf-user id=${connection.pubkey} .users=${this.users}></tf-user>
 | 
			
		||||
						${this.render_connection_summary(connection)}
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				${this.render_message(connection)}
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-dark-grey"
 | 
			
		||||
					@click=${() => tfrpc.rpc.connect(connection)}
 | 
			
		||||
				>
 | 
			
		||||
					Connect
 | 
			
		||||
				</button>
 | 
			
		||||
				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user>
 | 
			
		||||
				${this.render_connection_summary(connection)}
 | 
			
		||||
			</li>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
@@ -163,225 +92,81 @@ class TfTabConnectionsElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_connection(connection) {
 | 
			
		||||
		let requests = Object.values(
 | 
			
		||||
			connection.requests.reduce(function (accumulator, value) {
 | 
			
		||||
				let key = `${value.name}:${Math.sign(value.request_number)}`;
 | 
			
		||||
				if (!accumulator[key]) {
 | 
			
		||||
					accumulator[key] = Object.assign({count: 0}, value);
 | 
			
		||||
				}
 | 
			
		||||
				accumulator[key].count++;
 | 
			
		||||
				return accumulator;
 | 
			
		||||
			}, {})
 | 
			
		||||
		);
 | 
			
		||||
		return html`
 | 
			
		||||
			${connection.connected
 | 
			
		||||
				? html`
 | 
			
		||||
						<button
 | 
			
		||||
							class="w3-button w3-theme-d1"
 | 
			
		||||
							@click=${() => tfrpc.rpc.closeConnection(connection.id)}
 | 
			
		||||
						>
 | 
			
		||||
							Close
 | 
			
		||||
						</button>
 | 
			
		||||
					`
 | 
			
		||||
				: undefined}
 | 
			
		||||
			${connection.flags.one_shot ? '🔃' : undefined}
 | 
			
		||||
			<button
 | 
			
		||||
				class="w3-button w3-dark-grey"
 | 
			
		||||
				@click=${() => tfrpc.rpc.closeConnection(connection.id)}
 | 
			
		||||
			>
 | 
			
		||||
				Close
 | 
			
		||||
			</button>
 | 
			
		||||
			<tf-user id=${connection.id} .users=${this.users}></tf-user>
 | 
			
		||||
			${this.render_progress(
 | 
			
		||||
				'recv',
 | 
			
		||||
				connection.progress.in.total - connection.progress.in.current,
 | 
			
		||||
				connection.progress.in.total
 | 
			
		||||
			)}
 | 
			
		||||
			${this.render_progress(
 | 
			
		||||
				'send',
 | 
			
		||||
				connection.progress.out.total - connection.progress.out.current,
 | 
			
		||||
				connection.progress.out.total
 | 
			
		||||
			)}
 | 
			
		||||
			${connection.tunnel !== undefined
 | 
			
		||||
				? '🚇'
 | 
			
		||||
				: html`(${connection.host}:${connection.port})`}
 | 
			
		||||
			<div>
 | 
			
		||||
				${requests.map(
 | 
			
		||||
					(x) => html`
 | 
			
		||||
						<span
 | 
			
		||||
							class=${'w3-tag w3-small ' +
 | 
			
		||||
							(x.active ? 'w3-theme-l3' : 'w3-theme-d3')}
 | 
			
		||||
							>${x.request_number > 0 ? '🟩' : '🟥'} ${x.name}
 | 
			
		||||
							<span
 | 
			
		||||
								class="w3-badge w3-white"
 | 
			
		||||
								style=${x.count > 1 ? undefined : 'display: none'}
 | 
			
		||||
								>${x.count}</span
 | 
			
		||||
							></span
 | 
			
		||||
						>
 | 
			
		||||
					`
 | 
			
		||||
				)}
 | 
			
		||||
			</div>
 | 
			
		||||
			<ul>
 | 
			
		||||
				${this.connections
 | 
			
		||||
					.filter((x) => x.tunnel === this.connections.indexOf(connection))
 | 
			
		||||
					.map((x) => html`<li>${this.render_connection(x)}</li>`)}
 | 
			
		||||
				${this.render_room_peers(connection.id)}
 | 
			
		||||
			</ul>
 | 
			
		||||
			<div ?hidden=${!connection.destroy_reason} class="w3-panel w3-red">
 | 
			
		||||
				<p>${connection.destroy_reason}</p>
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	connect(address) {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		self.connect_attempt = address;
 | 
			
		||||
		self.connect_message = undefined;
 | 
			
		||||
		self.connect_success = false;
 | 
			
		||||
		tfrpc.rpc
 | 
			
		||||
			.connect(address)
 | 
			
		||||
			.then(function () {
 | 
			
		||||
				if (self.connect_attempt == address) {
 | 
			
		||||
					self.connect_message = 'Connected.';
 | 
			
		||||
					self.connect_success = true;
 | 
			
		||||
				}
 | 
			
		||||
			})
 | 
			
		||||
			.catch(function (error) {
 | 
			
		||||
				if (self.connect_attempt == address) {
 | 
			
		||||
					self.connect_message = 'Error: ' + error;
 | 
			
		||||
					self.connect_success = false;
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	toggle_accordian(id) {
 | 
			
		||||
		let element = this.renderRoot.getElementById(id);
 | 
			
		||||
		element.classList.toggle('w3-hide');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	valid_connections() {
 | 
			
		||||
		return this.connections.filter((x) => x.tunnel === undefined);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	valid_broadcasts() {
 | 
			
		||||
		return this.broadcasts
 | 
			
		||||
			.filter((x) => x.address)
 | 
			
		||||
			.filter((x) => this.connections.map((c) => c.id).indexOf(x.pubkey) == -1);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return html`
 | 
			
		||||
			<style>
 | 
			
		||||
				${generate_theme()}
 | 
			
		||||
			</style>
 | 
			
		||||
			<div class="w3-container" style="box-sizing: border-box">
 | 
			
		||||
				<div
 | 
			
		||||
					class=${'w3-panel w3-padding w3-theme-l3' +
 | 
			
		||||
					(this.peer_exchange !== false ? ' w3-hide' : '')}
 | 
			
		||||
				>
 | 
			
		||||
					<p>
 | 
			
		||||
						Looking for connections? Enabling this option will include publicly
 | 
			
		||||
						advertised rooms and pubs among the list of discovered connections
 | 
			
		||||
						to help you replicate.
 | 
			
		||||
					</p>
 | 
			
		||||
					<button
 | 
			
		||||
						class="w3-button w3-theme-d1"
 | 
			
		||||
						@click=${this.enable_peer_exchange}
 | 
			
		||||
					>
 | 
			
		||||
						🔍🌐 Use publicly advertised peers
 | 
			
		||||
					</button>
 | 
			
		||||
				</div>
 | 
			
		||||
			<div class="w3-container">
 | 
			
		||||
				<h2>New Connection</h2>
 | 
			
		||||
				<textarea class="w3-input w3-theme-d1" id="code"></textarea>
 | 
			
		||||
				${this.render_message(this.renderRoot.getElementById('code')?.value)}
 | 
			
		||||
				<textarea class="w3-input w3-dark-grey" id="code"></textarea>
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-theme-d1"
 | 
			
		||||
					class="w3-button w3-dark-grey"
 | 
			
		||||
					@click=${() =>
 | 
			
		||||
						self.connect(self.renderRoot.getElementById('code')?.value)}
 | 
			
		||||
						tfrpc.rpc.connect(self.renderRoot.getElementById('code').value)}
 | 
			
		||||
				>
 | 
			
		||||
					Connect
 | 
			
		||||
				</button>
 | 
			
		||||
				<h2
 | 
			
		||||
					class="w3-button w3-block w3-theme-d1"
 | 
			
		||||
					@click=${() => self.toggle_accordian('connections')}
 | 
			
		||||
				>
 | 
			
		||||
					Connections (${this.valid_connections().length})
 | 
			
		||||
				</h2>
 | 
			
		||||
				<ul class="w3-ul w3-border" id="connections">
 | 
			
		||||
					${this.valid_connections().map(
 | 
			
		||||
						(x) => html` <li class="w3-bar">${this.render_connection(x)}</li> `
 | 
			
		||||
					)}
 | 
			
		||||
				<h2>Broadcasts</h2>
 | 
			
		||||
				<ul>
 | 
			
		||||
					${this.broadcasts
 | 
			
		||||
						.filter((x) => x.address)
 | 
			
		||||
						.map((x) => self.render_broadcast(x))}
 | 
			
		||||
				</ul>
 | 
			
		||||
				<h2
 | 
			
		||||
					class="w3-button w3-block w3-theme-d1"
 | 
			
		||||
					@click=${() => self.toggle_accordian('broadcasts')}
 | 
			
		||||
				>
 | 
			
		||||
					Discovery (${this.valid_broadcasts().length})
 | 
			
		||||
				</h2>
 | 
			
		||||
				<ul class="w3-ul w3-border w3-hide" id="broadcasts">
 | 
			
		||||
					${this.valid_broadcasts().map((x) => self.render_broadcast(x))}
 | 
			
		||||
				<h2>Connections</h2>
 | 
			
		||||
				<ul>
 | 
			
		||||
					${this.connections
 | 
			
		||||
						.filter((x) => x.tunnel === undefined)
 | 
			
		||||
						.map((x) => html` <li>${this.render_connection(x)}</li> `)}
 | 
			
		||||
				</ul>
 | 
			
		||||
				<h2
 | 
			
		||||
					class="w3-button w3-block w3-theme-d1"
 | 
			
		||||
					@click=${() => self.toggle_accordian('stored_connections')}
 | 
			
		||||
				>
 | 
			
		||||
					Stored Connections (${this.stored_connections.length})
 | 
			
		||||
				</h2>
 | 
			
		||||
				<ul class="w3-ul w3-border w3-hide" id="stored_connections">
 | 
			
		||||
				<h2>Stored Connections (WIP)</h2>
 | 
			
		||||
				<ul>
 | 
			
		||||
					${this.stored_connections.map(
 | 
			
		||||
						(x) => html`
 | 
			
		||||
							<li>
 | 
			
		||||
								<div class="w3-bar">
 | 
			
		||||
									<button
 | 
			
		||||
										class="w3-bar-item w3-button w3-theme-d1"
 | 
			
		||||
										@click=${() => self.forget_stored_connection(x)}
 | 
			
		||||
									>
 | 
			
		||||
										Forget
 | 
			
		||||
									</button>
 | 
			
		||||
									<button
 | 
			
		||||
										class="w3-bar-item w3-button w3-theme-d1"
 | 
			
		||||
										@click=${() => this.connect(x)}
 | 
			
		||||
									>
 | 
			
		||||
										Connect
 | 
			
		||||
									</button>
 | 
			
		||||
									<div class="w3-bar-item">
 | 
			
		||||
										<tf-user id=${x.pubkey} .users=${self.users}></tf-user>
 | 
			
		||||
										<div><small>${x.address}:${x.port}</small></div>
 | 
			
		||||
										<div>
 | 
			
		||||
											<small
 | 
			
		||||
												>Last connection:
 | 
			
		||||
												${new Date(x.last_success * 1000)}</small
 | 
			
		||||
											>
 | 
			
		||||
										</div>
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
								${this.render_message(x)}
 | 
			
		||||
								<button
 | 
			
		||||
									class="w3-button w3-dark-grey"
 | 
			
		||||
									@click=${() => self.forget_stored_connection(x)}
 | 
			
		||||
								>
 | 
			
		||||
									Forget
 | 
			
		||||
								</button>
 | 
			
		||||
								<button
 | 
			
		||||
									class="w3-button w3-dark-grey"
 | 
			
		||||
									@click=${() => tfrpc.rpc.connect(x)}
 | 
			
		||||
								>
 | 
			
		||||
									Connect
 | 
			
		||||
								</button>
 | 
			
		||||
								${x.address}:${x.port}
 | 
			
		||||
								<tf-user id=${x.pubkey} .users=${self.users}></tf-user>
 | 
			
		||||
							</li>
 | 
			
		||||
						`
 | 
			
		||||
					)}
 | 
			
		||||
				</ul>
 | 
			
		||||
				<h2
 | 
			
		||||
					class="w3-button w3-block w3-theme-d1"
 | 
			
		||||
					@click=${() => self.toggle_accordian('local_accounts')}
 | 
			
		||||
				>
 | 
			
		||||
					Local Accounts (${this.identities.length})
 | 
			
		||||
				</h2>
 | 
			
		||||
				<div class="w3-container w3-hide" id="local_accounts">
 | 
			
		||||
				<h2>Local Accounts</h2>
 | 
			
		||||
				<ul>
 | 
			
		||||
					${this.identities.map(
 | 
			
		||||
						(x) =>
 | 
			
		||||
							html`<div
 | 
			
		||||
								class="w3-tag w3-round w3-theme-l3"
 | 
			
		||||
								style="padding: 4px; margin: 2px; max-width: 100%; text-wrap: nowrap; overflow: hidden"
 | 
			
		||||
							>
 | 
			
		||||
								${x == this.server_identity
 | 
			
		||||
									? html`<div class="w3-tag w3-medium w3-round w3-theme-l1">
 | 
			
		||||
											🖥 local server
 | 
			
		||||
										</div>`
 | 
			
		||||
									: undefined}
 | 
			
		||||
								${this.my_identities.indexOf(x) != -1
 | 
			
		||||
									? html`<div class="w3-tag w3-medium w3-round w3-theme-d1">
 | 
			
		||||
											😎 you
 | 
			
		||||
										</div>`
 | 
			
		||||
									: undefined}
 | 
			
		||||
								<tf-user id=${x} .users=${this.users}></tf-user>
 | 
			
		||||
							</div>`
 | 
			
		||||
							html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
				</ul>
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										78
									
								
								apps/ssb/tf-tab-mentions.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								apps/ssb/tf-tab-mentions.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,78 @@
 | 
			
		||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfTabMentionsElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			whoami: {type: String},
 | 
			
		||||
			users: {type: Object},
 | 
			
		||||
			following: {type: Array},
 | 
			
		||||
			expanded: {type: Object},
 | 
			
		||||
			messages: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static styles = styles;
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		let self = this;
 | 
			
		||||
		this.whoami = null;
 | 
			
		||||
		this.users = {};
 | 
			
		||||
		this.following = [];
 | 
			
		||||
		this.expanded = {};
 | 
			
		||||
		this.messages = [];
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load() {
 | 
			
		||||
		console.log('Loading...', this.whoami);
 | 
			
		||||
		let results = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
				FROM messages_fts(?)
 | 
			
		||||
				JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
				JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
				WHERE messages.author != ?
 | 
			
		||||
				ORDER BY timestamp DESC limit 20
 | 
			
		||||
			`,
 | 
			
		||||
			[
 | 
			
		||||
				'"' + this.whoami.replace('"', '""') + '"',
 | 
			
		||||
				JSON.stringify(this.following),
 | 
			
		||||
				this.whoami,
 | 
			
		||||
			]
 | 
			
		||||
		);
 | 
			
		||||
		console.log('Done.');
 | 
			
		||||
		this.messages = results;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	on_expand(event) {
 | 
			
		||||
		if (event.detail.expanded) {
 | 
			
		||||
			let expand = {};
 | 
			
		||||
			expand[event.detail.id] = true;
 | 
			
		||||
			this.expanded = Object.assign({}, this.expanded, expand);
 | 
			
		||||
		} else {
 | 
			
		||||
			delete this.expanded[event.detail.id];
 | 
			
		||||
			this.expanded = Object.assign({}, this.expanded);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		if (!this.loading) {
 | 
			
		||||
			this.loading = true;
 | 
			
		||||
			this.load();
 | 
			
		||||
		}
 | 
			
		||||
		return html`
 | 
			
		||||
			<tf-news
 | 
			
		||||
				id="news"
 | 
			
		||||
				whoami=${this.whoami}
 | 
			
		||||
				.messages=${this.messages}
 | 
			
		||||
				.users=${this.users}
 | 
			
		||||
				.expanded=${this.expanded}
 | 
			
		||||
				@tf-expand=${this.on_expand}
 | 
			
		||||
			></tf-news>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
customElements.define('tf-tab-mentions', TfTabMentionsElement);
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import {LitElement, cache, html, unsafeHTML, until} from './lit-all.min.js';
 | 
			
		||||
import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfTabNewsFeedElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
@@ -12,14 +12,6 @@ class TfTabNewsFeedElement extends LitElement {
 | 
			
		||||
			messages: {type: Array},
 | 
			
		||||
			drafts: {type: Object},
 | 
			
		||||
			expanded: {type: Object},
 | 
			
		||||
			channels_unread: {type: Object},
 | 
			
		||||
			channels_latest: {type: Object},
 | 
			
		||||
			loading: {type: Number},
 | 
			
		||||
			time_range: {type: Array},
 | 
			
		||||
			time_loading: {type: Array},
 | 
			
		||||
			private_messages: {type: Array},
 | 
			
		||||
			grouped_private_messages: {type: Object},
 | 
			
		||||
			recent_reactions: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -34,308 +26,112 @@ class TfTabNewsFeedElement extends LitElement {
 | 
			
		||||
		this.following = [];
 | 
			
		||||
		this.drafts = {};
 | 
			
		||||
		this.expanded = {};
 | 
			
		||||
		this.channels_unread = {};
 | 
			
		||||
		this.channels_latest = {};
 | 
			
		||||
		this.start_time = new Date().valueOf();
 | 
			
		||||
		this.time_range = [0, 0];
 | 
			
		||||
		this.time_loading = undefined;
 | 
			
		||||
		this.recent_reactions = [];
 | 
			
		||||
		this.loading = 0;
 | 
			
		||||
		this.start_time = new Date().valueOf() - 24 * 60 * 60 * 1000;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	channel() {
 | 
			
		||||
		return this.hash.startsWith('##')
 | 
			
		||||
			? this.hash.substring(2)
 | 
			
		||||
			: this.hash.substring(1);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async _fetch_related_messages(messages) {
 | 
			
		||||
		let refs = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				WITH
 | 
			
		||||
					news AS (
 | 
			
		||||
						SELECT value AS id FROM json_each(?)
 | 
			
		||||
					)
 | 
			
		||||
				SELECT refs_out.ref AS ref FROM messages_refs refs_out JOIN news ON refs_out.message = news.id
 | 
			
		||||
				UNION
 | 
			
		||||
				SELECT refs_in.message AS ref FROM messages_refs refs_in JOIN news ON refs_in.ref = news.id
 | 
			
		||||
			`,
 | 
			
		||||
			[JSON.stringify(messages.map((x) => x.id))]
 | 
			
		||||
		);
 | 
			
		||||
		let related_messages = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
				FROM messages
 | 
			
		||||
				JOIN json_each(?2) refs ON messages.id = refs.value
 | 
			
		||||
				JOIN json_each(?1) AS following ON messages.author = following.value
 | 
			
		||||
			`,
 | 
			
		||||
			[JSON.stringify(this.following), JSON.stringify(refs.map((x) => x.ref))]
 | 
			
		||||
		);
 | 
			
		||||
		let combined = [].concat(messages, related_messages);
 | 
			
		||||
		let refs2 = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				WITH
 | 
			
		||||
					news AS (
 | 
			
		||||
						SELECT value AS id FROM json_each(?)
 | 
			
		||||
					)
 | 
			
		||||
				SELECT refs_out.ref AS ref FROM messages_refs refs_out JOIN news ON refs_out.message = news.id
 | 
			
		||||
				UNION
 | 
			
		||||
				SELECT refs_in.message AS ref FROM messages_refs refs_in JOIN news ON refs_in.ref = news.id
 | 
			
		||||
			`,
 | 
			
		||||
			[JSON.stringify(combined.map((x) => x.id))]
 | 
			
		||||
		);
 | 
			
		||||
		let t0 = new Date();
 | 
			
		||||
		let result = [].concat(
 | 
			
		||||
			combined,
 | 
			
		||||
			await tfrpc.rpc.query(
 | 
			
		||||
	async fetch_messages() {
 | 
			
		||||
		if (this.hash.startsWith('#@')) {
 | 
			
		||||
			let r = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
				SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
				FROM json_each(?2) refs
 | 
			
		||||
				JOIN messages ON messages.id = refs.value
 | 
			
		||||
				JOIN json_each(?1) following ON messages.author = following.value
 | 
			
		||||
				WHERE messages.content ->> 'type' != 'post'
 | 
			
		||||
			`,
 | 
			
		||||
				[
 | 
			
		||||
					JSON.stringify(this.following),
 | 
			
		||||
					JSON.stringify(refs2.map((x) => x.ref)),
 | 
			
		||||
				]
 | 
			
		||||
			)
 | 
			
		||||
		);
 | 
			
		||||
		let t1 = new Date();
 | 
			
		||||
		console.log((t1 - t0) / 1000);
 | 
			
		||||
		return result;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async fetch_messages(start_time, end_time) {
 | 
			
		||||
		this.dispatchEvent(
 | 
			
		||||
			new CustomEvent('loadmessages', {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				composed: true,
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
		this.time_loading = [start_time, end_time];
 | 
			
		||||
		let result;
 | 
			
		||||
		const k_max_results = 64;
 | 
			
		||||
		if (this.hash == '#@') {
 | 
			
		||||
			result = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
					WITH mentions AS (SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
						FROM messages_fts(?1)
 | 
			
		||||
						JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
						JOIN json_each(?2) AS following ON messages.author = following.value
 | 
			
		||||
						WHERE
 | 
			
		||||
							messages.author != ?1 AND
 | 
			
		||||
							(?3 IS NULL OR messages.timestamp >= ?3) AND messages.timestamp < ?4
 | 
			
		||||
						ORDER BY timestamp DESC limit ?5)
 | 
			
		||||
					SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
						FROM mentions
 | 
			
		||||
						JOIN messages_refs ON mentions.id = messages_refs.ref
 | 
			
		||||
					WITH mine AS (SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
 | 
			
		||||
						FROM messages
 | 
			
		||||
						WHERE messages.author = ?
 | 
			
		||||
						ORDER BY sequence DESC
 | 
			
		||||
						LIMIT 20)
 | 
			
		||||
					SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
						FROM mine
 | 
			
		||||
						JOIN messages_refs ON mine.id = messages_refs.ref
 | 
			
		||||
						JOIN messages ON messages_refs.message = messages.id
 | 
			
		||||
					UNION
 | 
			
		||||
					SELECT TRUE AS is_primary, * FROM mentions
 | 
			
		||||
					SELECT * FROM mine
 | 
			
		||||
				`,
 | 
			
		||||
				[
 | 
			
		||||
					'"' + this.whoami.replace('"', '""') + '"',
 | 
			
		||||
					JSON.stringify(this.following),
 | 
			
		||||
					start_time,
 | 
			
		||||
					end_time,
 | 
			
		||||
					k_max_results,
 | 
			
		||||
				]
 | 
			
		||||
			);
 | 
			
		||||
		} else if (this.hash.startsWith('#@')) {
 | 
			
		||||
			result = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
					WITH
 | 
			
		||||
						selected AS (SELECT rowid, id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
 | 
			
		||||
							FROM messages
 | 
			
		||||
							WHERE messages.author = ?1 AND (?2 IS NULL OR messages.timestamp >= 2) AND messages.timestamp < ?3
 | 
			
		||||
							ORDER BY sequence DESC LIMIT ?4
 | 
			
		||||
						)
 | 
			
		||||
					SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
						FROM selected
 | 
			
		||||
						JOIN messages_refs ON selected.id = messages_refs.ref
 | 
			
		||||
						JOIN messages ON messages_refs.message = messages.id
 | 
			
		||||
					UNION
 | 
			
		||||
					SELECT TRUE AS is_primary, * FROM selected
 | 
			
		||||
				`,
 | 
			
		||||
				[this.hash.substring(1), start_time, end_time, k_max_results]
 | 
			
		||||
				[this.hash.substring(1)]
 | 
			
		||||
			);
 | 
			
		||||
			return r;
 | 
			
		||||
		} else if (this.hash.startsWith('#%')) {
 | 
			
		||||
			result = await tfrpc.rpc.query(
 | 
			
		||||
			return await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
					SELECT TRUE AS is_primary, id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
 | 
			
		||||
					SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
 | 
			
		||||
					FROM messages
 | 
			
		||||
					WHERE messages.id = ?1
 | 
			
		||||
					WHERE id = ?1
 | 
			
		||||
					UNION
 | 
			
		||||
					SELECT FALSE AS is_primary, id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
 | 
			
		||||
					SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
 | 
			
		||||
					FROM messages JOIN messages_refs
 | 
			
		||||
					ON messages.id = messages_refs.message
 | 
			
		||||
					WHERE messages_refs.ref = ?1
 | 
			
		||||
				`,
 | 
			
		||||
				[this.hash.substring(1)]
 | 
			
		||||
			);
 | 
			
		||||
		} else if (this.hash.startsWith('##')) {
 | 
			
		||||
			let t0 = new Date();
 | 
			
		||||
			let initial_messages = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
					WITH
 | 
			
		||||
						all_news AS (
 | 
			
		||||
							SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
								FROM messages
 | 
			
		||||
								JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
								WHERE messages.content ->> 'channel' = ?4 AND messages.content ->> 'type' != 'vote'
 | 
			
		||||
							UNION
 | 
			
		||||
							SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
								FROM messages_refs
 | 
			
		||||
								JOIN messages ON messages.id = messages_refs.message
 | 
			
		||||
								JOIN json_each(?1) AS following ON messages.author = following.value
 | 
			
		||||
								WHERE messages_refs.ref = '#' || ?4 AND messages.content ->> 'type' != 'vote'
 | 
			
		||||
							)
 | 
			
		||||
					SELECT TRUE AS is_primary, all_news.* FROM all_news
 | 
			
		||||
						WHERE (?2 IS NULL OR all_news.timestamp >= ?2) AND all_news.timestamp < ?3
 | 
			
		||||
						ORDER BY all_news.timestamp DESC LIMIT ?5
 | 
			
		||||
				`,
 | 
			
		||||
				[
 | 
			
		||||
					JSON.stringify(this.following),
 | 
			
		||||
					start_time,
 | 
			
		||||
					end_time,
 | 
			
		||||
					this.hash.substring(2),
 | 
			
		||||
					k_max_results,
 | 
			
		||||
				]
 | 
			
		||||
			);
 | 
			
		||||
			let t1 = new Date();
 | 
			
		||||
			result = await this._fetch_related_messages(initial_messages);
 | 
			
		||||
			let t2 = new Date();
 | 
			
		||||
			console.log(
 | 
			
		||||
				`load of ${result.length} rows took ${(t2 - t0) / 1000} (${(t1 - t0) / 1000} to find ${initial_messages.length} initial messages, ${(t2 - t1) / 1000} to find ${result.length} total messages) following=${this.following.length} st=${start_time} et=${end_time}`
 | 
			
		||||
			);
 | 
			
		||||
		} else if (this.hash.startsWith('#🔐')) {
 | 
			
		||||
			let ids =
 | 
			
		||||
				this.hash == '#🔐' ? [] : this.hash.substring('#🔐'.length).split(',');
 | 
			
		||||
			result = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
					SELECT TRUE AS is_primary, messages.rowid, messages.id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
 | 
			
		||||
					FROM messages
 | 
			
		||||
					JOIN json_each(?1) AS private_messages ON messages.id = private_messages.value
 | 
			
		||||
					WHERE
 | 
			
		||||
						(?2 IS NULL OR (messages.timestamp >= ?2)) AND messages.timestamp < ?3 AND
 | 
			
		||||
						json(messages.content) LIKE '"%'
 | 
			
		||||
					ORDER BY messages.rowid DESC LIMIT ?4
 | 
			
		||||
				`,
 | 
			
		||||
				[
 | 
			
		||||
					JSON.stringify(
 | 
			
		||||
						this.grouped_private_messages?.[JSON.stringify(ids)]?.map(
 | 
			
		||||
							(x) => x.id
 | 
			
		||||
						) ?? []
 | 
			
		||||
					),
 | 
			
		||||
					start_time,
 | 
			
		||||
					end_time,
 | 
			
		||||
					k_max_results,
 | 
			
		||||
				]
 | 
			
		||||
			);
 | 
			
		||||
			result = (await this.decrypt(result)).filter((x) => x.decrypted);
 | 
			
		||||
		} else if (this.hash == '#👍') {
 | 
			
		||||
			result = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
					WITH votes AS (SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
						FROM messages
 | 
			
		||||
						JOIN json_each(?1) AS following ON messages.author = following.value
 | 
			
		||||
						WHERE
 | 
			
		||||
							messages.content ->> 'type' = 'vote' AND
 | 
			
		||||
							(?2 IS NULL OR messages.timestamp >= ?2) AND messages.timestamp < ?3
 | 
			
		||||
						ORDER BY timestamp DESC limit ?4)
 | 
			
		||||
					SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
						FROM votes
 | 
			
		||||
						JOIN messages ON messages.id = votes.content ->> '$.vote.link'
 | 
			
		||||
					UNION
 | 
			
		||||
					SELECT TRUE AS is_primary, * FROM votes
 | 
			
		||||
				`,
 | 
			
		||||
				[JSON.stringify(this.following), start_time, end_time, k_max_results]
 | 
			
		||||
			);
 | 
			
		||||
		} else {
 | 
			
		||||
			let t0 = new Date();
 | 
			
		||||
			let initial_messages = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
					WITH
 | 
			
		||||
						channels AS (SELECT '#' || value AS value FROM json_each(?5))
 | 
			
		||||
					SELECT TRUE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
							FROM messages
 | 
			
		||||
							JOIN json_each(?1) AS following ON messages.author = following.value
 | 
			
		||||
							WHERE messages.timestamp < ?3 AND (?2 IS NULL OR messages.timestamp >= ?2) AND
 | 
			
		||||
								messages.content ->> 'type' != 'vote' AND
 | 
			
		||||
								(messages.content ->> 'root' IS NULL OR (
 | 
			
		||||
									NOT EXISTS (SELECT * FROM messages root JOIN channels ON ('#' || (root.content ->> 'channel')) = channels.value WHERE root.id = messages.content ->> 'root') AND
 | 
			
		||||
									NOT EXISTS (SELECT * FROM messages root JOIN messages_refs ON root.id = messages.content ->> 'root' JOIN channels ON messages_refs.message = root.id AND messages_refs.ref = channels.value)
 | 
			
		||||
								)) AND
 | 
			
		||||
								(messages.content ->> 'channel' IS NULL OR ('#' || (messages.content ->> 'channel')) NOT IN (SELECT * FROM channels)) AND
 | 
			
		||||
								NOT EXISTS (SELECT * FROM messages_refs JOIN channels ON messages_refs.message = messages.id AND messages_refs.ref = channels.value)
 | 
			
		||||
					ORDER BY timestamp DESC LIMIT ?4
 | 
			
		||||
				`,
 | 
			
		||||
				[
 | 
			
		||||
					JSON.stringify(this.following),
 | 
			
		||||
					start_time,
 | 
			
		||||
					end_time,
 | 
			
		||||
					k_max_results,
 | 
			
		||||
					JSON.stringify(Object.keys(this.channels_latest)),
 | 
			
		||||
				]
 | 
			
		||||
			);
 | 
			
		||||
			let t1 = new Date();
 | 
			
		||||
			result = await this._fetch_related_messages(initial_messages);
 | 
			
		||||
			let t2 = new Date();
 | 
			
		||||
			console.log(
 | 
			
		||||
				`load of ${result.length} rows took ${(t2 - t0) / 1000} (${(t1 - t0) / 1000} to find ${initial_messages.length} initial messages, ${(t2 - t1) / 1000} to find ${result.length} total messages) following=${this.following.length} st=${start_time} et=${end_time}`
 | 
			
		||||
			);
 | 
			
		||||
			let promises = [];
 | 
			
		||||
			const k_following_limit = 256;
 | 
			
		||||
			for (let i = 0; i < this.following.length; i += k_following_limit) {
 | 
			
		||||
				promises.push(
 | 
			
		||||
					tfrpc.rpc.query(
 | 
			
		||||
						`
 | 
			
		||||
						WITH news AS (SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
						FROM messages
 | 
			
		||||
						JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
						WHERE messages.timestamp > ? AND messages.timestamp < ?
 | 
			
		||||
						ORDER BY messages.timestamp DESC)
 | 
			
		||||
						SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
							FROM news
 | 
			
		||||
							JOIN messages_refs ON news.id = messages_refs.ref
 | 
			
		||||
							JOIN messages ON messages_refs.message = messages.id
 | 
			
		||||
						UNION
 | 
			
		||||
						SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
							FROM news
 | 
			
		||||
							JOIN messages_refs ON news.id = messages_refs.message
 | 
			
		||||
							JOIN messages ON messages_refs.ref = messages.id
 | 
			
		||||
						UNION
 | 
			
		||||
						SELECT news.* FROM news
 | 
			
		||||
					`,
 | 
			
		||||
						[
 | 
			
		||||
							JSON.stringify(this.following.slice(i, i + k_following_limit)),
 | 
			
		||||
							this.start_time,
 | 
			
		||||
							/*
 | 
			
		||||
							 ** Don't show messages more than a day into the future to prevent
 | 
			
		||||
							 ** messages with far-future timestamps from staying at the top forever.
 | 
			
		||||
							 */
 | 
			
		||||
							new Date().valueOf() + 24 * 60 * 60 * 1000,
 | 
			
		||||
						]
 | 
			
		||||
					)
 | 
			
		||||
				);
 | 
			
		||||
			}
 | 
			
		||||
			return [].concat(...(await Promise.all(promises)));
 | 
			
		||||
		}
 | 
			
		||||
		this.time_loading = undefined;
 | 
			
		||||
		return result;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	update_time_range_from_messages(messages) {
 | 
			
		||||
		let only_primary = messages.filter((x) => x.is_primary);
 | 
			
		||||
		this.time_range = [
 | 
			
		||||
			only_primary.reduce(
 | 
			
		||||
				(accumulator, current) => Math.min(accumulator, current.timestamp),
 | 
			
		||||
				this.time_range[0]
 | 
			
		||||
			),
 | 
			
		||||
			only_primary.reduce(
 | 
			
		||||
				(accumulator, current) => Math.max(accumulator, current.timestamp),
 | 
			
		||||
				this.time_range[1]
 | 
			
		||||
			),
 | 
			
		||||
		];
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	unread_allowed() {
 | 
			
		||||
		return (
 | 
			
		||||
			this.hash == '#@' ||
 | 
			
		||||
			(!this.hash.startsWith('#%') && !this.hash.startsWith('#@'))
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load_more() {
 | 
			
		||||
		this.loading++;
 | 
			
		||||
		this.loading_canceled = false;
 | 
			
		||||
		try {
 | 
			
		||||
			let more = [];
 | 
			
		||||
			let last_start_time = this.time_range[0];
 | 
			
		||||
			try {
 | 
			
		||||
				more = await this.fetch_messages(null, last_start_time);
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				console.log(e);
 | 
			
		||||
			}
 | 
			
		||||
			this.update_time_range_from_messages(
 | 
			
		||||
				more.filter((x) => x.timestamp < last_start_time)
 | 
			
		||||
			);
 | 
			
		||||
			this.messages = await this.decrypt([...more, ...this.messages]);
 | 
			
		||||
		} finally {
 | 
			
		||||
			this.loading--;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cancel_load() {
 | 
			
		||||
		this.loading_canceled = true;
 | 
			
		||||
		let last_start_time = this.start_time;
 | 
			
		||||
		this.start_time = last_start_time - 24 * 60 * 60 * 1000;
 | 
			
		||||
		let more = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				WITH news AS (SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
				FROM messages
 | 
			
		||||
				JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
				WHERE messages.timestamp > ?
 | 
			
		||||
				AND messages.timestamp <= ?
 | 
			
		||||
				ORDER BY messages.timestamp DESC)
 | 
			
		||||
				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
					FROM news
 | 
			
		||||
					JOIN messages_refs ON news.id = messages_refs.ref
 | 
			
		||||
					JOIN messages ON messages_refs.message = messages.id
 | 
			
		||||
				UNION
 | 
			
		||||
				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
					FROM news
 | 
			
		||||
					JOIN messages_refs ON news.id = messages_refs.message
 | 
			
		||||
					JOIN messages ON messages_refs.ref = messages.id
 | 
			
		||||
				UNION
 | 
			
		||||
				SELECT news.* FROM news
 | 
			
		||||
			`,
 | 
			
		||||
			[JSON.stringify(this.following), this.start_time, last_start_time]
 | 
			
		||||
		);
 | 
			
		||||
		this.messages = await this.decrypt([...more, ...this.messages]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async decrypt(messages) {
 | 
			
		||||
		console.log('decrypt');
 | 
			
		||||
		let result = [];
 | 
			
		||||
		for (let message of messages) {
 | 
			
		||||
			let content;
 | 
			
		||||
@@ -360,200 +156,44 @@ class TfTabNewsFeedElement extends LitElement {
 | 
			
		||||
		return result;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	merge_messages(old_messages, new_messages) {
 | 
			
		||||
		let old_by_id = Object.fromEntries(old_messages.map((x) => [x.id, x]));
 | 
			
		||||
		return new_messages.map((x) => (old_by_id[x.id] ? old_by_id[x.id] : x));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load_latest() {
 | 
			
		||||
		this.loading++;
 | 
			
		||||
		let now = new Date().valueOf();
 | 
			
		||||
		let end_time = now + 24 * 60 * 60 * 1000;
 | 
			
		||||
		let messages = [];
 | 
			
		||||
		try {
 | 
			
		||||
			messages = await this.fetch_messages(this.time_range[0], end_time);
 | 
			
		||||
			messages = await this.decrypt(messages);
 | 
			
		||||
			this.update_time_range_from_messages(
 | 
			
		||||
				messages.filter(
 | 
			
		||||
					(x) => x.timestamp >= this.time_range[0] && x.timestamp < end_time
 | 
			
		||||
				)
 | 
			
		||||
			);
 | 
			
		||||
		} finally {
 | 
			
		||||
			this.loading--;
 | 
			
		||||
		}
 | 
			
		||||
		this.messages = this.merge_messages(
 | 
			
		||||
			this.messages,
 | 
			
		||||
			Object.values(
 | 
			
		||||
				Object.fromEntries(
 | 
			
		||||
					[...this.messages, ...messages]
 | 
			
		||||
						.sort((x, y) => x.timestamp - y.timestamp)
 | 
			
		||||
						.slice(-1024)
 | 
			
		||||
						.map((x) => [x.id, x])
 | 
			
		||||
				)
 | 
			
		||||
			)
 | 
			
		||||
		);
 | 
			
		||||
		console.log('done loading latest messages.');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load_messages() {
 | 
			
		||||
		let start_time = new Date();
 | 
			
		||||
		let self = this;
 | 
			
		||||
		this.loading++;
 | 
			
		||||
		let messages = [];
 | 
			
		||||
		let original_hash = this.hash;
 | 
			
		||||
		try {
 | 
			
		||||
			if (this._messages_hash !== this.hash) {
 | 
			
		||||
				this.messages = [];
 | 
			
		||||
				this._messages_hash = this.hash;
 | 
			
		||||
			}
 | 
			
		||||
			this._messages_following = JSON.stringify(this.following);
 | 
			
		||||
			this._private_messages =
 | 
			
		||||
				JSON.stringify(this.private_messages) +
 | 
			
		||||
				JSON.stringify(this.grouped_private_messages);
 | 
			
		||||
			this._channels_latest = JSON.stringify(
 | 
			
		||||
				Object.keys(this.channels_latest ?? {})
 | 
			
		||||
			);
 | 
			
		||||
			let now = new Date().valueOf();
 | 
			
		||||
			let start_time = now - 24 * 60 * 60 * 1000;
 | 
			
		||||
			this.start_time = start_time;
 | 
			
		||||
			this.time_range = [now + 24 * 60 * 60 * 1000, now + 24 * 60 * 60 * 1000];
 | 
			
		||||
			messages = await this.fetch_messages(null, this.time_range[1]);
 | 
			
		||||
			this.update_time_range_from_messages(
 | 
			
		||||
				messages.filter((x) => x.timestamp < this.time_range[1])
 | 
			
		||||
			);
 | 
			
		||||
			messages = await this.decrypt(messages);
 | 
			
		||||
		} finally {
 | 
			
		||||
			this.loading--;
 | 
			
		||||
		}
 | 
			
		||||
		if (this.hash == original_hash) {
 | 
			
		||||
			this.messages = this.merge_messages(this.messages, messages);
 | 
			
		||||
		}
 | 
			
		||||
		this.time_loading = undefined;
 | 
			
		||||
		console.log(
 | 
			
		||||
			`loading ${messages.length} messages done for ${self.whoami} in ${(new Date() - start_time) / 1000}s`
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	mark_all_read() {
 | 
			
		||||
		let newest = this.messages.reduce(
 | 
			
		||||
			(accumulator, current) => Math.max(accumulator, current.rowid),
 | 
			
		||||
			this.channels_latest[this.channel()] ?? -1
 | 
			
		||||
		);
 | 
			
		||||
		if (newest >= 0) {
 | 
			
		||||
			this.dispatchEvent(
 | 
			
		||||
				new CustomEvent('channelsetunread', {
 | 
			
		||||
					bubbles: true,
 | 
			
		||||
					composed: true,
 | 
			
		||||
					detail: {
 | 
			
		||||
						channel: this.channel(),
 | 
			
		||||
						unread: newest + 1,
 | 
			
		||||
					},
 | 
			
		||||
				})
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	close_private_chat() {
 | 
			
		||||
		this.mark_all_read();
 | 
			
		||||
		this.dispatchEvent(
 | 
			
		||||
			new CustomEvent('closeprivatechat', {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				composed: true,
 | 
			
		||||
				detail: {
 | 
			
		||||
					key: JSON.stringify(
 | 
			
		||||
						this.hash == '#🔐'
 | 
			
		||||
							? []
 | 
			
		||||
							: this.hash.substring('#🔐'.length).split(',')
 | 
			
		||||
					),
 | 
			
		||||
				},
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
		tfrpc.rpc.setHash('#');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_close_chat_button() {
 | 
			
		||||
		if (this.hash.startsWith('#🔐')) {
 | 
			
		||||
			return html`
 | 
			
		||||
				<button class="w3-button w3-theme-d1" @click=${this.close_private_chat}>
 | 
			
		||||
					Close Chat
 | 
			
		||||
				</button>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
	async add_messages(messages) {
 | 
			
		||||
		this.messages = await this.decrypt([...messages, ...this.messages]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		if (
 | 
			
		||||
			!this.messages ||
 | 
			
		||||
			this._messages_hash !== this.hash ||
 | 
			
		||||
			this._messages_following !== JSON.stringify(this.following) ||
 | 
			
		||||
			this._private_messages !==
 | 
			
		||||
				JSON.stringify(this.private_messages) +
 | 
			
		||||
					JSON.stringify(this.grouped_private_messages) ||
 | 
			
		||||
			this._channels_latest !==
 | 
			
		||||
				JSON.stringify(Object.keys(this.channels_latest))
 | 
			
		||||
			this._messages_following !== this.following
 | 
			
		||||
		) {
 | 
			
		||||
			console.log(
 | 
			
		||||
				`loading messages for ${this.whoami} (following ${this.following.length})`
 | 
			
		||||
			);
 | 
			
		||||
			this.load_messages();
 | 
			
		||||
			let self = this;
 | 
			
		||||
			this.messages = [];
 | 
			
		||||
			this._messages_hash = this.hash;
 | 
			
		||||
			this._messages_following = this.following;
 | 
			
		||||
			this.fetch_messages()
 | 
			
		||||
				.then(this.decrypt.bind(this))
 | 
			
		||||
				.then(function (messages) {
 | 
			
		||||
					self.messages = messages;
 | 
			
		||||
					console.log(`loading mesages done for ${self.whoami}`);
 | 
			
		||||
				})
 | 
			
		||||
				.catch(function (error) {
 | 
			
		||||
					alert(JSON.stringify(error, null, 2));
 | 
			
		||||
				});
 | 
			
		||||
		}
 | 
			
		||||
		let more;
 | 
			
		||||
		if (!this.hash.startsWith('#%')) {
 | 
			
		||||
		if (!this.hash.startsWith('#@') && !this.hash.startsWith('#%')) {
 | 
			
		||||
			more = html`
 | 
			
		||||
				<p>
 | 
			
		||||
					${this.unread_allowed()
 | 
			
		||||
						? html`
 | 
			
		||||
								<button
 | 
			
		||||
									class="w3-button w3-theme-d1"
 | 
			
		||||
									@click=${this.mark_all_read}
 | 
			
		||||
								>
 | 
			
		||||
									Mark All Read
 | 
			
		||||
								</button>
 | 
			
		||||
							`
 | 
			
		||||
						: undefined}
 | 
			
		||||
					<button
 | 
			
		||||
						?disabled=${this.loading}
 | 
			
		||||
						class="w3-button w3-theme-d1"
 | 
			
		||||
						@click=${this.load_more}
 | 
			
		||||
					>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.load_more}>
 | 
			
		||||
						Load More
 | 
			
		||||
					</button>
 | 
			
		||||
					<button
 | 
			
		||||
						class=${'w3-button w3-theme-d1' + (this.loading ? '' : ' w3-hide')}
 | 
			
		||||
						@click=${this.cancel_load}
 | 
			
		||||
					>
 | 
			
		||||
						Cancel
 | 
			
		||||
					</button>
 | 
			
		||||
					<span
 | 
			
		||||
						>Showing
 | 
			
		||||
						${new Date(
 | 
			
		||||
							this.time_loading
 | 
			
		||||
								? Math.min(this.time_loading[0], this.time_range[0])
 | 
			
		||||
								: this.time_range[0]
 | 
			
		||||
						).toLocaleDateString()}
 | 
			
		||||
						-
 | 
			
		||||
						${new Date(
 | 
			
		||||
							this.time_loading
 | 
			
		||||
								? Math.max(this.time_loading[1], this.time_range[1])
 | 
			
		||||
								: this.time_range[1]
 | 
			
		||||
						).toLocaleDateString()}.</span
 | 
			
		||||
					>
 | 
			
		||||
				</p>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
		return cache(html`
 | 
			
		||||
			<style>
 | 
			
		||||
				${generate_theme()}
 | 
			
		||||
			</style>
 | 
			
		||||
			${this.unread_allowed()
 | 
			
		||||
				? html`<button
 | 
			
		||||
						class="w3-button w3-theme-d1"
 | 
			
		||||
						@click=${this.mark_all_read}
 | 
			
		||||
					>
 | 
			
		||||
						Mark All Read
 | 
			
		||||
					</button>`
 | 
			
		||||
				: undefined}
 | 
			
		||||
			${this.render_close_chat_button()}
 | 
			
		||||
		return html`
 | 
			
		||||
			<tf-news
 | 
			
		||||
				id="news"
 | 
			
		||||
				whoami=${this.whoami}
 | 
			
		||||
@@ -562,14 +202,9 @@ class TfTabNewsFeedElement extends LitElement {
 | 
			
		||||
				.following=${this.following}
 | 
			
		||||
				.drafts=${this.drafts}
 | 
			
		||||
				.expanded=${this.expanded}
 | 
			
		||||
				hash=${this.hash}
 | 
			
		||||
				channel=${this.channel()}
 | 
			
		||||
				channel_unread=${this.channels_unread?.[this.channel()]}
 | 
			
		||||
				.recent_reactions=${this.recent_reactions}
 | 
			
		||||
				@mark_all_read=${this.mark_all_read}
 | 
			
		||||
			></tf-news>
 | 
			
		||||
			${more}
 | 
			
		||||
		`);
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,6 @@
 | 
			
		||||
import {
 | 
			
		||||
	LitElement,
 | 
			
		||||
	cache,
 | 
			
		||||
	keyed,
 | 
			
		||||
	html,
 | 
			
		||||
	unsafeHTML,
 | 
			
		||||
	until,
 | 
			
		||||
} from './lit-all.min.js';
 | 
			
		||||
import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfTabNewsElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
@@ -15,21 +8,10 @@ class TfTabNewsElement extends LitElement {
 | 
			
		||||
			whoami: {type: String},
 | 
			
		||||
			users: {type: Object},
 | 
			
		||||
			hash: {type: String},
 | 
			
		||||
			unread: {type: Array},
 | 
			
		||||
			following: {type: Array},
 | 
			
		||||
			drafts: {type: Object},
 | 
			
		||||
			expanded: {type: Object},
 | 
			
		||||
			loading: {type: Boolean},
 | 
			
		||||
			channels: {type: Array},
 | 
			
		||||
			channels_unread: {type: Object},
 | 
			
		||||
			channels_latest: {type: Object},
 | 
			
		||||
			connections: {type: Array},
 | 
			
		||||
			private_messages: {type: Array},
 | 
			
		||||
			grouped_private_messages: {type: Object},
 | 
			
		||||
			visible_private_messages: {type: Object},
 | 
			
		||||
			recent_reactions: {type: Array},
 | 
			
		||||
			peer_exchange: {type: Boolean},
 | 
			
		||||
			is_administrator: {type: Boolean},
 | 
			
		||||
			stay_connected: {type: Boolean},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -41,19 +23,14 @@ class TfTabNewsElement extends LitElement {
 | 
			
		||||
		this.whoami = null;
 | 
			
		||||
		this.users = {};
 | 
			
		||||
		this.hash = '#';
 | 
			
		||||
		this.unread = [];
 | 
			
		||||
		this.following = [];
 | 
			
		||||
		this.cache = {};
 | 
			
		||||
		this.drafts = {};
 | 
			
		||||
		this.expanded = {};
 | 
			
		||||
		this.channels_unread = {};
 | 
			
		||||
		this.channels_latest = {};
 | 
			
		||||
		this.channels = [];
 | 
			
		||||
		this.connections = [];
 | 
			
		||||
		this.recent_reactions = [];
 | 
			
		||||
		tfrpc.rpc.localStorageGet('drafts').then(function (d) {
 | 
			
		||||
			self.drafts = JSON.parse(d || '{}');
 | 
			
		||||
		});
 | 
			
		||||
		this.check_peer_exchange();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	connectedCallback() {
 | 
			
		||||
@@ -66,19 +43,37 @@ class TfTabNewsElement extends LitElement {
 | 
			
		||||
		document.body.removeEventListener('keypress', this.on_keypress.bind(this));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async check_peer_exchange() {
 | 
			
		||||
		if (await tfrpc.rpc.isAdministrator()) {
 | 
			
		||||
			this.peer_exchange = await tfrpc.rpc.globalSettingsGet('peer_exchange');
 | 
			
		||||
		} else {
 | 
			
		||||
			this.peer_exchange = undefined;
 | 
			
		||||
	show_more() {
 | 
			
		||||
		let unread = this.unread;
 | 
			
		||||
		let news = this.shadowRoot?.getElementById('news');
 | 
			
		||||
		if (news) {
 | 
			
		||||
			console.log('injecting messages', news.messages);
 | 
			
		||||
			news.add_messages(
 | 
			
		||||
				Object.values(Object.fromEntries(this.unread.map((x) => [x.id, x])))
 | 
			
		||||
			);
 | 
			
		||||
			this.dispatchEvent(new CustomEvent('refresh'));
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	load_latest() {
 | 
			
		||||
		let news = this.shadowRoot?.getElementById('news');
 | 
			
		||||
		if (news) {
 | 
			
		||||
			news.load_latest();
 | 
			
		||||
	new_messages_text() {
 | 
			
		||||
		if (!this.unread?.length) {
 | 
			
		||||
			return 'No new messages.';
 | 
			
		||||
		}
 | 
			
		||||
		let counts = {};
 | 
			
		||||
		for (let message of this.unread) {
 | 
			
		||||
			let type = 'private';
 | 
			
		||||
			try {
 | 
			
		||||
				type = JSON.parse(message.content).type || type;
 | 
			
		||||
			} catch {}
 | 
			
		||||
			counts[type] = (counts[type] || 0) + 1;
 | 
			
		||||
		}
 | 
			
		||||
		return (
 | 
			
		||||
			'↻ Show New: ' +
 | 
			
		||||
			Object.keys(counts)
 | 
			
		||||
				.sort()
 | 
			
		||||
				.map((x) => counts[x].toString() + ' ' + x + 's')
 | 
			
		||||
				.join(', ')
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	draft(event) {
 | 
			
		||||
@@ -89,7 +84,10 @@ class TfTabNewsElement extends LitElement {
 | 
			
		||||
		} else {
 | 
			
		||||
			delete this.drafts[id];
 | 
			
		||||
		}
 | 
			
		||||
		this.drafts = Object.assign({}, this.drafts);
 | 
			
		||||
		/* Only trigger a re-render if we're creating a new draft or discarding an old one. */
 | 
			
		||||
		if ((previous !== undefined) != (event.detail.draft !== undefined)) {
 | 
			
		||||
			this.drafts = Object.assign({}, this.drafts);
 | 
			
		||||
		}
 | 
			
		||||
		tfrpc.rpc.localStorageSet('drafts', JSON.stringify(this.drafts));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -110,360 +108,48 @@ class TfTabNewsElement extends LitElement {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	unread_status(channel) {
 | 
			
		||||
		if (channel === undefined) {
 | 
			
		||||
			if (
 | 
			
		||||
				Object.keys(this.channels_unread).some((x) => this.unread_status(x))
 | 
			
		||||
			) {
 | 
			
		||||
				return '✉️ ';
 | 
			
		||||
			}
 | 
			
		||||
		} else if (channel?.startsWith('🔐')) {
 | 
			
		||||
			let key = JSON.stringify(channel.substring('🔐'.length).split(','));
 | 
			
		||||
			if (this.grouped_private_messages?.[key]) {
 | 
			
		||||
				let grouped_latest = Math.max(
 | 
			
		||||
					...this.grouped_private_messages?.[key]?.map((x) => x.rowid)
 | 
			
		||||
				);
 | 
			
		||||
				if (
 | 
			
		||||
					this.channels_unread[channel] === undefined ||
 | 
			
		||||
					grouped_latest > this.channels_unread[channel]
 | 
			
		||||
				) {
 | 
			
		||||
					return '✉️ ';
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		} else if (
 | 
			
		||||
			this.channels_latest[channel] &&
 | 
			
		||||
			this.channels_latest[channel] > 0 &&
 | 
			
		||||
			(this.channels_unread[channel] === undefined ||
 | 
			
		||||
				this.channels_unread[channel] <= this.channels_latest[channel])
 | 
			
		||||
		) {
 | 
			
		||||
			return '✉️ ';
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	show_sidebar() {
 | 
			
		||||
		this.renderRoot.getElementById('sidebar').style.display = 'block';
 | 
			
		||||
		this.renderRoot.getElementById('sidebar_overlay').style.display = 'block';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	hide_sidebar() {
 | 
			
		||||
		this.renderRoot.getElementById('sidebar').style.display = 'none';
 | 
			
		||||
		this.renderRoot.getElementById('sidebar_overlay').style.display = 'none';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async channel_toggle_subscribed() {
 | 
			
		||||
		let channel = this.hash.substring(2);
 | 
			
		||||
		let subscribed = this.channels.indexOf(channel) != -1;
 | 
			
		||||
		subscribed = !subscribed;
 | 
			
		||||
 | 
			
		||||
		await tfrpc.rpc.appendMessage(this.whoami, {
 | 
			
		||||
			type: 'channel',
 | 
			
		||||
			channel: channel,
 | 
			
		||||
			subscribed: subscribed,
 | 
			
		||||
		});
 | 
			
		||||
		if (subscribed) {
 | 
			
		||||
			this.channels = [].concat([channel], this.channels).sort();
 | 
			
		||||
		} else {
 | 
			
		||||
			this.channels = this.channels.filter((x) => x != channel);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	channel() {
 | 
			
		||||
		return this.hash.startsWith('##') ? this.hash.substring(2) : undefined;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	compare_follows(a, b) {
 | 
			
		||||
		return b[1].ts > a[1].ts ? 1 : b[1].ts < a[1].ts ? -1 : 0;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	suggested_follows() {
 | 
			
		||||
		/*
 | 
			
		||||
		 ** Filter out people who have used future timestamps so that they aren't
 | 
			
		||||
		 ** pinned at the top.
 | 
			
		||||
		 */
 | 
			
		||||
		let self = this;
 | 
			
		||||
		let now = new Date().valueOf();
 | 
			
		||||
		return Object.entries(this.users)
 | 
			
		||||
			.filter((x) => x[1].ts < now)
 | 
			
		||||
			.filter((x) => x[1].follow_depth > 1)
 | 
			
		||||
			.sort(self.compare_follows)
 | 
			
		||||
			.slice(0, 8)
 | 
			
		||||
			.map((x) => x[0]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async enable_peer_exchange() {
 | 
			
		||||
		await tfrpc.rpc.globalSettingsSet('peer_exchange', true);
 | 
			
		||||
		await this.check_peer_exchange();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	is_loading() {
 | 
			
		||||
		return this.shadowRoot?.getElementById('news')?.loading;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_sidebar() {
 | 
			
		||||
		return html`
 | 
			
		||||
			<div
 | 
			
		||||
				class="w3-sidebar w3-bar-block w3-theme-d1 w3-collapse w3-animate-left"
 | 
			
		||||
				style="width: 2in; left: 0; z-index: 5; box-sizing: border-box; top: 0"
 | 
			
		||||
				id="sidebar"
 | 
			
		||||
			>
 | 
			
		||||
				<div
 | 
			
		||||
					class="w3-right w3-button w3-hide-large"
 | 
			
		||||
					@click=${this.hide_sidebar}
 | 
			
		||||
				>
 | 
			
		||||
					×
 | 
			
		||||
				</div>
 | 
			
		||||
				${this.hash.startsWith('##') &&
 | 
			
		||||
				this.channels.indexOf(this.hash.substring(2)) == -1
 | 
			
		||||
					? html`
 | 
			
		||||
							<div class="w3-bar-item w3-theme-d2">Viewing</div>
 | 
			
		||||
							<a
 | 
			
		||||
								href="#"
 | 
			
		||||
								class="w3-bar-item w3-button"
 | 
			
		||||
								style="font-weight: bold"
 | 
			
		||||
								>${this.hash.substring(1)}</a
 | 
			
		||||
							>
 | 
			
		||||
						`
 | 
			
		||||
					: undefined}
 | 
			
		||||
				<h4 class="w3-bar-item w3-theme-d2">Channels</h4>
 | 
			
		||||
				<a
 | 
			
		||||
					href="#"
 | 
			
		||||
					class="w3-bar-item w3-button"
 | 
			
		||||
					style=${this.hash == '#' ? 'font-weight: bold' : undefined}
 | 
			
		||||
					>${this.unread_status('')}general</a
 | 
			
		||||
				>
 | 
			
		||||
				<a
 | 
			
		||||
					href="#@"
 | 
			
		||||
					class="w3-bar-item w3-button"
 | 
			
		||||
					style=${this.hash == '#@' ? 'font-weight: bold' : undefined}
 | 
			
		||||
					>${this.unread_status('@')}@mentions</a
 | 
			
		||||
				>
 | 
			
		||||
				<a
 | 
			
		||||
					href="#👍"
 | 
			
		||||
					class="w3-bar-item w3-button"
 | 
			
		||||
					style=${this.hash == '#👍' ? 'font-weight: bold' : undefined}
 | 
			
		||||
					>${this.unread_status('👍')}👍votes</a
 | 
			
		||||
				>
 | 
			
		||||
				${Object.keys(this?.visible_private_messages ?? [])
 | 
			
		||||
					?.sort()
 | 
			
		||||
					?.map(
 | 
			
		||||
						(key) => html`
 | 
			
		||||
							<a
 | 
			
		||||
								href=${'#🔐' + JSON.parse(key).join(',')}
 | 
			
		||||
								class="w3-bar-item w3-button"
 | 
			
		||||
								style=${this.hash == '#🔐' + JSON.parse(key).join(',')
 | 
			
		||||
									? 'font-weight: bold'
 | 
			
		||||
									: undefined}
 | 
			
		||||
								>${this.unread_status('🔐' + JSON.parse(key).join(','))}
 | 
			
		||||
								${(key != '[]' ? JSON.parse(key) : [this.whoami]).map(
 | 
			
		||||
									(id) => html`
 | 
			
		||||
										<tf-user
 | 
			
		||||
											id=${id}
 | 
			
		||||
											nolink="true"
 | 
			
		||||
											.users=${this.users}
 | 
			
		||||
										></tf-user>
 | 
			
		||||
									`
 | 
			
		||||
								)}</a
 | 
			
		||||
							>
 | 
			
		||||
						`
 | 
			
		||||
					)}
 | 
			
		||||
				${Object.keys(this.drafts)
 | 
			
		||||
					.sort()
 | 
			
		||||
					.map(
 | 
			
		||||
						(x) => html`
 | 
			
		||||
							<a
 | 
			
		||||
								href=${'#' + encodeURIComponent(x)}
 | 
			
		||||
								class="w3-bar-item w3-button"
 | 
			
		||||
								style="text-wrap: nowrap; text-overflow: ellipsis"
 | 
			
		||||
								>📝 ${this.drafts[x]?.text ?? x}</a
 | 
			
		||||
							>
 | 
			
		||||
						`
 | 
			
		||||
					)}
 | 
			
		||||
				${this.channels.map(
 | 
			
		||||
					(x) => html`
 | 
			
		||||
						<a
 | 
			
		||||
							href=${'#' + encodeURIComponent('#' + x)}
 | 
			
		||||
							class="w3-bar-item w3-button"
 | 
			
		||||
							style=${this.hash == '##' + x ? 'font-weight: bold' : undefined}
 | 
			
		||||
							>${this.unread_status(x)}#${x}</a
 | 
			
		||||
						>
 | 
			
		||||
					`
 | 
			
		||||
				)}
 | 
			
		||||
 | 
			
		||||
				<a class="w3-bar-item w3-theme-d2 w3-button" href="#connections">
 | 
			
		||||
					<h4 style="margin: 0">Connections</h4>
 | 
			
		||||
				</a>
 | 
			
		||||
				${this.connections?.filter((x) => x.id)?.length == 0
 | 
			
		||||
					? html`
 | 
			
		||||
							<button
 | 
			
		||||
								class=${'w3-bar-item w3-button' +
 | 
			
		||||
								(this.connections?.some((x) => x.flags.one_shot)
 | 
			
		||||
									? ' w3-spin'
 | 
			
		||||
									: '')}
 | 
			
		||||
								@click=${() =>
 | 
			
		||||
									this.dispatchEvent(
 | 
			
		||||
										new Event('refresh', {bubbles: true, composed: true})
 | 
			
		||||
									)}
 | 
			
		||||
							>
 | 
			
		||||
								↻ Sync now
 | 
			
		||||
							</button>
 | 
			
		||||
							<button
 | 
			
		||||
								class="w3-bar-item w3-button w3-ripple"
 | 
			
		||||
								@click=${() =>
 | 
			
		||||
									this.dispatchEvent(
 | 
			
		||||
										new Event('toggle_stay_connected', {
 | 
			
		||||
											bubbles: true,
 | 
			
		||||
											composed: true,
 | 
			
		||||
										})
 | 
			
		||||
									)}
 | 
			
		||||
							>
 | 
			
		||||
								<span style="display: inline-block; width: 1.8em"
 | 
			
		||||
									>${this.stay_connected ? '🔗' : '⛓️💥'}</span
 | 
			
		||||
								>
 | 
			
		||||
								${this.stay_connected ? 'Online mode' : 'Passive mode'}
 | 
			
		||||
							</button>
 | 
			
		||||
							<button
 | 
			
		||||
								class=${'w3-bar-item w3-button w3-border w3-leftbar w3-rightbar' +
 | 
			
		||||
								(this.peer_exchange !== false ? ' w3-hide' : '')}
 | 
			
		||||
								@click=${this.enable_peer_exchange}
 | 
			
		||||
							>
 | 
			
		||||
								🔍🌐 Use publicly advertised peers
 | 
			
		||||
							</button>
 | 
			
		||||
						`
 | 
			
		||||
					: undefined}
 | 
			
		||||
				${this.connections
 | 
			
		||||
					.filter((x) => x.id)
 | 
			
		||||
					.map(
 | 
			
		||||
						(x) => html`
 | 
			
		||||
							<tf-user
 | 
			
		||||
								class="w3-bar-item"
 | 
			
		||||
								style=${x.destroy_reason
 | 
			
		||||
									? 'border-left: 4px solid red; border-right: 4px solid red'
 | 
			
		||||
									: x.connected
 | 
			
		||||
										? x.flags?.one_shot
 | 
			
		||||
											? 'border-left: 4px solid blue; border-right: 4px solid blue'
 | 
			
		||||
											: 'border-left: 4px solid green; border-right: 4px solid green'
 | 
			
		||||
										: ''}
 | 
			
		||||
								id=${x.id}
 | 
			
		||||
								fallback_name=${x.host}
 | 
			
		||||
								.users=${this.users}
 | 
			
		||||
							></tf-user>
 | 
			
		||||
						`
 | 
			
		||||
					)}
 | 
			
		||||
				<h4 class="w3-bar-item w3-theme-d2">Suggested Follows</h4>
 | 
			
		||||
				${this.suggested_follows().map(
 | 
			
		||||
					(x) => html`
 | 
			
		||||
						<tf-user
 | 
			
		||||
							class="w3-bar-item"
 | 
			
		||||
							style="max-width: 100%"
 | 
			
		||||
							id=${x}
 | 
			
		||||
							.users=${this.users}
 | 
			
		||||
						></tf-user>
 | 
			
		||||
					`
 | 
			
		||||
				)}
 | 
			
		||||
			</div>
 | 
			
		||||
			<div
 | 
			
		||||
				class="w3-overlay"
 | 
			
		||||
				id="sidebar_overlay"
 | 
			
		||||
				@click=${this.hide_sidebar}
 | 
			
		||||
			></div>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let profile =
 | 
			
		||||
			this.hash.startsWith('#@') && this.hash != '#@'
 | 
			
		||||
				? keyed(
 | 
			
		||||
						this.hash.substring(1),
 | 
			
		||||
						html`<tf-profile
 | 
			
		||||
							class="tf-profile"
 | 
			
		||||
							id=${this.hash.substring(1)}
 | 
			
		||||
							whoami=${this.whoami}
 | 
			
		||||
							.users=${this.users}
 | 
			
		||||
						></tf-profile>`
 | 
			
		||||
					)
 | 
			
		||||
				: undefined;
 | 
			
		||||
		let edit_profile;
 | 
			
		||||
		if (
 | 
			
		||||
			!this.loading &&
 | 
			
		||||
			this.users[this.whoami]?.name === undefined &&
 | 
			
		||||
			this.hash.substring(1) != this.whoami
 | 
			
		||||
		) {
 | 
			
		||||
			edit_profile = html` <div
 | 
			
		||||
				class="w3-panel w3-padding w3-round w3-card-4 w3-theme-l3"
 | 
			
		||||
			>
 | 
			
		||||
				ℹ️ Follow your identity link ☝️ above to edit your profile and set your
 | 
			
		||||
				name.
 | 
			
		||||
			</div>`;
 | 
			
		||||
		}
 | 
			
		||||
		return cache(html`
 | 
			
		||||
			<style>
 | 
			
		||||
				${generate_theme()}
 | 
			
		||||
			</style>
 | 
			
		||||
			${this.render_sidebar()}
 | 
			
		||||
			<div
 | 
			
		||||
				style="margin-left: 2in; padding: 0px; top: 0; height: 100vh; max-height: 100%; overflow: auto; contain: layout"
 | 
			
		||||
				id="main"
 | 
			
		||||
				class="w3-main"
 | 
			
		||||
			>
 | 
			
		||||
				<div style="padding: 8px">
 | 
			
		||||
					<p>
 | 
			
		||||
						${this.hash.startsWith('##')
 | 
			
		||||
							? html`
 | 
			
		||||
									<button
 | 
			
		||||
										class="w3-button w3-theme-d1"
 | 
			
		||||
										@click=${this.channel_toggle_subscribed}
 | 
			
		||||
									>
 | 
			
		||||
										${this.channels.indexOf(this.hash.substring(2)) != -1
 | 
			
		||||
											? 'Unsubscribe from #'
 | 
			
		||||
											: 'Subscribe to #'}${this.hash.substring(2)}
 | 
			
		||||
									</button>
 | 
			
		||||
								`
 | 
			
		||||
							: undefined}
 | 
			
		||||
					</p>
 | 
			
		||||
					<div>
 | 
			
		||||
						<div
 | 
			
		||||
							id="show_sidebar"
 | 
			
		||||
							class="w3-button w3-hide-large"
 | 
			
		||||
							@click=${this.show_sidebar}
 | 
			
		||||
						>
 | 
			
		||||
							${this.unread_status()}☰
 | 
			
		||||
						</div>
 | 
			
		||||
						Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!
 | 
			
		||||
						${edit_profile}
 | 
			
		||||
					</div>
 | 
			
		||||
					<div>
 | 
			
		||||
						<tf-compose
 | 
			
		||||
							id="tf-compose"
 | 
			
		||||
							whoami=${this.whoami}
 | 
			
		||||
							.users=${this.users}
 | 
			
		||||
							.drafts=${this.drafts}
 | 
			
		||||
							@tf-draft=${this.draft}
 | 
			
		||||
							.channel=${this.channel()}
 | 
			
		||||
							.recipients=${this.hash.startsWith('#🔐')
 | 
			
		||||
								? this.hash.substring('#🔐'.length).split(',')
 | 
			
		||||
								: undefined}
 | 
			
		||||
						></tf-compose>
 | 
			
		||||
					</div>
 | 
			
		||||
					${profile}
 | 
			
		||||
					<tf-tab-news-feed
 | 
			
		||||
						id="news"
 | 
			
		||||
						whoami=${this.whoami}
 | 
			
		||||
						.users=${this.users}
 | 
			
		||||
						.following=${this.following}
 | 
			
		||||
						hash=${this.hash}
 | 
			
		||||
						.drafts=${this.drafts}
 | 
			
		||||
						.expanded=${this.expanded}
 | 
			
		||||
						@tf-draft=${this.draft}
 | 
			
		||||
						@tf-expand=${this.on_expand}
 | 
			
		||||
						.channels_unread=${this.channels_unread}
 | 
			
		||||
						.channels_latest=${this.channels_latest}
 | 
			
		||||
						.private_messages=${this.private_messages}
 | 
			
		||||
						.grouped_private_messages=${this.grouped_private_messages}
 | 
			
		||||
						.recent_reactions=${this.recent_reactions}
 | 
			
		||||
					></tf-tab-news-feed>
 | 
			
		||||
				</div>
 | 
			
		||||
		let profile = this.hash.startsWith('#@')
 | 
			
		||||
			? html`<tf-profile
 | 
			
		||||
					id=${this.hash.substring(1)}
 | 
			
		||||
					whoami=${this.whoami}
 | 
			
		||||
					.users=${this.users}
 | 
			
		||||
				></tf-profile>`
 | 
			
		||||
			: undefined;
 | 
			
		||||
		return html`
 | 
			
		||||
			<p class="w3-bar">
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-bar-item w3-button w3-dark-grey"
 | 
			
		||||
					@click=${this.show_more}
 | 
			
		||||
				>
 | 
			
		||||
					${this.new_messages_text()}
 | 
			
		||||
				</button>
 | 
			
		||||
			</p>
 | 
			
		||||
			<div>
 | 
			
		||||
				Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!
 | 
			
		||||
			</div>
 | 
			
		||||
		`);
 | 
			
		||||
			<div>
 | 
			
		||||
				<tf-compose
 | 
			
		||||
					id="tf-compose"
 | 
			
		||||
					whoami=${this.whoami}
 | 
			
		||||
					.users=${this.users}
 | 
			
		||||
					.drafts=${this.drafts}
 | 
			
		||||
					@tf-draft=${this.draft}
 | 
			
		||||
				></tf-compose>
 | 
			
		||||
			</div>
 | 
			
		||||
			${profile}
 | 
			
		||||
			<tf-tab-news-feed
 | 
			
		||||
				id="news"
 | 
			
		||||
				whoami=${this.whoami}
 | 
			
		||||
				.users=${this.users}
 | 
			
		||||
				.following=${this.following}
 | 
			
		||||
				hash=${this.hash}
 | 
			
		||||
				.drafts=${this.drafts}
 | 
			
		||||
				.expanded=${this.expanded}
 | 
			
		||||
				@tf-draft=${this.draft}
 | 
			
		||||
				@tf-expand=${this.on_expand}
 | 
			
		||||
			></tf-tab-news-feed>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										136
									
								
								apps/ssb/tf-tab-query.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								apps/ssb/tf-tab-query.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,136 @@
 | 
			
		||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfTabQueryElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			whoami: {type: String},
 | 
			
		||||
			users: {type: Object},
 | 
			
		||||
			following: {type: Array},
 | 
			
		||||
			query: {type: String},
 | 
			
		||||
			expanded: {type: Object},
 | 
			
		||||
			results: {type: Array},
 | 
			
		||||
			error: {type: Object},
 | 
			
		||||
			duration: {type: Number},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static styles = styles;
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		let self = this;
 | 
			
		||||
		this.whoami = null;
 | 
			
		||||
		this.users = {};
 | 
			
		||||
		this.following = [];
 | 
			
		||||
		this.expanded = {};
 | 
			
		||||
		this.duration = undefined;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async search(query) {
 | 
			
		||||
		console.log('Searching...', this.whoami, query);
 | 
			
		||||
		this.results = [];
 | 
			
		||||
		this.error = undefined;
 | 
			
		||||
		this.duration = undefined;
 | 
			
		||||
		let search = this.renderRoot.getElementById('search');
 | 
			
		||||
		if (search) {
 | 
			
		||||
			search.value = query;
 | 
			
		||||
			search.focus();
 | 
			
		||||
		}
 | 
			
		||||
		await tfrpc.rpc.setHash('#sql=' + encodeURIComponent(query));
 | 
			
		||||
		let start_time = new Date();
 | 
			
		||||
		try {
 | 
			
		||||
			this.results = await tfrpc.rpc.query(query, []);
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			this.error = error;
 | 
			
		||||
		}
 | 
			
		||||
		let end_time = new Date();
 | 
			
		||||
		this.duration = (end_time - start_time).valueOf();
 | 
			
		||||
		console.log('Done.');
 | 
			
		||||
		search = this.renderRoot.getElementById('search');
 | 
			
		||||
		if (search) {
 | 
			
		||||
			search.value = query;
 | 
			
		||||
			search.focus();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	search_keydown(event) {
 | 
			
		||||
		if (event.keyCode == 13 && event.ctrlKey) {
 | 
			
		||||
			this.query = this.renderRoot.getElementById('search').value;
 | 
			
		||||
			event.preventDefault();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	on_expand(event) {
 | 
			
		||||
		if (event.detail.expanded) {
 | 
			
		||||
			let expand = {};
 | 
			
		||||
			expand[event.detail.id] = true;
 | 
			
		||||
			this.expanded = Object.assign({}, this.expanded, expand);
 | 
			
		||||
		} else {
 | 
			
		||||
			delete this.expanded[event.detail.id];
 | 
			
		||||
			this.expanded = Object.assign({}, this.expanded);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_results() {
 | 
			
		||||
		if (!this.results?.length) {
 | 
			
		||||
			return html`<div>No results.</div>`;
 | 
			
		||||
		} else {
 | 
			
		||||
			let keys = Object.keys(this.results[0]).sort();
 | 
			
		||||
			return html`<table style="width: 100%; max-width: 100%">
 | 
			
		||||
				<tr>
 | 
			
		||||
					${keys.map((key) => html`<th>${key}</th>`)}
 | 
			
		||||
				</tr>
 | 
			
		||||
				${this.results.map(
 | 
			
		||||
					(row) =>
 | 
			
		||||
						html`<tr>
 | 
			
		||||
							${keys.map((key) => html`<td>${row[key]}</td>`)}
 | 
			
		||||
						</tr>`
 | 
			
		||||
				)}
 | 
			
		||||
			</table>`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_error() {
 | 
			
		||||
		if (this.error) {
 | 
			
		||||
			return html`<h2 style="color: red">${this.error.message}</h2>
 | 
			
		||||
				<pre style="color: red">${this.error.stack}</pre>`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		if (this.query !== this.last_query) {
 | 
			
		||||
			this.last_query = this.query;
 | 
			
		||||
			this.search(this.query);
 | 
			
		||||
		}
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return html`
 | 
			
		||||
			<div style="display: flex; flex-direction: row; gap: 4px">
 | 
			
		||||
				<textarea
 | 
			
		||||
					id="search"
 | 
			
		||||
					rows="8"
 | 
			
		||||
					class="w3-input w3-dark-grey"
 | 
			
		||||
					style="flex: 1; resize: vertical"
 | 
			
		||||
					@keydown=${this.search_keydown}
 | 
			
		||||
				>
 | 
			
		||||
${this.query}</textarea
 | 
			
		||||
				>
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-dark-grey"
 | 
			
		||||
					@click=${(event) =>
 | 
			
		||||
						self.search(self.renderRoot.getElementById('search').value)}
 | 
			
		||||
				>
 | 
			
		||||
					Execute
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div ?hidden=${this.duration === undefined}>
 | 
			
		||||
				Took ${this.duration / 1000.0} seconds.
 | 
			
		||||
			</div>
 | 
			
		||||
			<div ?hidden=${this.duration !== undefined}>Executing...</div>
 | 
			
		||||
			${this.render_error()} ${this.render_results()}
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-tab-query', TfTabQueryElement);
 | 
			
		||||
@@ -1,19 +1,15 @@
 | 
			
		||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfTabSearchElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			drafts: {type: Object},
 | 
			
		||||
			whoami: {type: String},
 | 
			
		||||
			users: {type: Object},
 | 
			
		||||
			following: {type: Array},
 | 
			
		||||
			query: {type: String},
 | 
			
		||||
			expanded: {type: Object},
 | 
			
		||||
			messages: {type: Array},
 | 
			
		||||
			results: {type: Array},
 | 
			
		||||
			error: {type: Object},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -26,10 +22,6 @@ class TfTabSearchElement extends LitElement {
 | 
			
		||||
		this.users = {};
 | 
			
		||||
		this.following = [];
 | 
			
		||||
		this.expanded = {};
 | 
			
		||||
		this.drafts = {};
 | 
			
		||||
		tfrpc.rpc.localStorageGet('drafts').then(function (d) {
 | 
			
		||||
			self.drafts = JSON.parse(d || '{}');
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async search(query) {
 | 
			
		||||
@@ -41,40 +33,24 @@ class TfTabSearchElement extends LitElement {
 | 
			
		||||
			search.select();
 | 
			
		||||
		}
 | 
			
		||||
		await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query));
 | 
			
		||||
		this.error = undefined;
 | 
			
		||||
		this.results = [];
 | 
			
		||||
		this.messages = [];
 | 
			
		||||
		if (query.startsWith('sql:')) {
 | 
			
		||||
			this.messages = [];
 | 
			
		||||
			try {
 | 
			
		||||
				this.results = await tfrpc.rpc.query(
 | 
			
		||||
					query.substring('sql:'.length),
 | 
			
		||||
					[]
 | 
			
		||||
				);
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				this.results = [];
 | 
			
		||||
				this.error = e;
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			let results = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
					SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
					FROM messages_fts(?)
 | 
			
		||||
					JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
					JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
					ORDER BY timestamp DESC limit 100
 | 
			
		||||
				`,
 | 
			
		||||
				['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)]
 | 
			
		||||
			);
 | 
			
		||||
			console.log('Done.');
 | 
			
		||||
			search = this.renderRoot.getElementById('search');
 | 
			
		||||
			if (search) {
 | 
			
		||||
				search.value = query;
 | 
			
		||||
				search.focus();
 | 
			
		||||
				search.select();
 | 
			
		||||
			}
 | 
			
		||||
			this.messages = results;
 | 
			
		||||
		let results = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
				FROM messages_fts(?)
 | 
			
		||||
				JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
				JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
				ORDER BY timestamp DESC limit 100
 | 
			
		||||
			`,
 | 
			
		||||
			['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)]
 | 
			
		||||
		);
 | 
			
		||||
		console.log('Done.');
 | 
			
		||||
		search = this.renderRoot.getElementById('search');
 | 
			
		||||
		if (search) {
 | 
			
		||||
			search.value = query;
 | 
			
		||||
			search.focus();
 | 
			
		||||
			search.select();
 | 
			
		||||
		}
 | 
			
		||||
		this.renderRoot.getElementById('news').messages = results;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	search_keydown(event) {
 | 
			
		||||
@@ -94,51 +70,6 @@ class TfTabSearchElement extends LitElement {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	draft(event) {
 | 
			
		||||
		let id = event.detail.id || '';
 | 
			
		||||
		let previous = this.drafts[id];
 | 
			
		||||
		if (event.detail.draft !== undefined) {
 | 
			
		||||
			this.drafts[id] = event.detail.draft;
 | 
			
		||||
		} else {
 | 
			
		||||
			delete this.drafts[id];
 | 
			
		||||
		}
 | 
			
		||||
		this.drafts = Object.assign({}, this.drafts);
 | 
			
		||||
		tfrpc.rpc.localStorageSet('drafts', JSON.stringify(this.drafts));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_results() {
 | 
			
		||||
		if (this.error) {
 | 
			
		||||
			return html`<h2 style="color: red">${this.error.message}</h2>
 | 
			
		||||
				<pre style="color: red">${this.error.stack}</pre>`;
 | 
			
		||||
		} else if (this.messages?.length) {
 | 
			
		||||
			return html`<tf-news
 | 
			
		||||
				id="news"
 | 
			
		||||
				whoami=${this.whoami}
 | 
			
		||||
				.messages=${this.messages}
 | 
			
		||||
				.users=${this.users}
 | 
			
		||||
				.expanded=${this.expanded}
 | 
			
		||||
				.drafts=${this.drafts}
 | 
			
		||||
				@tf-expand=${this.on_expand}
 | 
			
		||||
				@tf-draft=${this.draft}
 | 
			
		||||
			></tf-news>`;
 | 
			
		||||
		} else if (this.results?.length) {
 | 
			
		||||
			let keys = Object.keys(this.results[0]).sort();
 | 
			
		||||
			return html`<table style="width: 100%; max-width: 100%">
 | 
			
		||||
				<tr>
 | 
			
		||||
					${keys.map((key) => html`<th>${key}</th>`)}
 | 
			
		||||
				</tr>
 | 
			
		||||
				${this.results.map(
 | 
			
		||||
					(row) =>
 | 
			
		||||
						html`<tr>
 | 
			
		||||
							${keys.map((key) => html`<td>${row[key]}</td>`)}
 | 
			
		||||
						</tr>`
 | 
			
		||||
				)}
 | 
			
		||||
			</table>`;
 | 
			
		||||
		} else {
 | 
			
		||||
			return html`<div>No results.</div>`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		if (this.query !== this.last_query) {
 | 
			
		||||
			this.last_query = this.query;
 | 
			
		||||
@@ -146,14 +77,11 @@ class TfTabSearchElement extends LitElement {
 | 
			
		||||
		}
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return html`
 | 
			
		||||
			<style>${generate_theme()}</style>
 | 
			
		||||
			<div class="w3-padding">
 | 
			
		||||
				<div style="display: flex; flex-direction: row; gap: 4px">
 | 
			
		||||
					<input type="text" class="w3-input w3-theme-d1" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input>
 | 
			
		||||
					<button class="w3-button w3-theme-d1" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Search</button>
 | 
			
		||||
				</div>
 | 
			
		||||
				${this.render_results()}
 | 
			
		||||
			<div style="display: flex; flex-direction: row; gap: 4px">
 | 
			
		||||
				<input type="text" class="w3-input w3-dark-grey" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input>
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Search</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users} .expanded=${this.expanded} @tf-expand=${this.on_expand}></tf-news>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfTagElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
@@ -17,15 +17,11 @@ class TfTagElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let number = this.count ? html` (${this.count})` : undefined;
 | 
			
		||||
		return html`
 | 
			
		||||
			<style>
 | 
			
		||||
				${generate_theme()}</style
 | 
			
		||||
			><a
 | 
			
		||||
				href=${'#' + encodeURIComponent(this.tag)}
 | 
			
		||||
				class="w3-tag w3-theme-d1 w3-round-4 w3-button"
 | 
			
		||||
				>${this.tag}${number}</a
 | 
			
		||||
			>
 | 
			
		||||
		`;
 | 
			
		||||
		return html`<a
 | 
			
		||||
			href="#q=${this.tag}"
 | 
			
		||||
			style="display: inline-block; margin: 3px; border: 1px solid black; background-color: #444; padding: 4px; border-radius: 3px"
 | 
			
		||||
			>${this.tag}${number}</a
 | 
			
		||||
		>`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,15 +1,12 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {styles, generate_theme} from './tf-styles.js';
 | 
			
		||||
import {styles} from './tf-styles.js';
 | 
			
		||||
 | 
			
		||||
class TfUserElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			id: {type: String},
 | 
			
		||||
			fallback_name: {type: String},
 | 
			
		||||
			icon_only: {type: Boolean},
 | 
			
		||||
			users: {type: Object},
 | 
			
		||||
			nolink: {type: Boolean},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -18,55 +15,32 @@ class TfUserElement extends LitElement {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.id = null;
 | 
			
		||||
		this.fallback_name = null;
 | 
			
		||||
		this.icon_only = false;
 | 
			
		||||
		this.users = {};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let user = this.users[this.id];
 | 
			
		||||
		let shape =
 | 
			
		||||
			user?.follow_depth === undefined || user.follow_depth >= 2
 | 
			
		||||
				? 'w3-circle'
 | 
			
		||||
				: 'w3-round';
 | 
			
		||||
		let image = html`<span
 | 
			
		||||
			class=${'w3-theme-l4 ' + shape}
 | 
			
		||||
			style="display: inline-block; width: 2em; height: 2em; text-align: center; line-height: 2em"
 | 
			
		||||
			>😎</span
 | 
			
		||||
		>`;
 | 
			
		||||
		let name = this.users?.[this.id]?.name;
 | 
			
		||||
		let name_string = name ?? this.fallback_name ?? this.id;
 | 
			
		||||
		name = this.icon_only
 | 
			
		||||
			? undefined
 | 
			
		||||
			: !this.nolink
 | 
			
		||||
				? html`<a target="_top" href=${'#' + this.id}>${name_string}</a>`
 | 
			
		||||
				: html`<span>${name_string}</span>`;
 | 
			
		||||
		name =
 | 
			
		||||
			name !== undefined
 | 
			
		||||
				? html`<a target="_top" href=${'#' + this.id}>${name}</a>`
 | 
			
		||||
				: html`<a target="_top" href=${'#' + this.id}>${this.id}</a>`;
 | 
			
		||||
 | 
			
		||||
		if (user) {
 | 
			
		||||
			let image_link = user.image;
 | 
			
		||||
			if (typeof image_link == 'string' && !image_link.startsWith('&')) {
 | 
			
		||||
				try {
 | 
			
		||||
					image_link = JSON.parse(image_link)?.link;
 | 
			
		||||
				} catch {}
 | 
			
		||||
			}
 | 
			
		||||
			if (image_link !== undefined) {
 | 
			
		||||
				image = html`<img
 | 
			
		||||
					class=${'w3-theme-l4 ' + shape}
 | 
			
		||||
					style="width: 2em; height: 2em; vertical-align: middle; object-fit: cover"
 | 
			
		||||
					src="/${image_link}/view"
 | 
			
		||||
					title=${name_string + ' (' + this.id + ')'}
 | 
			
		||||
				/>`;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return html` <style>
 | 
			
		||||
				${generate_theme()}
 | 
			
		||||
			</style>
 | 
			
		||||
			<div
 | 
			
		||||
				style=${'display: inline-block; vertical-align: middle; text-wrap: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis' +
 | 
			
		||||
				(this.nolink ? '' : '; font-weight: bold')}
 | 
			
		||||
			>
 | 
			
		||||
				${image} ${name}
 | 
			
		||||
		if (this.users[this.id]) {
 | 
			
		||||
			let image = this.users[this.id].image;
 | 
			
		||||
			image = typeof image == 'string' ? image : image?.link;
 | 
			
		||||
			return html` <div style="display: inline-block; font-weight: bold">
 | 
			
		||||
				<img
 | 
			
		||||
					style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%"
 | 
			
		||||
					?hidden=${image === undefined}
 | 
			
		||||
					src="${image ? '/' + image + '/view' : undefined}"
 | 
			
		||||
				/>
 | 
			
		||||
				${name}
 | 
			
		||||
			</div>`;
 | 
			
		||||
		} else {
 | 
			
		||||
			return html` <div style="display: inline-block; font-weight: bold">
 | 
			
		||||
				${name}
 | 
			
		||||
			</div>`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,6 @@
 | 
			
		||||
import * as linkify from './commonmark-linkify.js';
 | 
			
		||||
import * as hashtagify from './commonmark-hashtag.js';
 | 
			
		||||
 | 
			
		||||
const k_code_classes = 'w3-theme-l4 w3-theme-border w3-round';
 | 
			
		||||
 | 
			
		||||
var reUnsafeProtocol = /^javascript:|vbscript:|file:|data:/i;
 | 
			
		||||
var reSafeDataProtocol = /^data:image\/(?:png|gif|jpeg|webp)/i;
 | 
			
		||||
var potentiallyUnsafe = function (url) {
 | 
			
		||||
	return reUnsafeProtocol.test(url) && !reSafeDataProtocol.test(url);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function image(node, entering) {
 | 
			
		||||
	if (
 | 
			
		||||
		node.firstChild?.type === 'text' &&
 | 
			
		||||
@@ -50,9 +43,9 @@ function image(node, entering) {
 | 
			
		||||
						'</div>'
 | 
			
		||||
				);
 | 
			
		||||
				if (this.options.safe && potentiallyUnsafe(node.destination)) {
 | 
			
		||||
					this.lit('<img src="" title="');
 | 
			
		||||
					this.lit('<img src="" alt="');
 | 
			
		||||
				} else {
 | 
			
		||||
					this.lit('<img src="' + this.esc(node.destination) + '" title="');
 | 
			
		||||
					this.lit('<img src="' + this.esc(node.destination) + '" alt="');
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			this.disableTags += 1;
 | 
			
		||||
@@ -68,32 +61,13 @@ function image(node, entering) {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function code(node) {
 | 
			
		||||
	let attrs = this.attrs(node);
 | 
			
		||||
	attrs.push(['class', k_code_classes]);
 | 
			
		||||
	this.tag('code', attrs);
 | 
			
		||||
	this.out(node.literal);
 | 
			
		||||
	this.tag('/code');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function attrs(node) {
 | 
			
		||||
	let result = commonmark.HtmlRenderer.prototype.attrs.bind(this)(node);
 | 
			
		||||
	if (node.type == 'block_quote') {
 | 
			
		||||
		result.push(['class', 'w3-theme-d1']);
 | 
			
		||||
	} else if (node.type == 'code_block') {
 | 
			
		||||
		result.push(['class', k_code_classes]);
 | 
			
		||||
	}
 | 
			
		||||
	return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function markdown(md) {
 | 
			
		||||
	let reader = new commonmark.Parser();
 | 
			
		||||
	let writer = new commonmark.HtmlRenderer({safe: true});
 | 
			
		||||
	let reader = new commonmark.Parser({safe: true});
 | 
			
		||||
	let writer = new commonmark.HtmlRenderer();
 | 
			
		||||
	writer.image = image;
 | 
			
		||||
	writer.code = code;
 | 
			
		||||
	writer.attrs = attrs;
 | 
			
		||||
	let parsed = reader.parse(md || '');
 | 
			
		||||
	parsed = hashtagify.transform(parsed);
 | 
			
		||||
	parsed = linkify.transform(parsed);
 | 
			
		||||
	let walker = parsed.walker();
 | 
			
		||||
	let event, node;
 | 
			
		||||
	while ((event = walker.next())) {
 | 
			
		||||
@@ -104,12 +78,12 @@ export function markdown(md) {
 | 
			
		||||
					node.destination.startsWith('@') &&
 | 
			
		||||
					node.destination.endsWith('.ed25519')
 | 
			
		||||
				) {
 | 
			
		||||
					node.destination = '#' + encodeURIComponent(node.destination);
 | 
			
		||||
					node.destination = '#' + node.destination;
 | 
			
		||||
				} else if (
 | 
			
		||||
					node.destination.startsWith('%') &&
 | 
			
		||||
					node.destination.endsWith('.sha256')
 | 
			
		||||
				) {
 | 
			
		||||
					node.destination = '#' + encodeURIComponent(node.destination);
 | 
			
		||||
					node.destination = '#' + node.destination;
 | 
			
		||||
				} else if (
 | 
			
		||||
					node.destination.startsWith('&') &&
 | 
			
		||||
					node.destination.endsWith('.sha256')
 | 
			
		||||
 
 | 
			
		||||
@@ -482,7 +482,16 @@ class TributeRange {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getDocument() {
 | 
			
		||||
        return document;
 | 
			
		||||
        let iframe;
 | 
			
		||||
        if (this.tribute.current.collection) {
 | 
			
		||||
            iframe = this.tribute.current.collection.iframe;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!iframe) {
 | 
			
		||||
            return document
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return iframe.contentWindow.document
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    positionMenuAtCaret(scrollTo) {
 | 
			
		||||
@@ -644,8 +653,8 @@ class TributeRange {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getWindowSelection() {
 | 
			
		||||
        if (this.tribute.collection[0].iframe?.getSelection) {
 | 
			
		||||
            return this.tribute.collection[0].iframe.getSelection()
 | 
			
		||||
        if (this.tribute.collection.iframe) {
 | 
			
		||||
            return this.tribute.collection.iframe.contentWindow.getSelection()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return window.getSelection()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "💾",
 | 
			
		||||
	"previous": "&tzZFIe7Y54O4sx1QtAPdemkXh+p5qHXSG/dlS7NP6OQ=.sha256"
 | 
			
		||||
}
 | 
			
		||||
@@ -1,126 +0,0 @@
 | 
			
		||||
async function query(sql, args) {
 | 
			
		||||
	let rows = [];
 | 
			
		||||
	await ssb.sqlAsync(sql, args ?? [], function (row) {
 | 
			
		||||
		rows.push(row);
 | 
			
		||||
	});
 | 
			
		||||
	return rows;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function get_biggest() {
 | 
			
		||||
	return query(`
 | 
			
		||||
		select author, size from messages_stats group by author order by size desc limit 10;
 | 
			
		||||
	`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function get_total() {
 | 
			
		||||
	return (
 | 
			
		||||
		await query(`
 | 
			
		||||
		select sum(length(content)) as size, count(distinct author) as count from messages;
 | 
			
		||||
	`)
 | 
			
		||||
	)[0];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function get_names(identities) {
 | 
			
		||||
	return query(
 | 
			
		||||
		`
 | 
			
		||||
		SELECT author, name FROM (
 | 
			
		||||
			SELECT
 | 
			
		||||
				messages.author,
 | 
			
		||||
				RANK() OVER (PARTITION BY messages.author ORDER BY messages.sequence DESC) AS author_rank,
 | 
			
		||||
				messages.content ->> 'name' AS name
 | 
			
		||||
			FROM messages
 | 
			
		||||
			JOIN json_each(?) AS identities ON identities.value = messages.author
 | 
			
		||||
			WHERE
 | 
			
		||||
				json_extract(messages.content, '$.type') = 'about' AND
 | 
			
		||||
				content ->> 'about' = messages.author AND name IS NOT NULL)
 | 
			
		||||
		WHERE author_rank = 1
 | 
			
		||||
	`,
 | 
			
		||||
		[JSON.stringify(identities)]
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function get_most_follows() {
 | 
			
		||||
	return query(`
 | 
			
		||||
		select author, count(*) as count
 | 
			
		||||
		from messages
 | 
			
		||||
		where content ->> 'type' = 'contact' and content ->> 'following' = true
 | 
			
		||||
		group by author
 | 
			
		||||
		order by count desc
 | 
			
		||||
		limit 10;
 | 
			
		||||
	`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function nice_size(bytes) {
 | 
			
		||||
	let value = bytes;
 | 
			
		||||
	let index = 0;
 | 
			
		||||
	let units = ['B', 'kB', 'MB', 'GB'];
 | 
			
		||||
	while (value > 1024 && index < units.length - 1) {
 | 
			
		||||
		value /= 1024;
 | 
			
		||||
		index++;
 | 
			
		||||
	}
 | 
			
		||||
	return `${Math.round(value * 10) / 10} ${units[index]}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	await app.setDocument('<p style="color: #fff">Analyzing feeds...</p>');
 | 
			
		||||
	let most_follows = get_most_follows();
 | 
			
		||||
	let total = await get_total();
 | 
			
		||||
	let identities = await ssb.getAllIdentities();
 | 
			
		||||
	let following1 = await ssb.following(identities, 1);
 | 
			
		||||
	let following2 = await ssb.following(identities, 2);
 | 
			
		||||
	let biggest = await get_biggest();
 | 
			
		||||
	most_follows = await most_follows;
 | 
			
		||||
	let names = await get_names(
 | 
			
		||||
		[].concat(
 | 
			
		||||
			biggest.map((x) => x.author),
 | 
			
		||||
			most_follows.map((x) => x.author)
 | 
			
		||||
		)
 | 
			
		||||
	);
 | 
			
		||||
	names = Object.fromEntries(names.map((x) => [x.author, x.name]));
 | 
			
		||||
	for (let item of biggest) {
 | 
			
		||||
		item.name = names[item.author];
 | 
			
		||||
		item.following =
 | 
			
		||||
			identities.indexOf(item.author) != -1
 | 
			
		||||
				? 0
 | 
			
		||||
				: following1[item.author] !== undefined
 | 
			
		||||
					? 1
 | 
			
		||||
					: following2[item.author] !== undefined
 | 
			
		||||
						? 2
 | 
			
		||||
						: undefined;
 | 
			
		||||
	}
 | 
			
		||||
	for (let item of most_follows) {
 | 
			
		||||
		item.name = names[item.author];
 | 
			
		||||
	}
 | 
			
		||||
	let html = `<body style="color: #000; background-color: #ddd">\n
 | 
			
		||||
		<h1>Storage Summary</h1>
 | 
			
		||||
		<h2>Top Accounts by Size</h2>
 | 
			
		||||
		<ol>`;
 | 
			
		||||
	for (let item of biggest) {
 | 
			
		||||
		html += `<li>
 | 
			
		||||
			<span style="color: #888">${nice_size(item.size)}</span>
 | 
			
		||||
			<a target="_top" href="/~core/ssb/#${encodeURI(item.author)}">${item.name ?? item.author}</a>
 | 
			
		||||
		</li>
 | 
			
		||||
		\n`;
 | 
			
		||||
	}
 | 
			
		||||
	html += `
 | 
			
		||||
		</ol>
 | 
			
		||||
		<h2>Top Accounts by Follows</h2>
 | 
			
		||||
		<ol>`;
 | 
			
		||||
	for (let item of most_follows) {
 | 
			
		||||
		html += `<li>
 | 
			
		||||
			<span style="color: #888">${item.count}</span>
 | 
			
		||||
			${following2[item.author] ? '✅' : '🚫'}
 | 
			
		||||
			<a target="_top" href="/~core/ssb/#${encodeURI(item.author)}">${item.name ?? item.author}</a>
 | 
			
		||||
		</li>
 | 
			
		||||
		\n`;
 | 
			
		||||
	}
 | 
			
		||||
	html += `
 | 
			
		||||
		</ol>
 | 
			
		||||
		<p>Total <span style="color: #888">${nice_size(total.size)}</span> in ${total.count} accounts.</p>
 | 
			
		||||
	`;
 | 
			
		||||
	await app.setDocument(html);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main().catch(function (e) {
 | 
			
		||||
	print(e);
 | 
			
		||||
});
 | 
			
		||||
@@ -1,4 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "📦"
 | 
			
		||||
}
 | 
			
		||||
@@ -1,3 +0,0 @@
 | 
			
		||||
app.setDocument(
 | 
			
		||||
	'<p style="color: #fff">Maybe one day this app will run tests, but for now there is nothing to see here.</p>'
 | 
			
		||||
);
 | 
			
		||||
@@ -1 +0,0 @@
 | 
			
		||||
Hello, world!
 | 
			
		||||
							
								
								
									
										4
									
								
								apps/user_settings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								apps/user_settings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "⚙️"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										50
									
								
								apps/user_settings/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								apps/user_settings/app.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
			
		||||
import * as tfrpc from '/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
tfrpc.register(async function getIdentities() {
 | 
			
		||||
	return ssb.getIdentities();
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function createID(id) {
 | 
			
		||||
	return await ssb.createIdentity();
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function getPrivateKey(id) {
 | 
			
		||||
	return bip39Words(await ssb.getPrivateKey(id));
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function getThemes() {
 | 
			
		||||
	// TODO
 | 
			
		||||
	return ['solarized', 'gruvbox', 'light'];
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function setTheme() {
 | 
			
		||||
	// TODO
 | 
			
		||||
	console.warn("setTheme called - not implemented")
 | 
			
		||||
	return null;
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function reload() {
 | 
			
		||||
	await main();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	// Get body.html
 | 
			
		||||
	const body = utf8Decode(await getFile('body.html'));
 | 
			
		||||
 | 
			
		||||
	// Build the document
 | 
			
		||||
	const document = `
 | 
			
		||||
	<!DOCTYPE html>
 | 
			
		||||
	<html>
 | 
			
		||||
		<head>
 | 
			
		||||
			<link rel="stylesheet" href="/static/tildefriends-v1.css"/>
 | 
			
		||||
			<script src="tf-theme-picker.js" type="module"></script>
 | 
			
		||||
			<script src="tf-password-form.js" type="module"></script>
 | 
			
		||||
			<script src="tf-delete-account-btn.js" type="module"></script>
 | 
			
		||||
			<script src="tf-identity-manager.js" type="module"></script>
 | 
			
		||||
		</head>
 | 
			
		||||
 | 
			
		||||
		<body class="flex-column">
 | 
			
		||||
			${body}
 | 
			
		||||
		</body>
 | 
			
		||||
	</html>`;
 | 
			
		||||
 | 
			
		||||
	// Send it to the browser
 | 
			
		||||
	app.setDocument(document);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
							
								
								
									
										20
									
								
								apps/user_settings/body.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								apps/user_settings/body.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
<h1>Your settings</h1>
 | 
			
		||||
 | 
			
		||||
<div class="box flex-column">
 | 
			
		||||
	<h2>Appearance</h2>
 | 
			
		||||
 | 
			
		||||
	<tf-theme-picker></tf-theme-picker>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="box flex-column">
 | 
			
		||||
	<h2>Danger Zone</h2>
 | 
			
		||||
 | 
			
		||||
	<h3>Manage your identities</h3>
 | 
			
		||||
	<tf-identity-manager></tf-identity-manager>
 | 
			
		||||
	
 | 
			
		||||
	<h3>Change my password</h3>
 | 
			
		||||
	<tf-password-form></tf-password-form>
 | 
			
		||||
 | 
			
		||||
	<h3>Delete your account</h3>
 | 
			
		||||
	<tf-delete-account-btn></tf-delete-account-btn>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										120
									
								
								apps/user_settings/lit-all.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								apps/user_settings/lit-all.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								apps/user_settings/lit-all.min.js.map
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/user_settings/lit-all.min.js.map
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								apps/user_settings/script.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/user_settings/script.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
/* */
 | 
			
		||||
							
								
								
									
										1
									
								
								apps/user_settings/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/user_settings/style.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
/* */
 | 
			
		||||
							
								
								
									
										36
									
								
								apps/user_settings/tf-delete-account-btn.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								apps/user_settings/tf-delete-account-btn.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
class TfDeleteAccountButtonElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	deleteAccount() {
 | 
			
		||||
		const res = confirm(
 | 
			
		||||
			'Are you really sure you want to delete your account ?'
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		if (!res) return;
 | 
			
		||||
 | 
			
		||||
		console.warn('TODO');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		return html`
 | 
			
		||||
			<link rel="stylesheet" href="/static/tildefriends-v1.css"/>
 | 
			
		||||
 | 
			
		||||
			<span>This action is irreversible !</span>
 | 
			
		||||
 | 
			
		||||
			<button class="red" @click=${this.deleteAccount}>
 | 
			
		||||
				[Not implemented] Delete my Tilde Friends account
 | 
			
		||||
			</button>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-delete-account-btn', TfDeleteAccountButtonElement);
 | 
			
		||||
							
								
								
									
										71
									
								
								apps/user_settings/tf-identity-manager.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								apps/user_settings/tf-identity-manager.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
class TfIdentityManagerElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			ids: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.ids = [];
 | 
			
		||||
		this.load();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load() {
 | 
			
		||||
		this.ids = await tfrpc.rpc.getIdentities();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async createIdentity() {
 | 
			
		||||
		try {
 | 
			
		||||
			let id = await tfrpc.rpc.createID();
 | 
			
		||||
			alert('Successfully created: ' + id);
 | 
			
		||||
			await tfrpc.rpc.reload();
 | 
			
		||||
		} catch (err) {
 | 
			
		||||
			alert('Error creating identity: ' + err);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async exportIdentity(id) {
 | 
			
		||||
		alert('Your private key is:\n' + (await tfrpc.rpc.getPrivateKey(id)));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		return html`
 | 
			
		||||
			<link rel="stylesheet" href="/static/tildefriends-v1.css"/>
 | 
			
		||||
			<style>
 | 
			
		||||
				.id-span {
 | 
			
		||||
					font-family: monospace;
 | 
			
		||||
					margin-left: 8px;
 | 
			
		||||
				}
 | 
			
		||||
			</style>
 | 
			
		||||
 | 
			
		||||
			<h4>Create a new identity</h4>
 | 
			
		||||
			<button id="create-id" class="green" @click=${this.createIdentity}>Create Identity</button>
 | 
			
		||||
 | 
			
		||||
			<h4>Import an SSB Identity from 12 BIP39 English Words</h4>
 | 
			
		||||
			<textarea id="add-id" style="width: 100%" rows="4"></textarea>
 | 
			
		||||
			<button class="green">[Not implemented] Import Identity</button>
 | 
			
		||||
 | 
			
		||||
			<h4>Warning !</h4>
 | 
			
		||||
			<strong>Anybody that has access to your private key can gain total access over your account.</strong>
 | 
			
		||||
			<br><br>
 | 
			
		||||
			Tilde Friends' contributors will never ask you for your private key !
 | 
			
		||||
 | 
			
		||||
			<ul>
 | 
			
		||||
			${this.ids.map(
 | 
			
		||||
				(id) =>
 | 
			
		||||
					html`
 | 
			
		||||
				<li>
 | 
			
		||||
					<button class="blue" @click=${() => this.exportIdentity(id)}>Export Identity</button>
 | 
			
		||||
					<button class="red">[Not implemented] Delete Identity</button>
 | 
			
		||||
					<span class="id-span">${id}</span>
 | 
			
		||||
				</li>`
 | 
			
		||||
			)}
 | 
			
		||||
			</ul>`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-identity-manager', TfIdentityManagerElement);
 | 
			
		||||
							
								
								
									
										68
									
								
								apps/user_settings/tf-password-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								apps/user_settings/tf-password-form.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,68 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
class TfPasswordFormElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			//selected: {type: String},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Checks a password against different requirements
 | 
			
		||||
	 * @param {string} password the password to validate
 | 
			
		||||
	 * @returns
 | 
			
		||||
	 */
 | 
			
		||||
	validatePassword(password) {
 | 
			
		||||
		// TODO(tasiaiso)
 | 
			
		||||
		return true;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	submitPassword() {
 | 
			
		||||
		const currentPwd = this.shadowRoot.getElementById('current').value;
 | 
			
		||||
		const newPwd = this.shadowRoot.getElementById('new').value;
 | 
			
		||||
		const repeatPwd = this.shadowRoot.getElementById('Repeat').value;
 | 
			
		||||
 | 
			
		||||
		if (!(newPwd === repeatPwd)) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// TODO
 | 
			
		||||
		// tfrpc.changePassword()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		return html`
 | 
			
		||||
			<link rel="stylesheet" href="/static/tildefriends-v1.css"/>
 | 
			
		||||
 | 
			
		||||
			<style>
 | 
			
		||||
				.grid {
 | 
			
		||||
					display: grid;
 | 
			
		||||
					grid-template-columns: auto auto;
 | 
			
		||||
				}
 | 
			
		||||
			</style>
 | 
			
		||||
 | 
			
		||||
			<div class="grid">
 | 
			
		||||
				<label for="current">Current password:</label>
 | 
			
		||||
				<input type="password" id="current" name="current" autocomplete="current-password" />
 | 
			
		||||
 | 
			
		||||
				<label for="new">Enter new password:</label>
 | 
			
		||||
				<input type="password" id="new" name="new" autocomplete="new-password" />
 | 
			
		||||
 | 
			
		||||
				<label for="repeat">Repeat new password:</label>
 | 
			
		||||
				<input type="password" id="repeat" name="repeat" autocomplete="new-password" />
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<button @click=${this.submitPassword} class="red">
 | 
			
		||||
				[Not implemented] Change my password
 | 
			
		||||
			</button>
 | 
			
		||||
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-password-form', TfPasswordFormElement);
 | 
			
		||||
							
								
								
									
										40
									
								
								apps/user_settings/tf-theme-picker.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								apps/user_settings/tf-theme-picker.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
class TfThemePickerElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			selected: {type: String},
 | 
			
		||||
			themes: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.load();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load() {
 | 
			
		||||
		this.themes = await tfrpc.rpc.getThemes();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	changed(event) {
 | 
			
		||||
		this.selected = event.srcElement.value;
 | 
			
		||||
		console.log('selected theme', this.selected);
 | 
			
		||||
		// TODO
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		return html`
 | 
			
		||||
			<link rel="stylesheet" href="/static/tildefriends-v1.css"/>
 | 
			
		||||
 | 
			
		||||
			<label for="theme">[Not implemented] Choose your theme:</label>
 | 
			
		||||
 | 
			
		||||
			<select name="theme" ?hidden=${!this.themes?.length} @change=${this.changed}>
 | 
			
		||||
				${(this.themes ?? []).map((id) => html`<option value=${id}>${id}</option>`)}
 | 
			
		||||
			</select>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-theme-picker', TfThemePickerElement);
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🕸",
 | 
			
		||||
	"previous": "&n7hu5b8/TsfiG6FDlCRG5nPCrIdCr96+xpIJ/aQT/uM=.sha256"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										100
									
								
								apps/web/app.js
									
									
									
									
									
								
							
							
						
						
									
										100
									
								
								apps/web/app.js
									
									
									
									
									
								
							@@ -1,100 +0,0 @@
 | 
			
		||||
let g_hash;
 | 
			
		||||
 | 
			
		||||
async function query(sql, params) {
 | 
			
		||||
	let results = [];
 | 
			
		||||
	await ssb.sqlAsync(sql, params, function (row) {
 | 
			
		||||
		results.push(row);
 | 
			
		||||
	});
 | 
			
		||||
	return results;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function resolve(id) {
 | 
			
		||||
	try {
 | 
			
		||||
		let blob = await ssb.blobGet(id);
 | 
			
		||||
		if (blob) {
 | 
			
		||||
			let json;
 | 
			
		||||
			try {
 | 
			
		||||
				json = JSON.parse(utf8Decode(blob));
 | 
			
		||||
			} catch {
 | 
			
		||||
				return {id: utf8Decode(blob)};
 | 
			
		||||
			}
 | 
			
		||||
			if (json?.links) {
 | 
			
		||||
				for (let [key, value] of Object.entries(json.links)) {
 | 
			
		||||
					json.links[key] = await resolve(value);
 | 
			
		||||
				}
 | 
			
		||||
				return json;
 | 
			
		||||
			} else {
 | 
			
		||||
				return 'huh?' + json;
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			return `missing<${id}>`;
 | 
			
		||||
		}
 | 
			
		||||
	} catch (e) {
 | 
			
		||||
		return id + ': ' + e.message;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function get_names(identities) {
 | 
			
		||||
	return Object.fromEntries(
 | 
			
		||||
		(
 | 
			
		||||
			await query(
 | 
			
		||||
				`
 | 
			
		||||
		SELECT author, name FROM (
 | 
			
		||||
			SELECT
 | 
			
		||||
				messages.author,
 | 
			
		||||
				RANK() OVER (PARTITION BY messages.author ORDER BY messages.sequence DESC) AS author_rank,
 | 
			
		||||
				messages.content ->> 'name' AS name
 | 
			
		||||
			FROM messages
 | 
			
		||||
			JOIN json_each(?) AS identities ON identities.value = messages.author
 | 
			
		||||
			WHERE
 | 
			
		||||
				json_extract(messages.content, '$.type') = 'about' AND
 | 
			
		||||
				content ->> 'about' = messages.author AND name IS NOT NULL)
 | 
			
		||||
		WHERE author_rank = 1
 | 
			
		||||
	`,
 | 
			
		||||
				[JSON.stringify(identities)]
 | 
			
		||||
			)
 | 
			
		||||
		).map((x) => [x.author, x.name])
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function render(hash) {
 | 
			
		||||
	g_hash = hash;
 | 
			
		||||
	if (!hash) {
 | 
			
		||||
		let sites = await query(
 | 
			
		||||
			`
 | 
			
		||||
			SELECT site.author, site.id
 | 
			
		||||
			FROM messages site
 | 
			
		||||
			WHERE site.content ->> 'type' = 'web-init'
 | 
			
		||||
		`,
 | 
			
		||||
			[]
 | 
			
		||||
		);
 | 
			
		||||
		let names = await get_names(sites.map((x) => x.author));
 | 
			
		||||
		if (hash === g_hash) {
 | 
			
		||||
			await app.setDocument(
 | 
			
		||||
				`<ul style="background-color: #ddd">${sites.map((x) => `<li><a target="_top" href="#${encodeURIComponent(x.id)}">${names[x.author] ?? x.author} - ${x.id}</a></li>`).join('\n')}</ul>`
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		let site_id =
 | 
			
		||||
			hash.charAt(0) == '#'
 | 
			
		||||
				? decodeURIComponent(hash.substring(1))
 | 
			
		||||
				: decodeURIComponent(hash);
 | 
			
		||||
		await app.setDocument(`<html style="margin: 0; padding: 0; width: 100vw; height: 100vh; margin: 0; padding: 0">
 | 
			
		||||
			<body style="display: flex; flex-direction: column; width: 100vw; height: 100vh">
 | 
			
		||||
				<iframe src="${encodeURIComponent(site_id)}/index.html" style="flex: 1 1; border: 0; background-color: #fff"></iframe>
 | 
			
		||||
			</body>
 | 
			
		||||
		</html>`);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
core.register('message', async function message_handler(message) {
 | 
			
		||||
	if (message.event == 'hashChange') {
 | 
			
		||||
		await render(message.hash);
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	render(null);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user