Compare commits
	
		
			7 Commits
		
	
	
		
			v0.2025.10
			...
			5c8c6e8760
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						5c8c6e8760
	
				 | 
					
					
						|||
| 
						
						
							
						
						85ac8080f4
	
				 | 
					
					
						|||
| 
						
						
							
						
						0751699bc8
	
				 | 
					
					
						|||
| 
						
						
							
						
						5551fd2dea
	
				 | 
					
					
						|||
| 
						
						
							
						
						69b2e2a955
	
				 | 
					
					
						|||
| 
						
						
							
						
						34c7fa8312
	
				 | 
					
					
						|||
| 
						
						
							
						
						396f37ee3b
	
				 | 
					
					
						
@@ -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
									
									
								
							
							
						
						@@ -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/
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -19,9 +19,3 @@
 | 
			
		||||
[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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								.markdownlint.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,5 @@
 | 
			
		||||
default: true
 | 
			
		||||
MD010: false # Ignore tabs in code blocks
 | 
			
		||||
MD013: false # Don't wrap lines by default
 | 
			
		||||
MD046: 
 | 
			
		||||
  style: "fenced" # Force fenced code blocks
 | 
			
		||||
@@ -2,7 +2,6 @@ node_modules
 | 
			
		||||
src
 | 
			
		||||
deps
 | 
			
		||||
.clang-format
 | 
			
		||||
flake.lock
 | 
			
		||||
 | 
			
		||||
# Minified files
 | 
			
		||||
**/*.min.css
 | 
			
		||||
@@ -13,3 +12,6 @@ flake.lock
 | 
			
		||||
apps/ssb/tribute.esm.js
 | 
			
		||||
apps/api/app.js
 | 
			
		||||
**/emojis.json
 | 
			
		||||
 | 
			
		||||
# only markdownlint should deal with the documentation
 | 
			
		||||
docs/**/*.md
 | 
			
		||||
 
 | 
			
		||||
@@ -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
									
									
									
									
									
								
							
							
						
						@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										762
									
								
								GNUmakefile
									
									
									
									
									
								
							
							
						
						
							
								
								
									
										2
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						@@ -1,4 +1,4 @@
 | 
			
		||||
Copyright 2014 Cory McWilliams
 | 
			
		||||
Copyright 2014-2024 Cory McWilliams
 | 
			
		||||
 | 
			
		||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
 | 
			
		||||
this software and associated documentation files (the "Software"), to deal in
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										66
									
								
								README.md
									
									
									
									
									
								
							
							
						
						@@ -1,70 +1,22 @@
 | 
			
		||||
# 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.
 | 
			
		||||
 | 
			
		||||
## 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.
 | 
			
		||||
 | 
			
		||||
### 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.
 | 
			
		||||
 | 
			
		||||
## Running
 | 
			
		||||
 | 
			
		||||
By default, running the built `out/debug/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
 | 
			
		||||
<http://localhost:12345/~core/admin/>.
 | 
			
		||||
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.
 | 
			
		||||
 | 
			
		||||
## Documentation
 | 
			
		||||
 | 
			
		||||
Docs live here: <https://docs.tildefriends.net/>.
 | 
			
		||||
Docs are a work in progress in the `docs` folder and in Tilde Friends: <https://www.tildefriends.net/~cory/wiki/#test-wiki/tf-app-quick-reference>.
 | 
			
		||||
 | 
			
		||||
## License
 | 
			
		||||
 | 
			
		||||
All code unless otherwise noted in is provided under the
 | 
			
		||||
[MIT](https://opensource.org/licenses/MIT) license.
 | 
			
		||||
All code, documentation and assets unless otherwise noted in is provided under the
 | 
			
		||||
[MIT](https://opensource.org/licenses/MIT/) license.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🎛",
 | 
			
		||||
	"previous": "&kmKNyb/uaXNb24gCinJtfS8iWx4cLUWdtl0y2DwEUas=.sha256"
 | 
			
		||||
	"previous": "&vrpS/vE7n588iYv1p8HafDxHB+YDHTrtUbJiu9nGA9I=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,8 +4,7 @@
 | 
			
		||||
		<script>
 | 
			
		||||
			const g_data = $data;
 | 
			
		||||
		</script>
 | 
			
		||||
		<link rel="stylesheet" href="w3.css" />
 | 
			
		||||
		<!-- prettier-ignore -->
 | 
			
		||||
		<link rel="stylesheet" href="w3.css"></link>
 | 
			
		||||
		<style>
 | 
			
		||||
			/* 2018 Valiant Poppy */
 | 
			
		||||
			.w3-theme-l5 {color:#000 !important; background-color:#fbf3f3 !important}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,55 +27,31 @@ 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>
 | 
			
		||||
					<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${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>
 | 
			
		||||
					<input class="w3-quarter w3-check" type="checkbox" ?checked=${description.value} id=${'gs_' + key}></input>
 | 
			
		||||
					<button class="w3-quarter w3-button w3-theme-action" @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.checked)}>Set</button>
 | 
			
		||||
				</li>
 | 
			
		||||
			`;
 | 
			
		||||
		} 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
 | 
			
		||||
					>
 | 
			
		||||
					<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${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>
 | 
			
		||||
					<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>
 | 
			
		||||
			`;
 | 
			
		||||
		} 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>
 | 
			
		||||
					<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${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>
 | 
			
		||||
@@ -85,17 +61,13 @@ ${description.value}</textarea
 | 
			
		||||
	}
 | 
			
		||||
	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>
 | 
			
		||||
			<button class="w3-button w3-theme-action" @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>
 | 
			
		||||
		html`
 | 
			
		||||
			<header class="w3-container w3-theme-l2"><h2>Users</h2></header>
 | 
			
		||||
			<ul class="w3-ul">
 | 
			
		||||
				${Object.entries(users).map((u) => user_template(u[0], u[1]))}
 | 
			
		||||
			</ul>`;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
 | 
			
		||||
/* W3.CSS 4.15 December 2020 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}
 | 
			
		||||
@@ -108,8 +108,6 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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%}
 | 
			
		||||
@@ -150,7 +148,6 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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}
 | 
			
		||||
@@ -178,19 +175,6 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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}
 | 
			
		||||
@@ -248,4 +232,4 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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}
 | 
			
		||||
.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
									
									
									
									
									
								
							
							
						
						@@ -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
									
									
								
							
							
						
						
							
								
								
									
										42
									
								
								apps/blog/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -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": "&de7q4A59auHP/34bXgeNH05JZoxsGr5TjwXPvehWH30=.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,13 +15,9 @@ 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>
 | 
			
		||||
@@ -84,7 +78,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,16 +98,6 @@ 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">
 | 
			
		||||
@@ -132,19 +116,16 @@ async function main() {
 | 
			
		||||
		<div class="w3-card-4 w3-margin">
 | 
			
		||||
			<header class="w3-container w3-theme-l2"><h2>Identities</h2></header>
 | 
			
		||||
			<ul class="w3-ul">` +
 | 
			
		||||
			(ids ?? [])
 | 
			
		||||
				.map(
 | 
			
		||||
					(
 | 
			
		||||
						id
 | 
			
		||||
					) => `<li style="overflow: hidden; text-wrap: nowrap; text-overflow: ellipsis">
 | 
			
		||||
				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>' : ''}
 | 
			
		||||
				${id}
 | 
			
		||||
			</li>`
 | 
			
		||||
				)
 | 
			
		||||
				.join('\n') +
 | 
			
		||||
			`	</ul>
 | 
			
		||||
					)
 | 
			
		||||
					.join('\n') +
 | 
			
		||||
				`	</ul>
 | 
			
		||||
		</div>
 | 
			
		||||
	</body>`
 | 
			
		||||
	);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
 | 
			
		||||
/* W3.CSS 4.15 December 2020 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}
 | 
			
		||||
@@ -108,8 +108,6 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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%}
 | 
			
		||||
@@ -150,7 +148,6 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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}
 | 
			
		||||
@@ -178,19 +175,6 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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}
 | 
			
		||||
@@ -248,4 +232,4 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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}
 | 
			
		||||
.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": "&cUqvSDUls3jn0haD85LPFAGdkc8wFuy347TtATNcJgg=.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);
 | 
			
		||||
@@ -88,12 +91,10 @@ tfrpc.register(function 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
									
									
								
							
							
						
						
							
								
								
									
										42
									
								
								apps/issues/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -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
									
									
								
							
							
						
						
							
								
								
									
										42
									
								
								apps/journal/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -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"
 | 
			
		||||
	"previous": "&HXCdDG8gGYXElTyEFbg85jqa6lDXNL2ENPIA9UoJNbI=.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
									
									
								
							
							
						
						@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🦀",
 | 
			
		||||
	"previous": "&Gic1e3jOZ7z5131jSCclbFXRpjyu8JlWJrjE7Fvn5dc=.sha256"
 | 
			
		||||
	"emoji": "🐌",
 | 
			
		||||
	"previous": "&vEaOZjrNb0u9rhNqrQ8eU9TlOFlo4HsgW6hbI7VdIT0=.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);
 | 
			
		||||
@@ -103,23 +103,7 @@ tfrpc.register(async function encrypt(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());
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -7,7 +7,7 @@ function textNode(text) {
 | 
			
		||||
function linkNode(text, link) {
 | 
			
		||||
	const linkNode = new commonmark.Node('link', undefined);
 | 
			
		||||
	if (link.startsWith('#')) {
 | 
			
		||||
		linkNode.destination = `#${encodeURIComponent(link)}`;
 | 
			
		||||
		linkNode.destination = `#q=${encodeURIComponent(link)}`;
 | 
			
		||||
	} else {
 | 
			
		||||
		linkNode.destination = link;
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								apps/ssb/commonmark.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -1,6 +1,4 @@
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
@@ -14,20 +12,38 @@ function get_emojis() {
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function picker(callback, anchor, author, recent) {
 | 
			
		||||
async function get_recent(author) {
 | 
			
		||||
	let recent = await tfrpc.rpc.query(
 | 
			
		||||
		`
 | 
			
		||||
		SELECT DISTINCT content ->> '$.vote.expression' AS value
 | 
			
		||||
		FROM messages
 | 
			
		||||
		WHERE author = ? AND
 | 
			
		||||
		content ->> '$.type' = 'vote'
 | 
			
		||||
		ORDER BY timestamp DESC LIMIT 10
 | 
			
		||||
	`,
 | 
			
		||||
		[author]
 | 
			
		||||
	);
 | 
			
		||||
	return recent.map((x) => x.value);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function picker(callback, anchor, author) {
 | 
			
		||||
	let json = await get_emojis();
 | 
			
		||||
	let recent = await get_recent(author);
 | 
			
		||||
 | 
			
		||||
	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.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';
 | 
			
		||||
	div.style.flex = '1 1';
 | 
			
		||||
	div.style.flexDirection = 'column';
 | 
			
		||||
	let input = document.createElement('input');
 | 
			
		||||
	input.type = 'text';
 | 
			
		||||
	input.style.display = 'block';
 | 
			
		||||
@@ -37,12 +53,19 @@ export async function picker(callback, anchor, author, recent) {
 | 
			
		||||
	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') {
 | 
			
		||||
			cleanup();
 | 
			
		||||
@@ -130,45 +153,13 @@ export async function picker(callback, anchor, author, recent) {
 | 
			
		||||
	}
 | 
			
		||||
	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);
 | 
			
		||||
	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);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								apps/ssb/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						@@ -8,18 +8,10 @@ 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);
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,33 +1,22 @@
 | 
			
		||||
import {LitElement, html, css, guard, 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 TfElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			whoami: {type: String},
 | 
			
		||||
			hash: {type: String},
 | 
			
		||||
			unread: {type: Array},
 | 
			
		||||
			tab: {type: String},
 | 
			
		||||
			broadcasts: {type: Array},
 | 
			
		||||
			connections: {type: Array},
 | 
			
		||||
			loading: {type: Boolean},
 | 
			
		||||
			loading_about: {type: Number},
 | 
			
		||||
			loaded: {type: Boolean},
 | 
			
		||||
			following: {type: Array},
 | 
			
		||||
			users: {type: Object},
 | 
			
		||||
			ids: {type: Array},
 | 
			
		||||
			channels: {type: Array},
 | 
			
		||||
			channels_unread: {type: Object},
 | 
			
		||||
			channels_latest: {type: Object},
 | 
			
		||||
			guest: {type: Boolean},
 | 
			
		||||
			url: {type: String},
 | 
			
		||||
			private_closed: {type: Object},
 | 
			
		||||
			private_messages: {type: Array},
 | 
			
		||||
			grouped_private_messages: {type: Object},
 | 
			
		||||
			recent_reactions: {type: Array},
 | 
			
		||||
			is_administrator: {type: Boolean},
 | 
			
		||||
			stay_connected: {type: Boolean},
 | 
			
		||||
			progress: {type: Number},
 | 
			
		||||
			tags: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -37,20 +26,14 @@ class TfElement extends LitElement {
 | 
			
		||||
		super();
 | 
			
		||||
		let self = this;
 | 
			
		||||
		this.hash = '#';
 | 
			
		||||
		this.unread = [];
 | 
			
		||||
		this.tab = 'news';
 | 
			
		||||
		this.broadcasts = [];
 | 
			
		||||
		this.connections = [];
 | 
			
		||||
		this.following = [];
 | 
			
		||||
		this.users = {};
 | 
			
		||||
		this.loaded = false;
 | 
			
		||||
		this.loading_about = 0;
 | 
			
		||||
		this.channels = [];
 | 
			
		||||
		this.channels_unread = {};
 | 
			
		||||
		this.channels_latest = {};
 | 
			
		||||
		this.loading_latest = 0;
 | 
			
		||||
		this.loading_latest_scheduled = 0;
 | 
			
		||||
		this.recent_reactions = [];
 | 
			
		||||
		this.private_closed = {};
 | 
			
		||||
		this.tags = [];
 | 
			
		||||
		tfrpc.rpc.getBroadcasts().then((b) => {
 | 
			
		||||
			self.broadcasts = b || [];
 | 
			
		||||
		});
 | 
			
		||||
@@ -60,22 +43,10 @@ class TfElement extends LitElement {
 | 
			
		||||
		tfrpc.rpc.getHash().then((hash) => self.set_hash(hash));
 | 
			
		||||
		tfrpc.register(function hashChanged(hash) {
 | 
			
		||||
			self.set_hash(hash);
 | 
			
		||||
			self.reset_progress();
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.register(async function notifyNewMessage(id) {
 | 
			
		||||
			await self.fetch_new_message(id);
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.register(async function notifyNewBlob(id) {
 | 
			
		||||
			window.dispatchEvent(
 | 
			
		||||
				new CustomEvent('blob-stored', {
 | 
			
		||||
					bubbles: true,
 | 
			
		||||
					composed: true,
 | 
			
		||||
					detail: {
 | 
			
		||||
						id: id,
 | 
			
		||||
					},
 | 
			
		||||
				})
 | 
			
		||||
			);
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.register(function set(name, value) {
 | 
			
		||||
			if (name === 'broadcasts') {
 | 
			
		||||
				self.broadcasts = value;
 | 
			
		||||
@@ -91,217 +62,98 @@ class TfElement extends LitElement {
 | 
			
		||||
	async initial_load() {
 | 
			
		||||
		let whoami = await tfrpc.rpc.getActiveIdentity();
 | 
			
		||||
		let ids = (await tfrpc.rpc.getIdentities()) || [];
 | 
			
		||||
		this.is_administrator = await tfrpc.rpc.isAdministrator();
 | 
			
		||||
		this.stay_connected =
 | 
			
		||||
			this.is_administrator &&
 | 
			
		||||
			(await tfrpc.rpc.globalSettingsGet('stay_connected'));
 | 
			
		||||
		this.url = await tfrpc.rpc.url();
 | 
			
		||||
		this.whoami = whoami ?? (ids.length ? ids[0] : undefined);
 | 
			
		||||
		this.guest = !this.whoami?.length;
 | 
			
		||||
		this.ids = ids;
 | 
			
		||||
		let private_closed =
 | 
			
		||||
			(await tfrpc.rpc.databaseGet('private_closed')) ?? '{}';
 | 
			
		||||
		this.private_closed = JSON.parse(private_closed);
 | 
			
		||||
		await this.load_channels();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async close_private_chat(event) {
 | 
			
		||||
		let update = {};
 | 
			
		||||
		update[event.detail.key] = true;
 | 
			
		||||
		this.private_closed = Object.assign(update, this.private_closed);
 | 
			
		||||
		await tfrpc.rpc.databaseSet(
 | 
			
		||||
			'private_closed',
 | 
			
		||||
			JSON.stringify(this.private_closed)
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load_channels() {
 | 
			
		||||
		let channels = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
			SELECT
 | 
			
		||||
				content ->> 'channel' AS channel,
 | 
			
		||||
				content ->> 'subscribed' AS subscribed
 | 
			
		||||
			FROM
 | 
			
		||||
				messages
 | 
			
		||||
			WHERE
 | 
			
		||||
				author = ? AND
 | 
			
		||||
				content ->> 'type' = 'channel'
 | 
			
		||||
			ORDER BY sequence
 | 
			
		||||
		`,
 | 
			
		||||
			[this.whoami]
 | 
			
		||||
		);
 | 
			
		||||
		let channel_map = {};
 | 
			
		||||
		for (let row of channels) {
 | 
			
		||||
			if (row.subscribed) {
 | 
			
		||||
				channel_map[row.channel] = true;
 | 
			
		||||
			} else {
 | 
			
		||||
				delete channel_map[row.channel];
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		this.channels = Object.keys(channel_map).sort();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	connectedCallback() {
 | 
			
		||||
		super.connectedCallback();
 | 
			
		||||
		this._keydown = this.keydown.bind(this);
 | 
			
		||||
		window.addEventListener('keydown', this._keydown);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	disconnectedCallback() {
 | 
			
		||||
		super.disconnectedCallback();
 | 
			
		||||
		window.removeEventListener('keydown', this._keydown);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	keydown(event) {
 | 
			
		||||
		if (event.altKey && event.key == 'ArrowUp') {
 | 
			
		||||
			this.next_channel(-1);
 | 
			
		||||
			event.preventDefault();
 | 
			
		||||
		} else if (event.altKey && event.key == 'ArrowDown') {
 | 
			
		||||
			this.next_channel(1);
 | 
			
		||||
			event.preventDefault();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	visible_private() {
 | 
			
		||||
		if (!this.grouped_private_messages || !this.private_closed) {
 | 
			
		||||
			return [];
 | 
			
		||||
		}
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return Object.fromEntries(
 | 
			
		||||
			Object.entries(this.grouped_private_messages).filter(([key, value]) => {
 | 
			
		||||
				let channel = '🔐' + [...new Set(JSON.parse(key))].sort().join(',');
 | 
			
		||||
				let grouped_latest = Math.max(...value.map((x) => x.rowid));
 | 
			
		||||
				return (
 | 
			
		||||
					!self.private_closed[key] ||
 | 
			
		||||
					self.channels_unread[channel] === undefined ||
 | 
			
		||||
					grouped_latest > self.channels_unread[channel]
 | 
			
		||||
				);
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	next_channel(delta) {
 | 
			
		||||
		let channel_names = [
 | 
			
		||||
			'',
 | 
			
		||||
			'@',
 | 
			
		||||
			'👍',
 | 
			
		||||
			...Object.keys(this.visible_private())
 | 
			
		||||
				.sort()
 | 
			
		||||
				.map((x) => '🔐' + JSON.parse(x).join(',')),
 | 
			
		||||
			...this.channels.map((x) => '#' + x),
 | 
			
		||||
		];
 | 
			
		||||
		let index = channel_names.indexOf(this.hash.substring(1));
 | 
			
		||||
		index = index != -1 ? index + delta : 0;
 | 
			
		||||
		tfrpc.rpc.setHash(
 | 
			
		||||
			'#' +
 | 
			
		||||
				encodeURIComponent(
 | 
			
		||||
					channel_names[(index + channel_names.length) % channel_names.length]
 | 
			
		||||
				)
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	set_hash(hash) {
 | 
			
		||||
		this.hash = decodeURIComponent(hash || '#');
 | 
			
		||||
		this.hash = hash || '#';
 | 
			
		||||
		if (this.hash.startsWith('#q=')) {
 | 
			
		||||
			this.tab = 'search';
 | 
			
		||||
		} else if (this.hash === '#connections') {
 | 
			
		||||
			this.tab = 'connections';
 | 
			
		||||
		} else if (this.hash === '#mentions') {
 | 
			
		||||
			this.tab = 'mentions';
 | 
			
		||||
		} else if (this.hash.startsWith('#sql=')) {
 | 
			
		||||
			this.tab = 'query';
 | 
			
		||||
		} else {
 | 
			
		||||
			this.tab = 'news';
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async fetch_about(following, users) {
 | 
			
		||||
		this.loading_about++;
 | 
			
		||||
		let ids = Object.keys(following).sort();
 | 
			
		||||
		const k_cache_version = 3;
 | 
			
		||||
	async fetch_about(ids, users) {
 | 
			
		||||
		const k_cache_version = 1;
 | 
			
		||||
		let cache = await tfrpc.rpc.databaseGet('about');
 | 
			
		||||
		let original_cache = cache;
 | 
			
		||||
		cache = cache ? JSON.parse(cache) : {};
 | 
			
		||||
		if (cache.version !== k_cache_version) {
 | 
			
		||||
			cache = {
 | 
			
		||||
				version: k_cache_version,
 | 
			
		||||
				about: {},
 | 
			
		||||
				last_row_id: 0,
 | 
			
		||||
			};
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		let ids_out_of_date = ids.filter(
 | 
			
		||||
			(x) =>
 | 
			
		||||
				(users[x]?.seq && !cache.about[x]?.seq) ||
 | 
			
		||||
				(users[x]?.seq && users[x]?.seq > cache.about[x].seq)
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		let max_row_id = (
 | 
			
		||||
			await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
			SELECT MAX(rowid) AS max_row_id FROM messages
 | 
			
		||||
		`,
 | 
			
		||||
				[]
 | 
			
		||||
			)
 | 
			
		||||
		)[0].max_row_id;
 | 
			
		||||
		for (let id of Object.keys(cache.about)) {
 | 
			
		||||
			if (ids.indexOf(id) == -1) {
 | 
			
		||||
				delete cache.about[id];
 | 
			
		||||
			} else {
 | 
			
		||||
				users[id] = Object.assign(cache.about[id], users[id] || {});
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		console.log(
 | 
			
		||||
			'loading about for',
 | 
			
		||||
			ids.length,
 | 
			
		||||
			'accounts',
 | 
			
		||||
			ids_out_of_date.length,
 | 
			
		||||
			'out of date'
 | 
			
		||||
		let abouts = 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,
 | 
			
		||||
					json_each(?1) AS following
 | 
			
		||||
				WHERE
 | 
			
		||||
					messages.author = following.value AND
 | 
			
		||||
					messages.rowid > ?3 AND
 | 
			
		||||
					messages.rowid <= ?4 AND
 | 
			
		||||
					json_extract(messages.content, '$.type') = 'about'
 | 
			
		||||
				UNION
 | 
			
		||||
				SELECT
 | 
			
		||||
					messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
				FROM
 | 
			
		||||
					messages,
 | 
			
		||||
					json_each(?2) AS following
 | 
			
		||||
				WHERE
 | 
			
		||||
					messages.author = following.value AND
 | 
			
		||||
					messages.rowid <= ?4 AND
 | 
			
		||||
					json_extract(messages.content, '$.type') = 'about'
 | 
			
		||||
				ORDER BY messages.author, messages.sequence
 | 
			
		||||
			`,
 | 
			
		||||
			[
 | 
			
		||||
				JSON.stringify(ids.filter((id) => cache.about[id])),
 | 
			
		||||
				JSON.stringify(ids.filter((id) => !cache.about[id])),
 | 
			
		||||
				cache.last_row_id,
 | 
			
		||||
				max_row_id,
 | 
			
		||||
			]
 | 
			
		||||
		);
 | 
			
		||||
		if (ids_out_of_date.length) {
 | 
			
		||||
			try {
 | 
			
		||||
				let rows = await tfrpc.rpc.query(
 | 
			
		||||
					`
 | 
			
		||||
						SELECT all_abouts.author, json(json_group_object(all_abouts.key, all_abouts.value)) AS about
 | 
			
		||||
						FROM (
 | 
			
		||||
							SELECT
 | 
			
		||||
								messages.author,
 | 
			
		||||
								fields.key,
 | 
			
		||||
								RANK() OVER (PARTITION BY messages.author, fields.key ORDER BY messages.sequence DESC) AS rank,
 | 
			
		||||
								fields.value
 | 
			
		||||
							FROM messages JOIN json_each(messages.content) AS fields
 | 
			
		||||
							WHERE
 | 
			
		||||
								messages.content ->> '$.type' = 'about' AND
 | 
			
		||||
								messages.content ->> '$.about' = messages.author AND
 | 
			
		||||
								NOT fields.key IN ('about', 'type')) all_abouts
 | 
			
		||||
						JOIN json_each(?) AS following ON all_abouts.author = following.value
 | 
			
		||||
						WHERE rank = 1
 | 
			
		||||
						GROUP BY all_abouts.author
 | 
			
		||||
					`,
 | 
			
		||||
					[JSON.stringify(ids_out_of_date)]
 | 
			
		||||
		for (let about of abouts) {
 | 
			
		||||
			let content = JSON.parse(about.content);
 | 
			
		||||
			if (content.about === about.author) {
 | 
			
		||||
				delete content.type;
 | 
			
		||||
				delete content.about;
 | 
			
		||||
				cache.about[about.author] = Object.assign(
 | 
			
		||||
					cache.about[about.author] || {},
 | 
			
		||||
					content
 | 
			
		||||
				);
 | 
			
		||||
				users = users || {};
 | 
			
		||||
				for (let row of rows) {
 | 
			
		||||
					users[row.author] = Object.assign(
 | 
			
		||||
						users[row.author] || {},
 | 
			
		||||
						JSON.parse(row.about)
 | 
			
		||||
					);
 | 
			
		||||
					cache.about[row.author] = Object.assign(
 | 
			
		||||
						{seq: users[row.author].seq},
 | 
			
		||||
						JSON.parse(row.about)
 | 
			
		||||
					);
 | 
			
		||||
				}
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				console.log(e);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		for (let id of ids_out_of_date) {
 | 
			
		||||
			if (!cache.about[id]?.seq) {
 | 
			
		||||
				cache.about[id] = Object.assign(cache.about[id] ?? {}, {
 | 
			
		||||
					seq: users[id]?.seq ?? 0,
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
		cache.last_row_id = max_row_id;
 | 
			
		||||
		await tfrpc.rpc.databaseSet('about', JSON.stringify(cache));
 | 
			
		||||
		users = users || {};
 | 
			
		||||
		for (let id of Object.keys(cache.about)) {
 | 
			
		||||
			users[id] = Object.assign(users[id] || {}, cache.about[id]);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.loading_about--;
 | 
			
		||||
 | 
			
		||||
		let new_cache = JSON.stringify(cache);
 | 
			
		||||
		if (new_cache != original_cache) {
 | 
			
		||||
			let start_time = new Date();
 | 
			
		||||
			tfrpc.rpc.databaseSet('about', new_cache).then(function () {
 | 
			
		||||
				console.log('saving about took', (new Date() - start_time) / 1000);
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return Object.assign({}, users);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -315,15 +167,10 @@ class TfElement extends LitElement {
 | 
			
		||||
			`,
 | 
			
		||||
			[JSON.stringify(this.following), id]
 | 
			
		||||
		);
 | 
			
		||||
		for (let message of messages) {
 | 
			
		||||
			if (
 | 
			
		||||
				message.author == this.whoami &&
 | 
			
		||||
				JSON.parse(message.content)?.type == 'channel'
 | 
			
		||||
			) {
 | 
			
		||||
				this.load_channels();
 | 
			
		||||
			}
 | 
			
		||||
		if (messages && messages.length) {
 | 
			
		||||
			this.unread = [...this.unread, ...messages];
 | 
			
		||||
			this.unread = this.unread.slice(this.unread.length - 1024);
 | 
			
		||||
		}
 | 
			
		||||
		this.schedule_load_latest();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async _handle_whoami_changed(event) {
 | 
			
		||||
@@ -338,353 +185,70 @@ class TfElement extends LitElement {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async get_latest_private(following) {
 | 
			
		||||
		const k_version = 1;
 | 
			
		||||
		// { "version": 1, "range": [1234, 5678], messages: [ "%1.sha256", "%2.sha256", ... ], latest: rowid }
 | 
			
		||||
		let cache = JSON.parse(
 | 
			
		||||
			(await tfrpc.rpc.databaseGet(`private:${this.whoami}`)) ?? '{}'
 | 
			
		||||
		);
 | 
			
		||||
		if (cache.version !== k_version) {
 | 
			
		||||
			cache = {
 | 
			
		||||
				version: k_version,
 | 
			
		||||
				messages: [],
 | 
			
		||||
				range: [],
 | 
			
		||||
			};
 | 
			
		||||
		}
 | 
			
		||||
		let latest = (
 | 
			
		||||
			await tfrpc.rpc.query('SELECT MAX(rowid) AS latest FROM messages')
 | 
			
		||||
		)[0].latest;
 | 
			
		||||
		let ranges = [];
 | 
			
		||||
		const k_chunk_size = 512;
 | 
			
		||||
		if (cache.range.length) {
 | 
			
		||||
			for (let i = cache.range[1]; i < latest; i += k_chunk_size) {
 | 
			
		||||
				ranges.push([i, Math.min(i + k_chunk_size, latest), true]);
 | 
			
		||||
			}
 | 
			
		||||
			for (let i = cache.range[0]; i >= 0; i -= k_chunk_size) {
 | 
			
		||||
				ranges.push([Math.max(i - k_chunk_size, 0), i, false]);
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			for (let i = 0; i < latest; i += k_chunk_size) {
 | 
			
		||||
				ranges.push([i, Math.min(i + k_chunk_size, latest), true]);
 | 
			
		||||
	async create_identity() {
 | 
			
		||||
		if (confirm('Are you sure you want to create a new identity?')) {
 | 
			
		||||
			await tfrpc.rpc.createIdentity();
 | 
			
		||||
			this.ids = (await tfrpc.rpc.getIdentities()) || [];
 | 
			
		||||
			if (this.ids && !this.whoami) {
 | 
			
		||||
				this.whoami = this.ids[0];
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		for (let range of ranges) {
 | 
			
		||||
			let messages = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
					SELECT messages.rowid, messages.id, json(content) AS content
 | 
			
		||||
						FROM messages
 | 
			
		||||
						WHERE
 | 
			
		||||
							messages.rowid > ?1 AND
 | 
			
		||||
							messages.rowid <= ?2 AND
 | 
			
		||||
							json(messages.content) LIKE '"%'
 | 
			
		||||
						ORDER BY messages.rowid DESC
 | 
			
		||||
					`,
 | 
			
		||||
				[range[0], range[1]]
 | 
			
		||||
			);
 | 
			
		||||
			messages = (await this.decrypt(messages)).filter((x) => x.decrypted);
 | 
			
		||||
			if (messages.length) {
 | 
			
		||||
				cache.latest = Math.max(
 | 
			
		||||
					cache.latest ?? 0,
 | 
			
		||||
					...messages.map((x) => x.rowid)
 | 
			
		||||
				);
 | 
			
		||||
				if (range[2]) {
 | 
			
		||||
					cache.messages = [...cache.messages, ...messages.map((x) => x.id)];
 | 
			
		||||
				} else {
 | 
			
		||||
					cache.messages = [...messages.map((x) => x.id), ...cache.messages];
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			cache.range[0] = Math.min(cache.range[0] ?? range[0], range[0]);
 | 
			
		||||
			cache.range[1] = Math.max(cache.range[1] ?? range[1], range[1]);
 | 
			
		||||
			await tfrpc.rpc.databaseSet(
 | 
			
		||||
				`private:${this.whoami}`,
 | 
			
		||||
				JSON.stringify(cache)
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
		return [cache.latest, cache.messages];
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async query_timed(sql, args) {
 | 
			
		||||
	async load_recent_tags() {
 | 
			
		||||
		let start = new Date();
 | 
			
		||||
		let result = await tfrpc.rpc.query(sql, args);
 | 
			
		||||
		let end = new Date();
 | 
			
		||||
		console.log((end - start) / 1000, sql.replaceAll(/\s+/g, ' ').trim());
 | 
			
		||||
		return result;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async group_private_messages(messages) {
 | 
			
		||||
		let groups = {};
 | 
			
		||||
		let result = await this.decrypt(
 | 
			
		||||
			await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
				SELECT messages.rowid, messages.id, author, timestamp, json(content) AS content
 | 
			
		||||
				FROM messages
 | 
			
		||||
				JOIN json_each(?) AS ids
 | 
			
		||||
				WHERE messages.id = ids.value
 | 
			
		||||
				ORDER BY timestamp DESC
 | 
			
		||||
			`,
 | 
			
		||||
				[JSON.stringify(messages)]
 | 
			
		||||
			)
 | 
			
		||||
		);
 | 
			
		||||
		for (let message of result) {
 | 
			
		||||
			let key = JSON.stringify(
 | 
			
		||||
				[
 | 
			
		||||
					...new Set(
 | 
			
		||||
						message?.decrypted?.recps?.filter((x) => x != this.whoami)
 | 
			
		||||
					),
 | 
			
		||||
				].sort() ?? []
 | 
			
		||||
			);
 | 
			
		||||
			if (!groups[key]) {
 | 
			
		||||
				groups[key] = [];
 | 
			
		||||
			}
 | 
			
		||||
			groups[key].push(message);
 | 
			
		||||
		}
 | 
			
		||||
		return groups;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load_channels_latest(following) {
 | 
			
		||||
		let start_time = new Date();
 | 
			
		||||
		let latest_private = this.get_latest_private(following);
 | 
			
		||||
		const k_args = [
 | 
			
		||||
			JSON.stringify(this.channels),
 | 
			
		||||
			JSON.stringify(following),
 | 
			
		||||
			'"' + this.whoami.replace('"', '""') + '"',
 | 
			
		||||
			this.whoami,
 | 
			
		||||
		];
 | 
			
		||||
		let channels = (
 | 
			
		||||
			await Promise.all([
 | 
			
		||||
				this.query_timed(
 | 
			
		||||
					`
 | 
			
		||||
					SELECT channels.value AS channel, MAX(messages.rowid) AS rowid FROM messages
 | 
			
		||||
					JOIN json_each(?1) AS channels ON messages.content ->> 'channel' = channels.value
 | 
			
		||||
					JOIN json_each(?2) AS following ON messages.author = following.value
 | 
			
		||||
					WHERE
 | 
			
		||||
						messages.content ->> 'type' = 'post' AND
 | 
			
		||||
						messages.content ->> 'root' IS NULL AND
 | 
			
		||||
						messages.author != ?4
 | 
			
		||||
					GROUP by channel
 | 
			
		||||
				`,
 | 
			
		||||
					k_args
 | 
			
		||||
				),
 | 
			
		||||
				this.query_timed(
 | 
			
		||||
					`
 | 
			
		||||
					SELECT channels.value AS channel, MAX(messages.rowid) AS rowid FROM messages
 | 
			
		||||
					JOIN messages_refs ON messages.id = messages_refs.message
 | 
			
		||||
					JOIN json_each(?1) AS channels ON messages_refs.ref = '#' || channels.value
 | 
			
		||||
					JOIN json_each(?2) AS following ON messages.author = following.value
 | 
			
		||||
					WHERE
 | 
			
		||||
						messages.content ->> 'type' = 'post' AND
 | 
			
		||||
						messages.content ->> 'root' IS NULL AND
 | 
			
		||||
						messages.author != ?4
 | 
			
		||||
					GROUP by channel
 | 
			
		||||
				`,
 | 
			
		||||
					k_args
 | 
			
		||||
				),
 | 
			
		||||
				this.query_timed(
 | 
			
		||||
					`
 | 
			
		||||
					SELECT '' AS channel, MAX(messages.rowid) AS rowid FROM messages
 | 
			
		||||
					JOIN json_each(?2) AS following ON messages.author = following.value
 | 
			
		||||
					WHERE
 | 
			
		||||
						messages.content ->> 'type' = 'post' AND
 | 
			
		||||
						messages.content ->> 'root' IS NULL AND
 | 
			
		||||
						messages.author != ?4
 | 
			
		||||
				`,
 | 
			
		||||
					k_args
 | 
			
		||||
				),
 | 
			
		||||
				this.query_timed(
 | 
			
		||||
					`
 | 
			
		||||
					SELECT '@' AS channel, MAX(messages.rowid) AS rowid FROM messages_fts(?3)
 | 
			
		||||
					JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
					JOIN json_each(?2) AS following ON messages.author = following.value
 | 
			
		||||
					WHERE messages.author != ?4
 | 
			
		||||
				`,
 | 
			
		||||
					k_args
 | 
			
		||||
				),
 | 
			
		||||
			])
 | 
			
		||||
		).flat();
 | 
			
		||||
		let latest = {};
 | 
			
		||||
		for (let row of channels) {
 | 
			
		||||
			if (!latest[row.channel]) {
 | 
			
		||||
				latest[row.channel] = row.rowid;
 | 
			
		||||
			} else {
 | 
			
		||||
				latest[row.channel] = Math.max(row.rowid, latest[row.channel]);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		this.channels_latest = latest;
 | 
			
		||||
		console.log('channels took', (new Date() - start_time) / 1000.0);
 | 
			
		||||
		let self = this;
 | 
			
		||||
		start_time = new Date();
 | 
			
		||||
		latest_private.then(async function (latest) {
 | 
			
		||||
			self.channels_latest = Object.assign({}, self.channels_latest, {
 | 
			
		||||
				'🔐': latest[0],
 | 
			
		||||
			});
 | 
			
		||||
			console.log('private took', (new Date() - start_time) / 1000.0);
 | 
			
		||||
			self.private_messages = latest[1];
 | 
			
		||||
			self.grouped_private_messages = await self.group_private_messages(
 | 
			
		||||
				latest[1]
 | 
			
		||||
			);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_schedule_load_latest_timer() {
 | 
			
		||||
		--this.loading_latest_scheduled;
 | 
			
		||||
		this.schedule_load_latest();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	reset_progress() {
 | 
			
		||||
		if (this.progress === undefined) {
 | 
			
		||||
			this._progress_start = new Date();
 | 
			
		||||
			requestAnimationFrame(this.update_progress.bind(this));
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	update_progress() {
 | 
			
		||||
		if (
 | 
			
		||||
			!this.loading_latest &&
 | 
			
		||||
			!this.loading_latest_scheduled &&
 | 
			
		||||
			!this.shadowRoot.getElementById('tf-tab-news')?.is_loading()
 | 
			
		||||
		) {
 | 
			
		||||
			this.progress = undefined;
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		this.progress = (new Date() - this._progress_start).valueOf();
 | 
			
		||||
		requestAnimationFrame(this.update_progress.bind(this));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	schedule_load_latest() {
 | 
			
		||||
		this.reset_progress();
 | 
			
		||||
		if (!this.loading_latest) {
 | 
			
		||||
			this.shadowRoot.getElementById('tf-tab-news')?.load_latest();
 | 
			
		||||
			this.load();
 | 
			
		||||
		} else if (!this.loading_latest_scheduled) {
 | 
			
		||||
			this.loading_latest_scheduled++;
 | 
			
		||||
			setTimeout(this._schedule_load_latest_timer.bind(this), 5000);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async fetch_user_info(users) {
 | 
			
		||||
		let info = await tfrpc.rpc.query(
 | 
			
		||||
		this.tags = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				SELECT messages_stats.author, messages_stats.max_sequence, messages_stats.max_timestamp AS max_ts FROM messages_stats
 | 
			
		||||
				JOIN json_each(?) AS following
 | 
			
		||||
				ON messages_stats.author = following.value
 | 
			
		||||
			`,
 | 
			
		||||
			[JSON.stringify(Object.keys(users))]
 | 
			
		||||
		);
 | 
			
		||||
		for (let row of info) {
 | 
			
		||||
			users[row.author] = Object.assign(users[row.author], {
 | 
			
		||||
				seq: row.max_sequence,
 | 
			
		||||
				ts: row.max_ts,
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
		return users;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load_recent_reactions() {
 | 
			
		||||
		this.recent_reactions = (
 | 
			
		||||
			await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
			SELECT DISTINCT content ->> '$.vote.expression' AS value
 | 
			
		||||
			FROM messages
 | 
			
		||||
			WHERE author = ? AND
 | 
			
		||||
			content ->> '$.type' = 'vote'
 | 
			
		||||
			ORDER BY timestamp DESC LIMIT 10
 | 
			
		||||
			WITH
 | 
			
		||||
				recent AS (SELECT id, json(content) AS content FROM messages
 | 
			
		||||
					WHERE messages.timestamp > ? AND json_extract(content, '$.type') = 'post'
 | 
			
		||||
					ORDER BY timestamp DESC LIMIT 1024),
 | 
			
		||||
				recent_channels AS (SELECT recent.id, '#' || json_extract(content, '$.channel') AS tag
 | 
			
		||||
					FROM recent
 | 
			
		||||
					WHERE json_extract(content, '$.channel') IS NOT NULL),
 | 
			
		||||
				recent_mentions AS (SELECT recent.id, json_extract(mention.value, '$.link') AS tag
 | 
			
		||||
					FROM recent, json_each(recent.content, '$.mentions') AS mention
 | 
			
		||||
					WHERE json_valid(mention.value) AND tag LIKE '#%'),
 | 
			
		||||
				combined AS (SELECT id, tag FROM recent_channels UNION ALL SELECT id, tag FROM recent_mentions),
 | 
			
		||||
				by_message AS (SELECT DISTINCT id, tag FROM combined)
 | 
			
		||||
			SELECT tag, COUNT(*) AS count FROM by_message GROUP BY tag ORDER BY count DESC LIMIT 10
 | 
			
		||||
		`,
 | 
			
		||||
				[this.whoami]
 | 
			
		||||
			)
 | 
			
		||||
		).map((x) => x.value);
 | 
			
		||||
			[new Date() - 7 * 24 * 60 * 60 * 1000]
 | 
			
		||||
		);
 | 
			
		||||
		console.log('tags took', (new Date() - start) / 1000.0, 'seconds');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load() {
 | 
			
		||||
		this.loading_latest = true;
 | 
			
		||||
		this.reset_progress();
 | 
			
		||||
		try {
 | 
			
		||||
			let start_time = new Date();
 | 
			
		||||
			let whoami = this.whoami;
 | 
			
		||||
			let following = await tfrpc.rpc.following([whoami], 2);
 | 
			
		||||
			let old_users = this.users ?? {};
 | 
			
		||||
			let users = {};
 | 
			
		||||
			let by_count = [];
 | 
			
		||||
			for (let [id, v] of Object.entries(following)) {
 | 
			
		||||
				users[id] = Object.assign(
 | 
			
		||||
					{
 | 
			
		||||
						following: v.of,
 | 
			
		||||
						blocking: v.ob,
 | 
			
		||||
						followed: v.if,
 | 
			
		||||
						blocked: v.ib,
 | 
			
		||||
						follow_depth: following[id]?.d,
 | 
			
		||||
					},
 | 
			
		||||
					old_users[id]
 | 
			
		||||
				);
 | 
			
		||||
				by_count.push({count: v.of, id: id});
 | 
			
		||||
			}
 | 
			
		||||
			let reactions = this.load_recent_reactions();
 | 
			
		||||
			this.load_channels_latest(Object.keys(following));
 | 
			
		||||
			this.channels_unread = JSON.parse(
 | 
			
		||||
				(await tfrpc.rpc.databaseGet('unread')) ?? '{}'
 | 
			
		||||
			);
 | 
			
		||||
			this.following = Object.keys(following);
 | 
			
		||||
			let about_start_time = new Date();
 | 
			
		||||
			start_time = new Date();
 | 
			
		||||
			users = await this.fetch_user_info(users);
 | 
			
		||||
			console.log(
 | 
			
		||||
				'user info took',
 | 
			
		||||
				(new Date() - start_time) / 1000.0,
 | 
			
		||||
				'seconds'
 | 
			
		||||
			);
 | 
			
		||||
			this.users = users;
 | 
			
		||||
 | 
			
		||||
			let self = this;
 | 
			
		||||
			this.fetch_about(following, users).then(function (result) {
 | 
			
		||||
				self.users = result;
 | 
			
		||||
				console.log(
 | 
			
		||||
					'about took',
 | 
			
		||||
					(new Date() - about_start_time) / 1000.0,
 | 
			
		||||
					'seconds for',
 | 
			
		||||
					Object.keys(users).length,
 | 
			
		||||
					'users'
 | 
			
		||||
				);
 | 
			
		||||
			});
 | 
			
		||||
			console.log(
 | 
			
		||||
				`load finished ${whoami} => ${this.whoami} in ${(new Date() - start_time) / 1000}`
 | 
			
		||||
			);
 | 
			
		||||
			await reactions;
 | 
			
		||||
			this.whoami = whoami;
 | 
			
		||||
			this.loaded = whoami;
 | 
			
		||||
		} finally {
 | 
			
		||||
			this.loading_latest = false;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	channel_set_unread(event) {
 | 
			
		||||
		this.channels_unread[event.detail.channel ?? ''] = event.detail.unread;
 | 
			
		||||
		this.channels_unread = Object.assign({}, this.channels_unread);
 | 
			
		||||
		tfrpc.rpc.databaseSet('unread', JSON.stringify(this.channels_unread));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async decrypt(messages) {
 | 
			
		||||
		let whoami = this.whoami;
 | 
			
		||||
		return Promise.all(
 | 
			
		||||
			messages.map(async function (message) {
 | 
			
		||||
				let content;
 | 
			
		||||
				try {
 | 
			
		||||
					content = JSON.parse(message?.content);
 | 
			
		||||
				} catch {}
 | 
			
		||||
				if (typeof content === 'string') {
 | 
			
		||||
					let decrypted;
 | 
			
		||||
					try {
 | 
			
		||||
						decrypted = await tfrpc.rpc.try_decrypt(whoami, content);
 | 
			
		||||
					} catch {}
 | 
			
		||||
					if (decrypted) {
 | 
			
		||||
						try {
 | 
			
		||||
							message.decrypted = JSON.parse(decrypted);
 | 
			
		||||
						} catch {
 | 
			
		||||
							message.decrypted = decrypted;
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				return message;
 | 
			
		||||
			})
 | 
			
		||||
		let tags = this.load_recent_tags();
 | 
			
		||||
		let following = await tfrpc.rpc.following([whoami], 2);
 | 
			
		||||
		let users = {};
 | 
			
		||||
		let by_count = [];
 | 
			
		||||
		for (let [id, v] of Object.entries(following)) {
 | 
			
		||||
			users[id] = {
 | 
			
		||||
				following: v.of,
 | 
			
		||||
				blocking: v.ob,
 | 
			
		||||
				followed: v.if,
 | 
			
		||||
				blocked: v.ib,
 | 
			
		||||
			};
 | 
			
		||||
			by_count.push({count: v.of, id: id});
 | 
			
		||||
		}
 | 
			
		||||
		console.log(by_count.sort((x, y) => y.count - x.count).slice(0, 20));
 | 
			
		||||
		let start_time = new Date();
 | 
			
		||||
		users = await this.fetch_about(Object.keys(following).sort(), users);
 | 
			
		||||
		console.log(
 | 
			
		||||
			'about took',
 | 
			
		||||
			(new Date() - start_time) / 1000.0,
 | 
			
		||||
			'seconds for',
 | 
			
		||||
			Object.keys(users).length,
 | 
			
		||||
			'users'
 | 
			
		||||
		);
 | 
			
		||||
		this.following = Object.keys(following);
 | 
			
		||||
		this.users = users;
 | 
			
		||||
		await tags;
 | 
			
		||||
		console.log(`load finished ${whoami} => ${this.whoami}`);
 | 
			
		||||
		this.whoami = whoami;
 | 
			
		||||
		this.loaded = whoami;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_tab() {
 | 
			
		||||
@@ -698,22 +262,8 @@ class TfElement extends LitElement {
 | 
			
		||||
					whoami=${this.whoami}
 | 
			
		||||
					.users=${this.users}
 | 
			
		||||
					hash=${this.hash}
 | 
			
		||||
					?loading=${this.loading || this.loading_about != 0}
 | 
			
		||||
					.channels=${this.channels}
 | 
			
		||||
					.channels_latest=${this.channels_latest}
 | 
			
		||||
					.channels_unread=${this.channels_unread}
 | 
			
		||||
					@channelsetunread=${this.channel_set_unread}
 | 
			
		||||
					@refresh=${this.refresh}
 | 
			
		||||
					@toggle_stay_connected=${this.toggle_stay_connected}
 | 
			
		||||
					@loadmessages=${this.reset_progress}
 | 
			
		||||
					@closeprivatechat=${this.close_private_chat}
 | 
			
		||||
					.connections=${this.connections}
 | 
			
		||||
					.private_messages=${this.private_messages}
 | 
			
		||||
					.visible_private_messages=${this.visible_private()}
 | 
			
		||||
					.grouped_private_messages=${this.grouped_private_messages}
 | 
			
		||||
					.recent_reactions=${this.recent_reactions}
 | 
			
		||||
					?is_administrator=${this.is_administrator}
 | 
			
		||||
					?stay_connected=${this.stay_connected}
 | 
			
		||||
					.unread=${this.unread}
 | 
			
		||||
					@refresh=${() => (this.unread = [])}
 | 
			
		||||
				></tf-tab-news>
 | 
			
		||||
			`;
 | 
			
		||||
		} else if (this.tab === 'connections') {
 | 
			
		||||
@@ -724,6 +274,14 @@ class TfElement extends LitElement {
 | 
			
		||||
					.broadcasts=${this.broadcasts}
 | 
			
		||||
				></tf-tab-connections>
 | 
			
		||||
			`;
 | 
			
		||||
		} else if (this.tab === 'mentions') {
 | 
			
		||||
			return html`
 | 
			
		||||
				<tf-tab-mentions
 | 
			
		||||
					.following=${this.following}
 | 
			
		||||
					whoami=${this.whoami}
 | 
			
		||||
					.users="${this.users}}"
 | 
			
		||||
				></tf-tab-mentions>
 | 
			
		||||
			`;
 | 
			
		||||
		} else if (this.tab === 'search') {
 | 
			
		||||
			return html`
 | 
			
		||||
				<tf-tab-search
 | 
			
		||||
@@ -735,46 +293,33 @@ class TfElement extends LitElement {
 | 
			
		||||
						: null}
 | 
			
		||||
				></tf-tab-search>
 | 
			
		||||
			`;
 | 
			
		||||
		} else if (this.tab === 'query') {
 | 
			
		||||
			return html`
 | 
			
		||||
				<tf-tab-query
 | 
			
		||||
					.following=${this.following}
 | 
			
		||||
					whoami=${this.whoami}
 | 
			
		||||
					.users=${this.users}
 | 
			
		||||
					query=${this.hash?.startsWith('#sql=')
 | 
			
		||||
						? decodeURIComponent(this.hash.substring(5))
 | 
			
		||||
						: null}
 | 
			
		||||
				></tf-tab-query>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async set_tab(tab) {
 | 
			
		||||
		this.tab = tab;
 | 
			
		||||
		if (tab === 'news') {
 | 
			
		||||
			this.schedule_load_latest();
 | 
			
		||||
			await tfrpc.rpc.setHash('#');
 | 
			
		||||
		} else if (tab === 'connections') {
 | 
			
		||||
			await tfrpc.rpc.setHash('#connections');
 | 
			
		||||
		} else if (tab === 'mentions') {
 | 
			
		||||
			await tfrpc.rpc.setHash('#mentions');
 | 
			
		||||
		} else if (tab === 'query') {
 | 
			
		||||
			await tfrpc.rpc.setHash('#sql=');
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	refresh() {
 | 
			
		||||
		tfrpc.rpc.sync();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async toggle_stay_connected() {
 | 
			
		||||
		let stay_connected = await tfrpc.rpc.globalSettingsGet('stay_connected');
 | 
			
		||||
		let new_stay_connected = !this.stay_connected;
 | 
			
		||||
		try {
 | 
			
		||||
			if (new_stay_connected != stay_connected) {
 | 
			
		||||
				await tfrpc.rpc.globalSettingsSet('stay_connected', new_stay_connected);
 | 
			
		||||
			}
 | 
			
		||||
		} finally {
 | 
			
		||||
			this.stay_connected = await tfrpc.rpc.globalSettingsGet('stay_connected');
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async pick_color() {
 | 
			
		||||
		let input = document.createElement('input');
 | 
			
		||||
		input.type = 'color';
 | 
			
		||||
		input.value = (await tfrpc.rpc.localStorageGet('color')) ?? '#ff0000';
 | 
			
		||||
		input.addEventListener('change', async function () {
 | 
			
		||||
			await tfrpc.rpc.localStorageSet('color', input.value);
 | 
			
		||||
			window.location.reload();
 | 
			
		||||
		});
 | 
			
		||||
		input.click();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
 | 
			
		||||
@@ -788,100 +333,47 @@ class TfElement extends LitElement {
 | 
			
		||||
		const k_tabs = {
 | 
			
		||||
			'📰': 'news',
 | 
			
		||||
			'📡': 'connections',
 | 
			
		||||
			'@': 'mentions',
 | 
			
		||||
			'🔍': 'search',
 | 
			
		||||
			'👩💻': 'query',
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		let tabs = html`
 | 
			
		||||
			<div
 | 
			
		||||
				class="w3-bar w3-theme-l1"
 | 
			
		||||
				style="position: static; top: 0; z-index: 10"
 | 
			
		||||
			>
 | 
			
		||||
				${this.is_administrator
 | 
			
		||||
					? html`
 | 
			
		||||
							<button
 | 
			
		||||
								class=${'w3-bar-item w3-button w3-circle w3-ripple' +
 | 
			
		||||
								(this.connections?.some((x) => x.flags.one_shot)
 | 
			
		||||
									? ' w3-spin'
 | 
			
		||||
									: '')}
 | 
			
		||||
								@click=${this.refresh}
 | 
			
		||||
							>
 | 
			
		||||
								↻
 | 
			
		||||
							</button>
 | 
			
		||||
							<button
 | 
			
		||||
								class="w3-bar-item w3-button w3-ripple"
 | 
			
		||||
								@click=${this.toggle_stay_connected}
 | 
			
		||||
							>
 | 
			
		||||
								${this.stay_connected ? '🔗' : '⛓️💥'}
 | 
			
		||||
							</button>
 | 
			
		||||
						`
 | 
			
		||||
					: undefined}
 | 
			
		||||
			<div class="w3-bar w3-theme-l1">
 | 
			
		||||
				${Object.entries(k_tabs).map(
 | 
			
		||||
					([k, v]) => html`
 | 
			
		||||
						<button
 | 
			
		||||
							title=${v}
 | 
			
		||||
							class="w3-bar-item w3-padding w3-hover-theme tab ${self.tab == v
 | 
			
		||||
							class="w3-bar-item w3-padding-large w3-hover-theme tab ${self.tab ==
 | 
			
		||||
							v
 | 
			
		||||
								? 'w3-theme-l2'
 | 
			
		||||
								: 'w3-theme-l1'}"
 | 
			
		||||
							@click=${() => self.set_tab(v)}
 | 
			
		||||
						>
 | 
			
		||||
							${k}
 | 
			
		||||
							<span class=${self.tab == v ? '' : 'w3-hide-small'}
 | 
			
		||||
								>${v.charAt(0).toUpperCase() + v.substring(1)}</span
 | 
			
		||||
							>
 | 
			
		||||
						</button>
 | 
			
		||||
					`
 | 
			
		||||
				)}
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-bar-item w3-button w3-right"
 | 
			
		||||
					@click=${this.pick_color}
 | 
			
		||||
				>
 | 
			
		||||
					🎨<span class="w3-hide-small">Color</span>
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
		let contents = this.guest
 | 
			
		||||
			? html`<div
 | 
			
		||||
					class="w3-display-middle w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge w3-xlarge w3-container"
 | 
			
		||||
				>
 | 
			
		||||
					<p>⚠️🦀 Must be logged in to Tilde Friends to scuttle here. 🦀⚠️</p>
 | 
			
		||||
					<footer class="w3-center">
 | 
			
		||||
						<a
 | 
			
		||||
							class="w3-button w3-theme-d1"
 | 
			
		||||
							href=${`/login?return=${encodeURIComponent(this.url)}`}
 | 
			
		||||
							>Login</a
 | 
			
		||||
						>
 | 
			
		||||
					</footer>
 | 
			
		||||
				</div>`
 | 
			
		||||
			: !this.loaded || this.loading
 | 
			
		||||
				? html`<div
 | 
			
		||||
						class="w3-display-middle w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge w3-xlarge"
 | 
			
		||||
					>
 | 
			
		||||
						<span class="w3-spin" style="display: inline-block">🦀</span>
 | 
			
		||||
						Loading...
 | 
			
		||||
					</div>`
 | 
			
		||||
				: this.render_tab();
 | 
			
		||||
		let progress =
 | 
			
		||||
			this.progress !== undefined
 | 
			
		||||
				? html`
 | 
			
		||||
						<div style="position: absolute; width: 100%" id="progress">
 | 
			
		||||
							<div
 | 
			
		||||
								class="w3-theme-l3"
 | 
			
		||||
								style=${`height: 4px; position: absolute; right: ${Math.cos(this.progress / 250) > 0 ? 'auto' : '0'}; width: ${50 * Math.sin(this.progress / 250) + 50}%`}
 | 
			
		||||
							></div>
 | 
			
		||||
						</div>
 | 
			
		||||
					`
 | 
			
		||||
				: undefined;
 | 
			
		||||
		let contents = !this.loaded
 | 
			
		||||
			? this.loading
 | 
			
		||||
				? html`<div class="w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge">
 | 
			
		||||
					Loading...
 | 
			
		||||
				</div>
 | 
			
		||||
				${this.render_tab()}`
 | 
			
		||||
				: html`<div>Select or create an identity.</div>`
 | 
			
		||||
			: this.render_tab();
 | 
			
		||||
		return html`
 | 
			
		||||
			<style>
 | 
			
		||||
				${generate_theme()}
 | 
			
		||||
			</style>
 | 
			
		||||
			<div
 | 
			
		||||
				style="width: 100vw; min-height: 100vh; height: 100vh; display: flex; flex-direction: column"
 | 
			
		||||
				style="width: 100vw; min-height: 100vh; height: 100%"
 | 
			
		||||
				class="w3-theme-dark"
 | 
			
		||||
			>
 | 
			
		||||
				${progress}
 | 
			
		||||
				<div style="flex: 0 0">${tabs}</div>
 | 
			
		||||
				<div style="flex: 1 1; overflow: auto; contain: layout">
 | 
			
		||||
				${tabs}
 | 
			
		||||
				<div style="padding: 8px">
 | 
			
		||||
					${this.tags.map(
 | 
			
		||||
						(x) => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>`
 | 
			
		||||
					)}
 | 
			
		||||
					${contents}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import {LitElement, html, unsafeHTML, live} 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 {
 | 
			
		||||
@@ -14,9 +14,6 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			apps: {type: Object},
 | 
			
		||||
			drafts: {type: Object},
 | 
			
		||||
			author: {type: String},
 | 
			
		||||
			channel: {type: String},
 | 
			
		||||
			new_thread: {type: Boolean},
 | 
			
		||||
			recipients: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -30,7 +27,6 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		this.apps = undefined;
 | 
			
		||||
		this.drafts = {};
 | 
			
		||||
		this.author = undefined;
 | 
			
		||||
		this.new_thread = false;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	process_text(text) {
 | 
			
		||||
@@ -80,9 +76,15 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		let preview = this.renderRoot.getElementById('preview');
 | 
			
		||||
		preview.innerHTML = this.process_text(edit.innerText);
 | 
			
		||||
		let content_warning = this.renderRoot.getElementById('content_warning');
 | 
			
		||||
		let content_warning_preview = this.renderRoot.getElementById(
 | 
			
		||||
			'content_warning_preview'
 | 
			
		||||
		);
 | 
			
		||||
		if (content_warning && content_warning_preview) {
 | 
			
		||||
			content_warning_preview.innerText = content_warning.value;
 | 
			
		||||
		}
 | 
			
		||||
		let draft = this.get_draft();
 | 
			
		||||
		draft.text = edit.innerText;
 | 
			
		||||
		draft.content_warning = content_warning?.value;
 | 
			
		||||
		draft.content_warning = content_warning?.innerText;
 | 
			
		||||
		setTimeout(() => this.notify(draft), 0);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -92,9 +94,7 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				composed: true,
 | 
			
		||||
				detail: {
 | 
			
		||||
					id:
 | 
			
		||||
						this.branch ??
 | 
			
		||||
						(this.recipients ? this.recipients.join(',') : undefined),
 | 
			
		||||
					id: this.branch,
 | 
			
		||||
					draft: draft,
 | 
			
		||||
				},
 | 
			
		||||
			})
 | 
			
		||||
@@ -186,13 +186,6 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
				break;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		event.preventDefault();
 | 
			
		||||
		document.execCommand(
 | 
			
		||||
			'insertText',
 | 
			
		||||
			false,
 | 
			
		||||
			event.clipboardData.getData('text/plain')
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async submit() {
 | 
			
		||||
@@ -202,26 +195,11 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		let message = {
 | 
			
		||||
			type: 'post',
 | 
			
		||||
			text: edit.innerText,
 | 
			
		||||
			channel: this.channel,
 | 
			
		||||
		};
 | 
			
		||||
		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,8 +221,12 @@ 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.innerText = '';
 | 
			
		||||
				self.input();
 | 
			
		||||
				self.notify(undefined);
 | 
			
		||||
				self.requestUpdate();
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			alert(error.message);
 | 
			
		||||
		}
 | 
			
		||||
@@ -258,12 +240,10 @@ class TfComposeElement 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];
 | 
			
		||||
			self.add_file(file);
 | 
			
		||||
		});
 | 
			
		||||
		document.body.appendChild(input);
 | 
			
		||||
		};
 | 
			
		||||
		input.click();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -273,9 +253,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,7 +274,7 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	get_values() {
 | 
			
		||||
	firstUpdated() {
 | 
			
		||||
		let values = Object.entries(this.users).map((x) => ({
 | 
			
		||||
			key: x[1].name ?? x[0],
 | 
			
		||||
			value: x[0],
 | 
			
		||||
@@ -310,34 +290,24 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
				values
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
		return values;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	firstUpdated() {
 | 
			
		||||
		let tribute = new Tribute({
 | 
			
		||||
			iframe: this.shadowRoot,
 | 
			
		||||
			collection: [
 | 
			
		||||
				{
 | 
			
		||||
					values: this.get_values(),
 | 
			
		||||
					values: values,
 | 
			
		||||
					selectTemplate: function (item) {
 | 
			
		||||
						return item
 | 
			
		||||
							? `[@${item.original.key}](${item.original.value})`
 | 
			
		||||
							: undefined;
 | 
			
		||||
						return item ? `[@${item.original.key}](${item.original.value})` : undefined;
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				{
 | 
			
		||||
					trigger: '&',
 | 
			
		||||
					values: this.autocomplete,
 | 
			
		||||
					selectTemplate: function (item) {
 | 
			
		||||
						return item
 | 
			
		||||
							? ``
 | 
			
		||||
							: undefined;
 | 
			
		||||
						return item ? `` : undefined;
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
			],
 | 
			
		||||
		});
 | 
			
		||||
		tribute.attach(this.renderRoot.getElementById('edit'));
 | 
			
		||||
		this._tribute = tribute;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updated() {
 | 
			
		||||
@@ -348,11 +318,9 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			preview.innerHTML = this.process_text(edit.innerText);
 | 
			
		||||
			this.last_updated_text = edit.innerText;
 | 
			
		||||
		}
 | 
			
		||||
		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 +336,7 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
	remove_mention(id) {
 | 
			
		||||
		let draft = this.get_draft();
 | 
			
		||||
		delete draft.mentions[id];
 | 
			
		||||
		setTimeout(() => this.notify(draft), 0);
 | 
			
		||||
		setTimeout(() => this.notify(), 0);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_mention(mention) {
 | 
			
		||||
@@ -455,15 +423,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-theme-d1" @click=${attach_app}>
 | 
			
		||||
				Attach App
 | 
			
		||||
			</button>`;
 | 
			
		||||
		} else {
 | 
			
		||||
			return html`<button
 | 
			
		||||
				class="w3-button w3-bar-item w3-theme-d1"
 | 
			
		||||
				class="w3-button w3-theme-d1"
 | 
			
		||||
				@click=${() => (this.apps = null)}
 | 
			
		||||
			>
 | 
			
		||||
				Discard App
 | 
			
		||||
@@ -484,38 +449,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-theme-d1" 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-theme-d1" 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-theme-d1" 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,7 +487,7 @@ 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>
 | 
			
		||||
				<input type="text" id="encrypt_to" style="display: flex; flex: 1 1" @input=${this.update_encrypt}></input>
 | 
			
		||||
				<button class="w3-button w3-theme-d1" @click=${() => this.set_encrypt(undefined)}>🚮</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<ul>
 | 
			
		||||
@@ -559,31 +509,6 @@ 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();
 | 
			
		||||
@@ -597,102 +522,49 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			draft.encrypt_to !== undefined
 | 
			
		||||
				? undefined
 | 
			
		||||
				: html`<button
 | 
			
		||||
						class="w3-button w3-bar-item w3-theme-d1"
 | 
			
		||||
						class="w3-button w3-theme-d1"
 | 
			
		||||
						@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-theme-d4 w3-padding-small"
 | 
			
		||||
				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">
 | 
			
		||||
				${this.render_encrypt()}
 | 
			
		||||
				<div class="w3-container w3-padding-small">
 | 
			
		||||
					<div class="w3-half">
 | 
			
		||||
						<span
 | 
			
		||||
							class="w3-input w3-theme-d1 w3-border"
 | 
			
		||||
							style="resize: vertical; width: 100%; white-space: pre-wrap"
 | 
			
		||||
							style="resize: vertical; width: 100%; overflow: hidden; white-space: pre-wrap"
 | 
			
		||||
							placeholder="Write a post here."
 | 
			
		||||
							id="edit"
 | 
			
		||||
							@input=${this.input}
 | 
			
		||||
							@paste=${this.paste}
 | 
			
		||||
							contenteditable="plaintext-only"
 | 
			
		||||
							contenteditable
 | 
			
		||||
							.innerText=${live(draft.text ?? '')}
 | 
			
		||||
						></span>
 | 
			
		||||
							></span>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div class="w3-half w3-container">
 | 
			
		||||
					<div class="w3-half w3-padding">
 | 
			
		||||
						${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-theme-d1" id="submit" @click=${this.submit}>
 | 
			
		||||
					Submit
 | 
			
		||||
				</button>
 | 
			
		||||
				<button class="w3-button w3-theme-d1" @click=${this.attach}>
 | 
			
		||||
					Attach
 | 
			
		||||
				</button>
 | 
			
		||||
				${this.render_attach_app_button()} ${encrypt}
 | 
			
		||||
				<button class="w3-button w3-theme-d1" @click=${this.discard}>
 | 
			
		||||
					Discard
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
		return result;
 | 
			
		||||
 
 | 
			
		||||
@@ -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,116 +171,74 @@ 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"
 | 
			
		||||
				let server_follow;
 | 
			
		||||
				if (this.server_follows_me === true) {
 | 
			
		||||
					server_follow = html`<button
 | 
			
		||||
						class="w3-button w3-theme-d1"
 | 
			
		||||
						@click=${this.save_edits}
 | 
			
		||||
						@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-theme-d1"
 | 
			
		||||
						@click=${() => this.server_follow_me(true)}
 | 
			
		||||
					>
 | 
			
		||||
						Server, Follow Me
 | 
			
		||||
					</button>`;
 | 
			
		||||
				}
 | 
			
		||||
				edit = html`
 | 
			
		||||
					<button class="w3-button w3-theme-d1" @click=${this.save_edits}>
 | 
			
		||||
						Save Profile
 | 
			
		||||
					</button>
 | 
			
		||||
					<button class="w3-button w3-theme-d1" @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-theme-d1" @click=${this.edit}>
 | 
			
		||||
					Edit Profile
 | 
			
		||||
				</button>`;
 | 
			
		||||
			}
 | 
			
		||||
@@ -293,72 +264,47 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
		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-theme-d1" 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-theme-d1" 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-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>
 | 
			
		||||
				</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,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 TfReactionsModalElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
@@ -24,57 +24,43 @@ class TfReactionsModalElement extends LitElement {
 | 
			
		||||
	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>
 | 
			
		||||
			? html` <div
 | 
			
		||||
					class="w3-modal w3-animate-opacity"
 | 
			
		||||
					style="display: block; box-sizing: border-box"
 | 
			
		||||
				>
 | 
			
		||||
					<div class="w3-modal-content w3-card-4 w3-theme-d1">
 | 
			
		||||
						<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.map(
 | 
			
		||||
									(x) => html`
 | 
			
		||||
										<li class="w3-bar">
 | 
			
		||||
											<span class="w3-bar-item"
 | 
			
		||||
												>${x?.content?.vote?.expression}</span
 | 
			
		||||
											>
 | 
			
		||||
											<tf-user
 | 
			
		||||
												class="w3-bar-item"
 | 
			
		||||
												id=${x.author}
 | 
			
		||||
												.users=${this.users}
 | 
			
		||||
											></tf-user>
 | 
			
		||||
											<span class="w3-bar-item w3-right"
 | 
			
		||||
												>${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>
 | 
			
		||||
				</div>`
 | 
			
		||||
			: undefined;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
import {css, unsafeCSS, until} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
import {css} from './lit-all.min.js';
 | 
			
		||||
 | 
			
		||||
const tf = css`
 | 
			
		||||
	img {
 | 
			
		||||
@@ -44,14 +43,12 @@ const tf = css`
 | 
			
		||||
		border-left: 4px solid #fff;
 | 
			
		||||
		padding: 8px;
 | 
			
		||||
		padding-left: 12px;
 | 
			
		||||
		margin-left: 0;
 | 
			
		||||
		margin-right: 0;
 | 
			
		||||
	}
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
// prettier-ignore
 | 
			
		||||
const w3 = css`
 | 
			
		||||
/* W3.CSS 5.01 March 14 2025 by Jan Egil and Borge Refsnes */
 | 
			
		||||
/* W3.CSS 4.15 December 2020 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}
 | 
			
		||||
@@ -91,7 +88,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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-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%}
 | 
			
		||||
@@ -139,7 +136,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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-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}}
 | 
			
		||||
@@ -161,10 +158,6 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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%}
 | 
			
		||||
@@ -206,9 +199,9 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
 | 
			
		||||
.w3-hover-none:hover{box-shadow:none!important}
 | 
			
		||||
/* Colors */
 | 
			
		||||
.w3-amber,.w3-hover-amber:hover,.w3-warning{color:#000!important;background-color:#ffc107!important}
 | 
			
		||||
.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,.w3-info,.w3-primary{color:#fff!important;background-color:#2196F3!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}
 | 
			
		||||
@@ -223,24 +216,15 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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,.w3-danger{color:#fff!important;background-color:#f44336!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,.w3-note{color:#000!important;background-color:#ffeb3b!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,.w3-secondary{color:#000!important;background-color:#9e9e9e!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,.w3-success{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-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}
 | 
			
		||||
@@ -301,181 +285,30 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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}
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
function rgb_to_hsl(r, g, b) {
 | 
			
		||||
	let min,
 | 
			
		||||
		max,
 | 
			
		||||
		i,
 | 
			
		||||
		l,
 | 
			
		||||
		s,
 | 
			
		||||
		maxcolor,
 | 
			
		||||
		h,
 | 
			
		||||
		rgb = [];
 | 
			
		||||
	rgb[0] = r / 255;
 | 
			
		||||
	rgb[1] = g / 255;
 | 
			
		||||
	rgb[2] = b / 255;
 | 
			
		||||
	min = rgb[0];
 | 
			
		||||
	max = rgb[0];
 | 
			
		||||
	maxcolor = 0;
 | 
			
		||||
	for (i = 0; i < rgb.length - 1; i++) {
 | 
			
		||||
		if (rgb[i + 1] <= min) {
 | 
			
		||||
			min = rgb[i + 1];
 | 
			
		||||
		}
 | 
			
		||||
		if (rgb[i + 1] >= max) {
 | 
			
		||||
			max = rgb[i + 1];
 | 
			
		||||
			maxcolor = i + 1;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if (maxcolor == 0) {
 | 
			
		||||
		h = (rgb[1] - rgb[2]) / (max - min);
 | 
			
		||||
	}
 | 
			
		||||
	if (maxcolor == 1) {
 | 
			
		||||
		h = 2 + (rgb[2] - rgb[0]) / (max - min);
 | 
			
		||||
	}
 | 
			
		||||
	if (maxcolor == 2) {
 | 
			
		||||
		h = 4 + (rgb[0] - rgb[1]) / (max - min);
 | 
			
		||||
	}
 | 
			
		||||
	if (isNaN(h)) {
 | 
			
		||||
		h = 0;
 | 
			
		||||
	}
 | 
			
		||||
	h = h * 60;
 | 
			
		||||
	if (h < 0) {
 | 
			
		||||
		h = h + 360;
 | 
			
		||||
	}
 | 
			
		||||
	l = (min + max) / 2;
 | 
			
		||||
	if (min == max) {
 | 
			
		||||
		s = 0;
 | 
			
		||||
	} else {
 | 
			
		||||
		if (l < 0.5) {
 | 
			
		||||
			s = (max - min) / (max + min);
 | 
			
		||||
		} else {
 | 
			
		||||
			s = (max - min) / (2 - max - min);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	s = s;
 | 
			
		||||
	return [h, s, l];
 | 
			
		||||
}
 | 
			
		||||
// prettier-ignore
 | 
			
		||||
const w3_2016_riverside = css`
 | 
			
		||||
.w3-theme-l5 {color:#000 !important; background-color:#f4f6f9 !important}
 | 
			
		||||
.w3-theme-l4 {color:#000 !important; background-color:#d9e1ec !important}
 | 
			
		||||
.w3-theme-l3 {color:#000 !important; background-color:#b4c3d8 !important}
 | 
			
		||||
.w3-theme-l2 {color:#fff !important; background-color:#8ea6c5 !important}
 | 
			
		||||
.w3-theme-l1 {color:#fff !important; background-color:#6888b1 !important}
 | 
			
		||||
.w3-theme-d1 {color:#fff !important; background-color:#456185 !important}
 | 
			
		||||
.w3-theme-d2 {color:#fff !important; background-color:#3d5676 !important}
 | 
			
		||||
.w3-theme-d3 {color:#fff !important; background-color:#354b68 !important}
 | 
			
		||||
.w3-theme-d4 {color:#fff !important; background-color:#2e4059 !important}
 | 
			
		||||
.w3-theme-d5 {color:#fff !important; background-color:#26364a !important}
 | 
			
		||||
 | 
			
		||||
function hex_to_rgb(hex) {
 | 
			
		||||
	if (hex.charAt(0) == '#') {
 | 
			
		||||
		hex = hex.substring(1);
 | 
			
		||||
	}
 | 
			
		||||
	return [
 | 
			
		||||
		parseInt(hex.substring(0, 2), 16),
 | 
			
		||||
		parseInt(hex.substring(2, 4), 16),
 | 
			
		||||
		parseInt(hex.substring(4, 6), 16),
 | 
			
		||||
	];
 | 
			
		||||
}
 | 
			
		||||
.w3-theme-light {color:#000 !important; background-color:#f4f6f9 !important}
 | 
			
		||||
.w3-theme-dark {color:#fff !important; background-color:#26364a !important}
 | 
			
		||||
.w3-theme-action {color:#fff !important; background-color:#26364a !important}
 | 
			
		||||
 | 
			
		||||
function hsl_to_rgb(hue, sat, light) {
 | 
			
		||||
	let t2;
 | 
			
		||||
	hue /= 60;
 | 
			
		||||
	if (light <= 0.5) {
 | 
			
		||||
		t2 = light * (sat + 1);
 | 
			
		||||
	} else {
 | 
			
		||||
		t2 = light + sat - light * sat;
 | 
			
		||||
	}
 | 
			
		||||
	let t1 = light * 2 - t2;
 | 
			
		||||
	return [
 | 
			
		||||
		hue_to_rgb(t1, t2, hue + 2) * 255,
 | 
			
		||||
		hue_to_rgb(t1, t2, hue) * 255,
 | 
			
		||||
		hue_to_rgb(t1, t2, hue - 2) * 255,
 | 
			
		||||
	];
 | 
			
		||||
}
 | 
			
		||||
function hue_to_rgb(t1, t2, hue) {
 | 
			
		||||
	if (hue < 0) {
 | 
			
		||||
		hue += 6;
 | 
			
		||||
	}
 | 
			
		||||
	if (hue >= 6) {
 | 
			
		||||
		hue -= 6;
 | 
			
		||||
	}
 | 
			
		||||
	if (hue < 1) {
 | 
			
		||||
		return (t2 - t1) * hue + t1;
 | 
			
		||||
	} else if (hue < 3) {
 | 
			
		||||
		return t2;
 | 
			
		||||
	} else if (hue < 4) {
 | 
			
		||||
		return (t2 - t1) * (4 - hue) + t1;
 | 
			
		||||
	} else {
 | 
			
		||||
		return t1;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
.w3-theme {color:#fff !important; background-color:#4c6a92 !important}
 | 
			
		||||
.w3-text-theme {color:#4c6a92 !important}
 | 
			
		||||
.w3-border-theme {border-color:#4c6a92 !important}
 | 
			
		||||
 | 
			
		||||
function rgb_to_hex(rgb) {
 | 
			
		||||
	const hex_pair = (x) => Math.floor(x).toString(16).padStart(2, '0');
 | 
			
		||||
	return `#${hex_pair(rgb[0])}${hex_pair(rgb[1])}${hex_pair(rgb[2])}`;
 | 
			
		||||
}
 | 
			
		||||
.w3-hover-theme:hover {color:#fff !important; background-color:#4c6a92 !important}
 | 
			
		||||
.w3-hover-text-theme:hover {color:#4c6a92 !important}
 | 
			
		||||
.w3-hover-border-theme:hover {border-color:#4c6a92 !important}
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
function is_dark(hex, value) {
 | 
			
		||||
	let [r, g, b] = hex_to_rgb(hex);
 | 
			
		||||
	return (r * 299 + g * 587 + b * 114) / 1000 < value;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function generate(color) {
 | 
			
		||||
	let [r, g, b] = hex_to_rgb(color);
 | 
			
		||||
	let [h, s, l] = rgb_to_hsl(r, g, b);
 | 
			
		||||
 | 
			
		||||
	let theme1 = {
 | 
			
		||||
		l5: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 4.7)),
 | 
			
		||||
		l4: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 4)),
 | 
			
		||||
		l3: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 3)),
 | 
			
		||||
		l2: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 2)),
 | 
			
		||||
		l1: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 1)),
 | 
			
		||||
		d0: rgb_to_hex(hsl_to_rgb(h, s, l)),
 | 
			
		||||
		d1: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 0.5)),
 | 
			
		||||
		d2: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 1)),
 | 
			
		||||
		d3: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 1.5)),
 | 
			
		||||
		d4: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 2)),
 | 
			
		||||
		d5: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 2.5)),
 | 
			
		||||
	};
 | 
			
		||||
	for (let [k, v] of Object.entries(theme1)) {
 | 
			
		||||
		theme1['t' + k] = is_dark(v, 165) ? '#fff' : '#000';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	let result = `
 | 
			
		||||
		.w3-theme-l5 {color: ${theme1.tl5} !important; background-color: ${theme1.l5} !important}
 | 
			
		||||
		.w3-theme-l4 {color: ${theme1.tl4} !important; background-color: ${theme1.l4} !important}
 | 
			
		||||
		.w3-theme-l3 {color: ${theme1.tl3} !important; background-color: ${theme1.l3} !important}
 | 
			
		||||
		.w3-theme-l2 {color: ${theme1.tl2} !important; background-color: ${theme1.l2} !important}
 | 
			
		||||
		.w3-theme-l1 {color: ${theme1.tl1} !important; background-color: ${theme1.l1} !important}
 | 
			
		||||
		.w3-theme-d1 {color: ${theme1.td1} !important; background-color: ${theme1.d1} !important}
 | 
			
		||||
		.w3-theme-d2 {color: ${theme1.td2} !important; background-color: ${theme1.d2} !important}
 | 
			
		||||
		.w3-theme-d3 {color: ${theme1.td3} !important; background-color: ${theme1.d3} !important}
 | 
			
		||||
		.w3-theme-d4 {color: ${theme1.td4} !important; background-color: ${theme1.d4} !important}
 | 
			
		||||
		.w3-theme-d5 {color: ${theme1.td5} !important; background-color: ${theme1.d5} !important}
 | 
			
		||||
		.w3-theme-light {color: ${theme1.tl5} !important; background-color: ${theme1.l5} !important}
 | 
			
		||||
		.w3-theme-dark {color: ${theme1.td5} !important; background-color: ${theme1.d5} !important}
 | 
			
		||||
		.w3-theme-action {color: ${theme1.td5} !important; background-color: ${theme1.d5} !important}
 | 
			
		||||
		.w3-theme {color: ${theme1.td0} !important; background-color: ${theme1.d0} !important}
 | 
			
		||||
		.w3-text-theme {color: ${theme1.d0} !important}
 | 
			
		||||
		.w3-border-theme {border-color: ${theme1.d0} !important}
 | 
			
		||||
		.w3-hover-theme:hover {color: ${theme1.td0} !important; background-color: ${theme1.d0} !important}
 | 
			
		||||
		.w3-hover-text-theme:hover {color: ${theme1.d0} !important}
 | 
			
		||||
		.w3-hover-border-theme:hover {border-color: ${theme1.d0} !important}
 | 
			
		||||
	`;
 | 
			
		||||
	return unsafeCSS(result);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let g_theme;
 | 
			
		||||
export function generate_theme() {
 | 
			
		||||
	return g_theme
 | 
			
		||||
		? g_theme
 | 
			
		||||
		: until(
 | 
			
		||||
				tfrpc.rpc.localStorageGet('color').then(function (value) {
 | 
			
		||||
					g_theme = generate(value ?? '#034f84');
 | 
			
		||||
					return g_theme;
 | 
			
		||||
				}),
 | 
			
		||||
				generated_now()
 | 
			
		||||
			);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function generated_now() {
 | 
			
		||||
	let now = new Date();
 | 
			
		||||
	return generate(
 | 
			
		||||
		rgb_to_hex([
 | 
			
		||||
			(now.getDay() * 128) / 6,
 | 
			
		||||
			(now.getHours() * 128) / 23,
 | 
			
		||||
			(now.getSeconds() * 128) / 59,
 | 
			
		||||
		])
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export let styles = [tf, w3];
 | 
			
		||||
export let styles = [tf, w3, w3_2016_riverside];
 | 
			
		||||
 
 | 
			
		||||
@@ -1,67 +1,34 @@
 | 
			
		||||
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) {
 | 
			
		||||
@@ -106,53 +73,19 @@ 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>
 | 
			
		||||
			<li class="w3-bar" style="overflow: hidden; overflow-wrap: nowrap">
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-bar-item w3-button w3-theme-d1"
 | 
			
		||||
					@click=${() => tfrpc.rpc.connect(connection)}
 | 
			
		||||
				>
 | 
			
		||||
					Connect
 | 
			
		||||
				</button>
 | 
			
		||||
				<div class="w3-bar-item">
 | 
			
		||||
					<tf-user id=${connection.pubkey} .users=${this.users}></tf-user>
 | 
			
		||||
					${this.render_connection_summary(connection)}
 | 
			
		||||
				</div>
 | 
			
		||||
				${this.render_message(connection)}
 | 
			
		||||
			</li>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
@@ -163,225 +96,92 @@ 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-theme-d1"
 | 
			
		||||
				@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>
 | 
			
		||||
			<div>${connection.requests.map(x => html`
 | 
			
		||||
				<span class="w3-tag w3-small">${x.request_number > 0 ? '🟩' : '🟥'} ${x.name}</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>
 | 
			
		||||
				<h2>New Connection</h2>
 | 
			
		||||
				<textarea class="w3-input w3-theme-d1" id="code"></textarea>
 | 
			
		||||
				${this.render_message(this.renderRoot.getElementById('code')?.value)}
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-theme-d1"
 | 
			
		||||
					@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 class="w3-ul w3-border">
 | 
			
		||||
					${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 class="w3-ul w3-border">
 | 
			
		||||
					${this.connections
 | 
			
		||||
						.filter((x) => x.tunnel === undefined)
 | 
			
		||||
						.map(
 | 
			
		||||
							(x) => html`
 | 
			
		||||
								<li class="w3-bar">${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</h2>
 | 
			
		||||
				<ul class="w3-ul w3-border">
 | 
			
		||||
					${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>
 | 
			
		||||
							<li 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=${() => tfrpc.rpc.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>
 | 
			
		||||
								${this.render_message(x)}
 | 
			
		||||
							</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 class="w3-ul w3-border">
 | 
			
		||||
					${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}
 | 
			
		||||
							html`<li class="w3-bar">
 | 
			
		||||
								<tf-user id=${x} .users=${this.users}></tf-user>
 | 
			
		||||
							</div>`
 | 
			
		||||
							</li>`
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
				</ul>
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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-theme-d1" @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) {
 | 
			
		||||
@@ -110,360 +105,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-theme-d1"
 | 
			
		||||
					@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
									
								
							
							
						
						@@ -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-theme-d1"
 | 
			
		||||
					style="flex: 1; resize: vertical"
 | 
			
		||||
					@keydown=${this.search_keydown}
 | 
			
		||||
				>
 | 
			
		||||
${this.query}</textarea
 | 
			
		||||
				>
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-theme-d1"
 | 
			
		||||
					@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-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>
 | 
			
		||||
			<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>`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,12 +2,6 @@ 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 +44,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;
 | 
			
		||||
@@ -87,8 +81,8 @@ function attrs(node) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
@@ -104,12 +98,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!
 | 
			
		||||
@@ -1,5 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🕸",
 | 
			
		||||
	"previous": "&n7hu5b8/TsfiG6FDlCRG5nPCrIdCr96+xpIJ/aQT/uM=.sha256"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										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();
 | 
			
		||||
@@ -1,63 +0,0 @@
 | 
			
		||||
async function query(sql, params) {
 | 
			
		||||
	let results = [];
 | 
			
		||||
	await ssb.sqlAsync(sql, params, function (row) {
 | 
			
		||||
		results.push(row);
 | 
			
		||||
	});
 | 
			
		||||
	return results;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function guess_content_type(name) {
 | 
			
		||||
	if (name.endsWith('.html')) {
 | 
			
		||||
		return 'text/html; charset=UTF-8';
 | 
			
		||||
	} else if (name.endsWith('.js') || name.endsWith('.mjs')) {
 | 
			
		||||
		return 'text/javascript; charset=UTF-8';
 | 
			
		||||
	} else if (name.endsWith('.css')) {
 | 
			
		||||
		return 'text/stylesheet; charset=UTF-8';
 | 
			
		||||
	} else {
 | 
			
		||||
		return 'application/binary';
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	let path = request.path.replaceAll(/(%[0-9a-fA-F]{2})/g, (x) =>
 | 
			
		||||
		String.fromCharCode(parseInt(x.substring(1), 16))
 | 
			
		||||
	);
 | 
			
		||||
	let match = path.match(/^(%.{44}\.sha256)(?:\/)?(.*)$/);
 | 
			
		||||
 | 
			
		||||
	let content_type = guess_content_type(request.path);
 | 
			
		||||
	let root = await query(
 | 
			
		||||
		`
 | 
			
		||||
		SELECT root.content ->> 'root' AS root
 | 
			
		||||
		FROM messages site
 | 
			
		||||
		JOIN messages root
 | 
			
		||||
		ON site.id = ? AND root.author = site.author AND root.content ->> 'site' = site.id
 | 
			
		||||
		ORDER BY root.sequence DESC LIMIT 1
 | 
			
		||||
	`,
 | 
			
		||||
		[match[1]]
 | 
			
		||||
	);
 | 
			
		||||
	let root_id = root[0]['root'];
 | 
			
		||||
	let last_id = root_id;
 | 
			
		||||
	let blob = await ssb.blobGet(root_id);
 | 
			
		||||
	try {
 | 
			
		||||
		for (let part of match[2]?.split('/')) {
 | 
			
		||||
			let dir = JSON.parse(utf8Decode(blob));
 | 
			
		||||
			last_id = dir?.links[part];
 | 
			
		||||
			blob = await ssb.blobGet(dir?.links[part]);
 | 
			
		||||
			content_type = guess_content_type(part);
 | 
			
		||||
		}
 | 
			
		||||
	} catch {}
 | 
			
		||||
 | 
			
		||||
	respond({
 | 
			
		||||
		status_code: 200,
 | 
			
		||||
		data: blob ? utf8Decode(blob) : `${last_id} not found`,
 | 
			
		||||
		content_type: content_type,
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main().catch(function (e) {
 | 
			
		||||
	respond({
 | 
			
		||||
		status_code: 200,
 | 
			
		||||
		data: `${e.message}\n${e.stack}`,
 | 
			
		||||
		content_type: 'text/plain',
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "👋",
 | 
			
		||||
	"previous": "&n1QkPkB5JoduFSx8UKOY3IlZqS2GwLiTUZv4ZrEOthQ=.sha256"
 | 
			
		||||
	"previous": "&W5aJp2DgOW5rQ0AOIC9Ut3DpsahPrO6PjkJ1PQbNRdM=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								apps/welcome/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,5 @@
 | 
			
		||||
async function main() {
 | 
			
		||||
	await app.setDocument(utf8Decode(getFile('index.html')));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
@@ -1,78 +0,0 @@
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
			
		||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
 | 
			
		||||
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="48px" height="48px" id="svg3832" version="1.1" inkscape:version="0.47 r22583" sodipodi:docname="appimage-assistant_alt3.svg">
 | 
			
		||||
  <defs id="defs3834">
 | 
			
		||||
    <linearGradient inkscape:collect="always" xlink:href="#linearGradient3308-4-6-931-761-0" id="linearGradient2975" gradientUnits="userSpaceOnUse" x1="24.3125" y1="22.96875" x2="24.3125" y2="41.03125"/>
 | 
			
		||||
    <linearGradient id="linearGradient3308-4-6-931-761-0">
 | 
			
		||||
      <stop id="stop2919-2" style="stop-color:#ffffff;stop-opacity:1" offset="0"/>
 | 
			
		||||
      <stop id="stop2921-76" style="stop-color:#ffffff;stop-opacity:0" offset="1"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient inkscape:collect="always" xlink:href="#linearGradient4222" id="linearGradient2979" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,0.3704967,-0.3617496,0,33.508315,6.1670925)" x1="7.6485429" y1="26.437023" x2="41.861729" y2="26.437023"/>
 | 
			
		||||
    <linearGradient id="linearGradient4222">
 | 
			
		||||
      <stop id="stop4224" style="stop-color:#ffffff;stop-opacity:1" offset="0"/>
 | 
			
		||||
      <stop id="stop4226" style="stop-color:#ffffff;stop-opacity:0" offset="1"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient inkscape:collect="always" xlink:href="#linearGradient3308-4-6-931-761" id="linearGradient2982" gradientUnits="userSpaceOnUse" gradientTransform="translate(0,0.9999987)" x1="23.99999" y1="4.999989" x2="23.99999" y2="43"/>
 | 
			
		||||
    <linearGradient id="linearGradient3308-4-6-931-761">
 | 
			
		||||
      <stop id="stop2919" style="stop-color:#ffffff;stop-opacity:1" offset="0"/>
 | 
			
		||||
      <stop id="stop2921" style="stop-color:#ffffff;stop-opacity:0" offset="1"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <radialGradient inkscape:collect="always" xlink:href="#linearGradient3575" id="radialGradient2985" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,1.0262008,-1.6561124,9.4072203e-4,-56.097482,-45.332325)" cx="48.42384" cy="-48.027504" fx="48.42384" fy="-48.027504" r="38.212933"/>
 | 
			
		||||
    <linearGradient id="linearGradient3575">
 | 
			
		||||
      <stop id="stop3577" style="stop-color:#fafafa;stop-opacity:1" offset="0"/>
 | 
			
		||||
      <stop id="stop3579" style="stop-color:#e6e6e6;stop-opacity:1" offset="1"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <radialGradient inkscape:collect="always" xlink:href="#linearGradient3993" id="radialGradient2990" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,2.0478765,-2.7410544,-8.6412258e-8,47.161382,-8.837436)" cx="9.3330879" cy="8.4497671" fx="9.3330879" fy="8.4497671" r="19.99999"/>
 | 
			
		||||
    <linearGradient id="linearGradient3993">
 | 
			
		||||
      <stop offset="0" style="stop-color:#a3c0d0;stop-opacity:1" id="stop3995"/>
 | 
			
		||||
      <stop offset="1" style="stop-color:#427da1;stop-opacity:1" id="stop4001"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient inkscape:collect="always" xlink:href="#linearGradient2508" id="linearGradient2992" gradientUnits="userSpaceOnUse" gradientTransform="translate(0,0.9674382)" x1="14.048676" y1="44.137306" x2="14.048676" y2="4.0000005"/>
 | 
			
		||||
    <linearGradient id="linearGradient2508">
 | 
			
		||||
      <stop offset="0" style="stop-color:#2e4a5a;stop-opacity:1" id="stop2510"/>
 | 
			
		||||
      <stop offset="1" style="stop-color:#6e8796;stop-opacity:1" id="stop2512"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <radialGradient cx="4.9929786" cy="43.5" r="2.5" fx="4.9929786" fy="43.5" id="radialGradient2873-966-168" xlink:href="#linearGradient3688-166-749" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2.003784,0,0,1.4,27.98813,-17.4)"/>
 | 
			
		||||
    <linearGradient id="linearGradient3688-166-749">
 | 
			
		||||
      <stop id="stop2883" style="stop-color:#181818;stop-opacity:1" offset="0"/>
 | 
			
		||||
      <stop id="stop2885" style="stop-color:#181818;stop-opacity:0" offset="1"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <radialGradient cx="4.9929786" cy="43.5" r="2.5" fx="4.9929786" fy="43.5" id="radialGradient2875-742-326" xlink:href="#linearGradient3688-464-309" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2.003784,0,0,1.4,-20.01187,-104.4)"/>
 | 
			
		||||
    <linearGradient id="linearGradient3688-464-309">
 | 
			
		||||
      <stop id="stop2889" style="stop-color:#181818;stop-opacity:1" offset="0"/>
 | 
			
		||||
      <stop id="stop2891" style="stop-color:#181818;stop-opacity:0" offset="1"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <linearGradient x1="25.058096" y1="47.027729" x2="25.058096" y2="39.999443" id="linearGradient2877-634-617" xlink:href="#linearGradient3702-501-757" gradientUnits="userSpaceOnUse"/>
 | 
			
		||||
    <linearGradient id="linearGradient3702-501-757">
 | 
			
		||||
      <stop id="stop2895" style="stop-color:#181818;stop-opacity:0" offset="0"/>
 | 
			
		||||
      <stop id="stop2897" style="stop-color:#181818;stop-opacity:1" offset="0.5"/>
 | 
			
		||||
      <stop id="stop2899" style="stop-color:#181818;stop-opacity:0" offset="1"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
  </defs>
 | 
			
		||||
  <sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="7" inkscape:cx="24" inkscape:cy="24" inkscape:current-layer="layer1" showgrid="true" inkscape:grid-bbox="true" inkscape:document-units="px" inkscape:window-width="603" inkscape:window-height="484" inkscape:window-x="417" inkscape:window-y="162" inkscape:window-maximized="0"/>
 | 
			
		||||
  <metadata id="metadata3837">
 | 
			
		||||
    <rdf:RDF>
 | 
			
		||||
      <cc:Work rdf:about="">
 | 
			
		||||
        <dc:format>image/svg+xml</dc:format>
 | 
			
		||||
        <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
 | 
			
		||||
        <dc:title/>
 | 
			
		||||
      </cc:Work>
 | 
			
		||||
    </rdf:RDF>
 | 
			
		||||
  </metadata>
 | 
			
		||||
  <g id="layer1" inkscape:label="Layer 1" inkscape:groupmode="layer">
 | 
			
		||||
    <g style="display:inline" id="g2036" transform="matrix(1.1,0,0,0.4444449,-2.4000022,25.11107)">
 | 
			
		||||
      <g style="opacity:0.4" id="g3712" transform="matrix(1.052632,0,0,1.285713,-1.263158,-13.42854)">
 | 
			
		||||
        <rect style="fill:url(#radialGradient2873-966-168);fill-opacity:1;stroke:none" id="rect2801" y="40" x="38" height="7" width="5"/>
 | 
			
		||||
        <rect style="fill:url(#radialGradient2875-742-326);fill-opacity:1;stroke:none" id="rect3696" transform="scale(-1,-1)" y="-47" x="-10" height="7" width="5"/>
 | 
			
		||||
        <rect style="fill:url(#linearGradient2877-634-617);fill-opacity:1;stroke:none" id="rect3700" y="40" x="10" height="7.0000005" width="28"/>
 | 
			
		||||
      </g>
 | 
			
		||||
    </g>
 | 
			
		||||
    <rect style="fill:url(#radialGradient2990);fill-opacity:1;stroke:url(#linearGradient2992);stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0" id="rect5505" y="5.4674392" x="4.5" ry="2.2322156" rx="2.2322156" height="39" width="39"/>
 | 
			
		||||
    <path style="opacity:0.05;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.00178742;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" id="path4294-1" d="m 21,6.9687498 a 2.0165107,2.0165107 0 0 0 -2.03125,2.03125 l 0,3.9687502 -1.15625,0 a 2.0165107,2.0165107 0 0 0 -1.5,3.375 l 5.0625,5.75 c -0.06312,0.110777 -0.178724,0.246032 -0.21875,0.34375 -0.195898,0.478256 -0.25,0.83653 -0.25,1.21875 l 0,0.125 L 20.8125,23.6875 C 20.534322,23.409323 20.213169,23.162739 19.71875,22.96875 19.47154,22.87176 19.185456,22.791748 18.75,22.8125 c -0.435456,0.02075 -1.054055,0.210302 -1.46875,0.625 L 15.75,24.96875 c -0.414689,0.414689 -0.604245,1.033294 -0.625,1.46875 -0.02075,0.435456 0.05925,0.721537 0.15625,0.96875 C 15.475241,27.900677 15.721817,28.221821 16,28.5 l 0.09375,0.09375 -0.125,0 c -0.382218,0 -0.740493,0.0541 -1.21875,0.25 -0.239128,0.09795 -0.538285,0.214988 -0.84375,0.53125 -0.305465,0.316262 -0.625,0.914788 -0.625,1.53125 l 0,2.1875 c 0,0.616465 0.319536,1.214989 0.625,1.53125 0.305464,0.316261 0.604622,0.433301 0.84375,0.53125 0.478256,0.195898 0.83653,0.25 1.21875,0.25 l 0.125,0 L 16,35.5 c -0.278175,0.278176 -0.52476,0.599329 -0.71875,1.09375 -0.09699,0.24721 -0.177003,0.533292 -0.15625,0.96875 0.02075,0.435458 0.210304,1.054058 0.625,1.46875 l 1.53125,1.53125 c 0.414691,0.414697 1.033292,0.604245 1.46875,0.625 0.435458,0.02076 0.721537,-0.05926 0.96875,-0.15625 0.494425,-0.19399 0.81557,-0.440568 1.09375,-0.71875 l 0.09375,-0.09375 0,0.125 c 0,0.38222 0.0541,0.740495 0.25,1.21875 0.09795,0.239127 0.214989,0.538285 0.53125,0.84375 0.316261,0.305465 0.914783,0.625 1.53125,0.625 l 2.1875,0 c 0.616466,0 1.214989,-0.319534 1.53125,-0.625 0.316261,-0.305466 0.433302,-0.604622 0.53125,-0.84375 0.195896,-0.478255 0.25,-0.836532 0.25,-1.21875 l 0,-0.125 0.09375,0.09375 c 0.278176,0.278175 0.599329,0.52476 1.09375,0.71875 0.24721,0.09699 0.533292,0.177003 0.96875,0.15625 0.435458,-0.02075 1.054058,-0.210304 1.46875,-0.625 L 32.875,39.03125 C 33.289697,38.616559 33.479245,37.997958 33.5,37.5625 33.52076,37.127042 33.44074,36.840963 33.34375,36.59375 33.14976,36.099325 32.903182,35.77818 32.625,35.5 l -0.09375,-0.09375 0.125,0 c 0.38222,0 0.740494,-0.0541 1.21875,-0.25 0.239128,-0.09795 0.538286,-0.214988 0.84375,-0.53125 0.305464,-0.316262 0.625,-0.914787 0.625,-1.53125 l 0,-2.1875 c 0,-0.61646 -0.319535,-1.214987 -0.625,-1.53125 -0.305465,-0.316263 -0.604621,-0.433301 -0.84375,-0.53125 -0.478257,-0.195898 -0.836532,-0.25 -1.21875,-0.25 l -0.125,0 L 32.625,28.5 c 0.278177,-0.278177 0.52476,-0.599329 0.71875,-1.09375 C 33.44074,27.15904 33.520753,26.872957 33.5,26.4375 33.47925,26.002043 33.289697,25.383443 32.875,24.96875 L 31.34375,23.4375 c -0.414688,-0.414694 -1.03329,-0.604245 -1.46875,-0.625 -0.43546,-0.02076 -0.721537,0.05925 -0.96875,0.15625 -0.494426,0.193991 -0.815572,0.44057 -1.09375,0.71875 l -0.09375,0.09375 0,-0.125 c 0,-0.382218 -0.0541,-0.740493 -0.25,-1.21875 -0.09112,-0.22245 -0.228127,-0.500183 -0.5,-0.78125 l 4.71875,-5.3125 a 2.0165107,2.0165107 0 0 0 -1.5,-3.375 l -1.15625,0 0,-3.9687502 A 2.0165107,2.0165107 0 0 0 27,6.9687498 l -6,0 z M 24.3125,31.25 c 0.427097,0 0.75,0.322904 0.75,0.75 0,0.427096 -0.322903,0.75 -0.75,0.75 -0.427094,0 -0.75,-0.322906 -0.75,-0.75 0,-0.427094 0.322906,-0.75 0.75,-0.75 z"/>
 | 
			
		||||
    <path style="opacity:0.05;fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.00178742;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" id="path4294" d="m 20.90625,8.0312498 a 0.96385067,0.96385067 0 0 0 -0.875,0.96875 l 0,5.0312502 -2.21875,0 A 0.96385067,0.96385067 0 0 0 17.09375,15.625 l 5.78125,6.53125 c -0.158814,0.0616 -0.341836,0.0951 -0.4375,0.1875 -0.169161,0.163386 -0.252971,0.323419 -0.3125,0.46875 -0.119058,0.290663 -0.15625,0.566746 -0.15625,0.84375 l 0,1.65625 C 21.718163,25.40233 21.485871,25.509772 21.25,25.625 l -1.1875,-1.1875 c -0.199651,-0.19965 -0.421433,-0.352095 -0.71875,-0.46875 -0.148659,-0.05833 -0.329673,-0.104846 -0.5625,-0.09375 -0.232827,0.0111 -0.53583,0.09833 -0.75,0.3125 L 16.5,25.71875 c -0.214168,0.214168 -0.301403,0.517173 -0.3125,0.75 -0.0111,0.232827 0.03542,0.41384 0.09375,0.5625 0.116655,0.297321 0.269096,0.519099 0.46875,0.71875 l 1.1875,1.1875 c -0.115228,0.235871 -0.222668,0.468163 -0.3125,0.71875 l -1.65625,0 c -0.277003,0 -0.553087,0.03719 -0.84375,0.15625 -0.145332,0.05953 -0.305363,0.143338 -0.46875,0.3125 -0.163387,0.169162 -0.3125,0.46403 -0.3125,0.78125 l 0,2.1875 c 0,0.317221 0.149114,0.612089 0.3125,0.78125 0.163386,0.169161 0.323419,0.252971 0.46875,0.3125 0.290663,0.119058 0.566746,0.15625 0.84375,0.15625 l 1.65625,0 c 0.08983,0.250587 0.197272,0.482879 0.3125,0.71875 L 16.75,36.25 c -0.199649,0.19965 -0.352095,0.421432 -0.46875,0.71875 -0.05833,0.148659 -0.104846,0.329672 -0.09375,0.5625 0.0111,0.232828 0.09833,0.535831 0.3125,0.75 l 1.53125,1.53125 c 0.214168,0.214172 0.517172,0.301403 0.75,0.3125 0.232828,0.0111 0.41384,-0.03542 0.5625,-0.09375 0.29732,-0.116655 0.519098,-0.269096 0.71875,-0.46875 L 21.25,38.375 c 0.235871,0.115228 0.468164,0.222668 0.71875,0.3125 l 0,1.65625 c 0,0.277003 0.03719,0.553087 0.15625,0.84375 0.05953,0.145331 0.143339,0.305364 0.3125,0.46875 0.169161,0.163386 0.464028,0.3125 0.78125,0.3125 l 2.1875,0 c 0.317221,0 0.612089,-0.149113 0.78125,-0.3125 0.169161,-0.163387 0.252971,-0.323419 0.3125,-0.46875 0.119057,-0.290663 0.15625,-0.566748 0.15625,-0.84375 l 0,-1.65625 c 0.250586,-0.08983 0.482879,-0.197272 0.71875,-0.3125 l 1.1875,1.1875 c 0.19965,0.199649 0.421432,0.352095 0.71875,0.46875 0.148659,0.05833 0.329672,0.104846 0.5625,0.09375 0.232828,-0.0111 0.535831,-0.09833 0.75,-0.3125 L 32.125,38.28125 c 0.214172,-0.214168 0.301403,-0.517172 0.3125,-0.75 0.0111,-0.232828 -0.03542,-0.41384 -0.09375,-0.5625 C 32.227095,36.67143 32.074654,36.449652 31.875,36.25 L 30.6875,35.0625 C 30.802728,34.82663 30.910168,34.594337 31,34.34375 l 1.65625,0 c 0.277004,0 0.553087,-0.03719 0.84375,-0.15625 0.145332,-0.05953 0.305364,-0.143339 0.46875,-0.3125 0.163386,-0.169161 0.3125,-0.46403 0.3125,-0.78125 l 0,-2.1875 c 0,-0.317219 -0.149114,-0.612088 -0.3125,-0.78125 C 33.805364,29.955838 33.645332,29.872029 33.5,29.8125 33.209336,29.693442 32.933253,29.65625 32.65625,29.65625 l -1.65625,0 C 30.91017,29.405663 30.802728,29.17337 30.6875,28.9375 L 31.875,27.75 c 0.19965,-0.19965 0.352095,-0.421432 0.46875,-0.71875 0.05833,-0.148659 0.104846,-0.329672 0.09375,-0.5625 -0.0111,-0.232828 -0.09833,-0.535831 -0.3125,-0.75 L 30.59375,24.1875 c -0.214167,-0.21417 -0.517171,-0.301403 -0.75,-0.3125 -0.232829,-0.0111 -0.41384,0.03542 -0.5625,0.09375 -0.29732,0.116656 -0.519099,0.269097 -0.71875,0.46875 L 27.375,25.625 c -0.235871,-0.115228 -0.468163,-0.222668 -0.71875,-0.3125 l 0,-1.65625 c 0,-0.277003 -0.03719,-0.553087 -0.15625,-0.84375 -0.05953,-0.145332 -0.143338,-0.305363 -0.3125,-0.46875 -0.169162,-0.163387 -0.46403,-0.3125 -0.78125,-0.3125 l -0.15625,0 5.65625,-6.40625 A 0.96385067,0.96385067 0 0 0 30.1875,14.03125 l -2.21875,0 0,-5.0312502 A 0.96385067,0.96385067 0 0 0 27,8.0312498 l -6,0 a 0.96385067,0.96385067 0 0 0 -0.09375,0 z M 24.3125,30.1875 c 1.002113,0 1.8125,0.810388 1.8125,1.8125 0,1.002112 -0.810387,1.8125 -1.8125,1.8125 C 23.31039,33.8125 22.5,33.002111 22.5,32 c 0,-1.002111 0.81039,-1.8125 1.8125,-1.8125 z"/>
 | 
			
		||||
    <path style="fill:url(#radialGradient2985);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.00178742;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" id="path2317" d="M 21,8.9999996 21,15 17.8125,15 24,22 30.1875,15 27,15 l 0,-6.0000004 -6,0 z M 23.21875,23 c -0.172892,0 -0.28125,0.294922 -0.28125,0.65625 l 0,2.28125 C 22.24145,26.095996 21.585954,26.379869 21,26.75 l -1.625,-1.625 c -0.255498,-0.255497 -0.533998,-0.372253 -0.65625,-0.25 l -1.53125,1.53125 c -0.122254,0.122254 -0.0055,0.400753 0.25,0.65625 l 1.625,1.625 c -0.37013,0.585953 -0.654003,1.24145 -0.8125,1.9375 l -2.28125,0 c -0.361328,0 -0.65625,0.108357 -0.65625,0.28125 l 0,2.1875 c 0,0.172892 0.294922,0.28125 0.65625,0.28125 l 2.28125,0 c 0.158497,0.69605 0.44237,1.351546 0.8125,1.9375 l -1.625,1.625 c -0.255497,0.255498 -0.372254,0.533997 -0.25,0.65625 l 1.53125,1.53125 c 0.122252,0.122254 0.400752,0.0055 0.65625,-0.25 L 21,37.25 c 0.585954,0.37013 1.24145,0.654002 1.9375,0.8125 l 0,2.28125 C 22.9375,40.705077 23.045858,41 23.21875,41 l 2.1875,0 c 0.172893,0 0.28125,-0.294924 0.28125,-0.65625 l 0,-2.28125 c 0.69605,-0.158498 1.351546,-0.44237 1.9375,-0.8125 l 1.625,1.625 c 0.255498,0.255497 0.533997,0.372254 0.65625,0.25 l 1.53125,-1.53125 c 0.122254,-0.122252 0.0055,-0.400752 -0.25,-0.65625 l -1.625,-1.625 c 0.370129,-0.585954 0.654003,-1.24145 0.8125,-1.9375 l 2.28125,0 c 0.361329,0 0.65625,-0.108358 0.65625,-0.28125 l 0,-2.1875 c 0,-0.172893 -0.294921,-0.28125 -0.65625,-0.28125 l -2.28125,0 c -0.158497,-0.69605 -0.442371,-1.351547 -0.8125,-1.9375 l 1.625,-1.625 c 0.255497,-0.255497 0.372254,-0.533997 0.25,-0.65625 L 29.90625,24.875 C 29.783997,24.752745 29.505498,24.8695 29.25,25.125 l -1.625,1.625 c -0.585954,-0.370131 -1.24145,-0.654004 -1.9375,-0.8125 l 0,-2.28125 C 25.6875,23.294922 25.579143,23 25.40625,23 l -2.1875,0 z m 1.09375,6.21875 c 1.528616,0 2.78125,1.252635 2.78125,2.78125 0,1.528615 -1.252634,2.78125 -2.78125,2.78125 -1.528614,0 -2.78125,-1.252635 -2.78125,-2.78125 0,-1.528615 1.252636,-2.78125 2.78125,-2.78125 z"/>
 | 
			
		||||
    <rect style="opacity:0.4;fill:none;stroke:url(#linearGradient2982);stroke-width:0.99999976;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0" id="rect6741" y="6.4999886" x="5.4999981" ry="1.365193" rx="1.365193" height="37.000011" width="36.999985"/>
 | 
			
		||||
    <path style="fill:none;stroke:url(#linearGradient2979);stroke-width:0.99829447;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:0;marker:none;visibility:visible;display:inline;overflow:visible" id="path2777" d="M 28.926376,15.466668 24,21.177578 18.963089,15.5 21.5,15.5 l 0,-6.0000004 5,0 0,6.0000004 2.426376,-0.03333 z"/>
 | 
			
		||||
    <path style="fill:none;stroke:url(#linearGradient2975);stroke-width:1;stroke-opacity:1;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" id="path4243" d="m 23.4375,23.46875 c -0.01166,0.05381 -0.03125,0.100205 -0.03125,0.1875 l 0,2.28125 a 0.48185467,0.48185467 0 0 1 -0.375,0.46875 c -0.638467,0.145384 -1.238423,0.407111 -1.78125,0.75 a 0.48185467,0.48185467 0 0 1 -0.59375,-0.0625 l -1.625,-1.625 C 18.9779,25.4154 18.9477,25.40242 18.90625,25.375 l -1.21875,1.21875 c 0.02742,0.04145 0.0404,0.07165 0.09375,0.125 l 1.625,1.625 a 0.48185467,0.48185467 0 0 1 0.0625,0.59375 c -0.342888,0.542826 -0.604615,1.142782 -0.75,1.78125 a 0.48185467,0.48185467 0 0 1 -0.46875,0.375 l -2.28125,0 c -0.08729,0 -0.133695,0.01959 -0.1875,0.03125 l 0,1.75 c 0.05381,0.01166 0.100205,0.03125 0.1875,0.03125 l 2.28125,0 a 0.48185467,0.48185467 0 0 1 0.46875,0.375 c 0.145385,0.638468 0.407112,1.238423 0.75,1.78125 a 0.48185467,0.48185467 0 0 1 -0.0625,0.59375 l -1.625,1.625 c -0.05335,0.05335 -0.06633,0.08355 -0.09375,0.125 l 1.21875,1.21875 c 0.04145,-0.02742 0.07165,-0.0404 0.125,-0.09375 l 1.625,-1.625 A 0.48185467,0.48185467 0 0 1 21.25,36.84375 c 0.542827,0.342888 1.142781,0.604614 1.78125,0.75 a 0.48185467,0.48185467 0 0 1 0.375,0.46875 l 0,2.28125 c 0,0.08729 0.01959,0.133695 0.03125,0.1875 l 1.75,0 c 0.01166,-0.0538 0.03125,-0.100206 0.03125,-0.1875 l 0,-2.28125 a 0.48185467,0.48185467 0 0 1 0.375,-0.46875 c 0.638469,-0.145386 1.238423,-0.407112 1.78125,-0.75 a 0.48185467,0.48185467 0 0 1 0.59375,0.0625 l 1.625,1.625 c 0.05335,0.05335 0.08355,0.06633 0.125,0.09375 l 1.21875,-1.21875 c -0.02742,-0.04145 -0.0404,-0.07165 -0.09375,-0.125 l -1.625,-1.625 a 0.48185467,0.48185467 0 0 1 -0.0625,-0.59375 c 0.342888,-0.542828 0.604615,-1.142783 0.75,-1.78125 a 0.48185467,0.48185467 0 0 1 0.46875,-0.375 l 2.28125,0 c 0.08729,0 0.133695,-0.01959 0.1875,-0.03125 l 0,-1.75 c -0.0538,-0.01166 -0.100204,-0.03125 -0.1875,-0.03125 l -2.28125,0 a 0.48185467,0.48185467 0 0 1 -0.46875,-0.375 c -0.145385,-0.638467 -0.407113,-1.238424 -0.75,-1.78125 a 0.48185467,0.48185467 0 0 1 0.0625,-0.59375 l 1.625,-1.625 c 0.05335,-0.05335 0.06633,-0.08355 0.09375,-0.125 L 29.71875,25.375 c -0.04145,0.02742 -0.07165,0.0404 -0.125,0.09375 l -1.625,1.625 a 0.48185467,0.48185467 0 0 1 -0.59375,0.0625 c -0.542827,-0.342889 -1.142783,-0.604616 -1.78125,-0.75 a 0.48185467,0.48185467 0 0 1 -0.375,-0.46875 l 0,-2.28125 c 0,-0.0873 -0.01959,-0.133695 -0.03125,-0.1875 l -1.75,0 z m 0.875,5.28125 c 1.791829,0 3.25,1.458172 3.25,3.25 0,1.791828 -1.458171,3.25 -3.25,3.25 -1.791827,0 -3.25,-1.458172 -3.25,-3.25 0,-1.791828 1.458173,-3.25 3.25,-3.25 z"/>
 | 
			
		||||
  </g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 19 KiB  | 
@@ -1,75 +0,0 @@
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
			
		||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
 | 
			
		||||
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="48" height="48" viewBox="0 0 48.000001 48.000001" id="svg4230" version="1.1" inkscape:version="0.91 r13725" sodipodi:docname="fdroid-logo.svg">
 | 
			
		||||
  <defs id="defs4232">
 | 
			
		||||
    <linearGradient inkscape:collect="always" id="linearGradient5212">
 | 
			
		||||
      <stop style="stop-color:#ffffff;stop-opacity:0.09803922" offset="0" id="stop5214"/>
 | 
			
		||||
      <stop style="stop-color:#ffffff;stop-opacity:0" offset="1" id="stop5216"/>
 | 
			
		||||
    </linearGradient>
 | 
			
		||||
    <radialGradient inkscape:collect="always" xlink:href="#linearGradient5212" id="radialGradient5220" cx="-98.23381" cy="3.4695871" fx="-98.23381" fy="3.4695871" r="22.671185" gradientTransform="matrix(0,1.9747624,-2.117225,3.9784049e-8,8.677247,1199.588)" gradientUnits="userSpaceOnUse"/>
 | 
			
		||||
    <filter inkscape:collect="always" style="color-interpolation-filters:sRGB" id="filter4175" x="-0.023846937" width="1.0476939" y="-0.02415504" height="1.0483101">
 | 
			
		||||
      <feGaussianBlur inkscape:collect="always" stdDeviation="0.45053152" id="feGaussianBlur4177"/>
 | 
			
		||||
    </filter>
 | 
			
		||||
  </defs>
 | 
			
		||||
  <sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="11.313708" inkscape:cx="6.4184057" inkscape:cy="25.737489" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="true" units="px" inkscape:window-width="1920" inkscape:window-height="1009" inkscape:window-x="0" inkscape:window-y="34" inkscape:window-maximized="1" gridtolerance="10000"/>
 | 
			
		||||
  <metadata id="metadata4235">
 | 
			
		||||
    <rdf:RDF>
 | 
			
		||||
      <cc:Work rdf:about="">
 | 
			
		||||
        <dc:format>image/svg+xml</dc:format>
 | 
			
		||||
        <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
 | 
			
		||||
        <dc:title/>
 | 
			
		||||
        <cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/3.0/"/>
 | 
			
		||||
      </cc:Work>
 | 
			
		||||
      <cc:License rdf:about="http://creativecommons.org/licenses/by-sa/3.0/">
 | 
			
		||||
        <cc:permits rdf:resource="http://creativecommons.org/ns#Reproduction"/>
 | 
			
		||||
        <cc:permits rdf:resource="http://creativecommons.org/ns#Distribution"/>
 | 
			
		||||
        <cc:requires rdf:resource="http://creativecommons.org/ns#Notice"/>
 | 
			
		||||
        <cc:requires rdf:resource="http://creativecommons.org/ns#Attribution"/>
 | 
			
		||||
        <cc:permits rdf:resource="http://creativecommons.org/ns#DerivativeWorks"/>
 | 
			
		||||
        <cc:requires rdf:resource="http://creativecommons.org/ns#ShareAlike"/>
 | 
			
		||||
      </cc:License>
 | 
			
		||||
    </rdf:RDF>
 | 
			
		||||
  </metadata>
 | 
			
		||||
  <g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(0,-1004.3622)">
 | 
			
		||||
    <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.4;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;filter:url(#filter4175);color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 2.613462,1006.3488 a 1.250125,1.250125 0 0 0 -1.01172,2.0293 l 3.60351,4.6641 c -0.12699,0.3331 -0.20312,0.6915 -0.20312,1.0703 l 0,4 0,2.8652 0,0.1348 c 0,1.662 1.338,3 3,3 l 32,0 c 1.662,0 3,-1.338 3,-3 l 0,-4 0,-2.8652 0,-0.1348 c 0,-0.3803 -0.0771,-0.74 -0.20508,-1.0742 l 3.60156,-4.6602 a 1.250125,1.250125 0 0 0 -1.04882,-2.0273 1.250125,1.250125 0 0 0 -0.92969,0.498 l -3.43164,4.4414 c -0.31022,-0.1079 -0.63841,-0.1777 -0.98633,-0.1777 l -32,0 c -0.34857,0 -0.67757,0.069 -0.98828,0.1777 l -3.4336,-4.4414 a 1.250125,1.250125 0 0 0 -0.96679,-0.5 z m 5.38867,18.7637 c -0.20775,0 -0.40983,0.021 -0.60547,0.061 -1.36951,0.2761 -2.39453,1.4698 -2.39453,2.9101 l 0,0.029 0,19.7793 0,0.029 0,0.1914 c 0,1.662 1.338,3 3,3 l 32,0 c 1.662,0 3,-1.338 3,-3 l 0,-20 0,-0.029 c 0,-1.4403 -1.02502,-2.634 -2.39453,-2.9101 -0.19565,-0.039 -0.39772,-0.061 -0.60547,-0.061 l -32,0 z" id="path4192" inkscape:connector-curvature="0"/>
 | 
			
		||||
    <g id="g5012">
 | 
			
		||||
      <g id="g4179" transform="matrix(-1,0,0,1,47.999779,0)">
 | 
			
		||||
        <path style="fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:#769616;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" d="m 2.5889342,1006.8622 4.25,5.5" id="path4181" inkscape:connector-curvature="0" sodipodi:nodetypes="cc"/>
 | 
			
		||||
        <path sodipodi:nodetypes="cccccc" inkscape:connector-curvature="0" id="path4183" d="m 2.6113281,1005.6094 c -0.4534623,0.012 -0.7616975,0.189 -0.9807462,0.4486 2.0269314,2.4089 2.368401,2.7916 5.1354735,6.2214 1.0195329,1.319 2.0816026,0.6373 1.0620696,-0.6817 l -4.25,-5.5 c -0.2289894,-0.3056 -0.5850813,-0.478 -0.9667969,-0.4883 z" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:0.29803923;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"/>
 | 
			
		||||
        <path sodipodi:nodetypes="ccccc" inkscape:connector-curvature="0" id="path4185" d="m 1.6220992,1006.0705 c -0.1238933,0.1479 -0.561176,0.8046 -0.02249,1.5562 l 4.25,5.5 c 1.0195329,1.319 1.1498748,-0.6123 1.1498748,-0.6123 0,0 -3.7344514,-4.51 -5.3773848,-6.4439 z" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.2;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"/>
 | 
			
		||||
        <path sodipodi:nodetypes="cscccc" inkscape:connector-curvature="0" id="path4187" d="m 2.3378905,1005.8443 c -0.438175,0 -0.959862,0.1416 -0.8242183,0.7986 0.103561,0.5016 4.6608262,6.0744 4.6608262,6.0744 1.0195329,1.319 2.4934721,0.6763 1.4739391,-0.6425 l -4.234375,-5.4727 c -0.2602394,-0.29 -0.6085188,-0.7436 -1.076172,-0.7578 z" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"/>
 | 
			
		||||
      </g>
 | 
			
		||||
      <g id="g4955">
 | 
			
		||||
        <path sodipodi:nodetypes="cc" inkscape:connector-curvature="0" id="path4945" d="m 2.5889342,1006.8622 4.25,5.5" style="fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:#769616;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"/>
 | 
			
		||||
        <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:0.29803923;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 2.6113281,1005.6094 c -0.4534623,0.012 -0.7616975,0.189 -0.9807462,0.4486 2.0269314,2.4089 2.368401,2.7916 5.1354735,6.2214 1.0195329,1.319 2.0816026,0.6373 1.0620696,-0.6817 l -4.25,-5.5 c -0.2289894,-0.3056 -0.5850813,-0.478 -0.9667969,-0.4883 z" id="path4947" inkscape:connector-curvature="0" sodipodi:nodetypes="cccccc"/>
 | 
			
		||||
        <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#263238;fill-opacity:0.2;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 1.6220992,1006.0705 c -0.1238933,0.1479 -0.561176,0.8046 -0.02249,1.5562 l 4.25,5.5 c 1.0195329,1.319 1.1498748,-0.6123 1.1498748,-0.6123 0,0 -3.7344514,-4.51 -5.3773848,-6.4439 z" id="path4951" inkscape:connector-curvature="0" sodipodi:nodetypes="ccccc"/>
 | 
			
		||||
        <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#8ab000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 2.3378905,1005.8443 c -0.438175,0 -0.959862,0.1416 -0.8242183,0.7986 0.103561,0.5016 4.6608262,6.0744 4.6608262,6.0744 1.0195329,1.319 2.4934721,0.6763 1.4739391,-0.6425 l -4.234375,-5.4727 c -0.2602394,-0.29 -0.6085188,-0.7436 -1.076172,-0.7578 z" id="path4925" inkscape:connector-curvature="0" sodipodi:nodetypes="cscccc"/>
 | 
			
		||||
      </g>
 | 
			
		||||
      <g transform="translate(42,0)" id="g4967">
 | 
			
		||||
        <rect style="opacity:1;fill:#aeea00;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" id="rect4144" width="38" height="13" x="-37" y="1010.3622" rx="3" ry="3"/>
 | 
			
		||||
        <rect ry="3" rx="3" y="1013.3622" x="-37" height="10" width="38" id="rect4961" style="opacity:1;fill:#263238;fill-opacity:0.2;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
 | 
			
		||||
        <rect ry="3" rx="3" y="1010.3622" x="-37" height="10" width="38" id="rect4963" style="opacity:1;fill:#ffffff;fill-opacity:0.29803923;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
 | 
			
		||||
        <rect ry="2.5384617" rx="3" y="1011.3622" x="-37" height="11" width="38" id="rect4965" style="opacity:1;fill:#aeea00;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
 | 
			
		||||
      </g>
 | 
			
		||||
      <g id="g4979">
 | 
			
		||||
        <rect style="opacity:1;fill:#1976d2;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" id="rect4146" width="38" height="26" x="5" y="1024.3622" rx="3" ry="3"/>
 | 
			
		||||
        <rect ry="3" rx="3" y="1037.3622" x="5" height="13" width="38" id="rect4973" style="opacity:1;fill:#263238;fill-opacity:0.2;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
 | 
			
		||||
        <rect ry="3" rx="3" y="1024.3622" x="5" height="13" width="38" id="rect4975" style="opacity:1;fill:#ffffff;fill-opacity:0.2;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
 | 
			
		||||
        <rect ry="2.7692308" rx="3" y="1025.3622" x="5" height="24" width="38" id="rect4977" style="opacity:1;fill:#1976d2;fill-opacity:1;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:3;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"/>
 | 
			
		||||
      </g>
 | 
			
		||||
      <g transform="translate(0,1013.3622)" id="g4211">
 | 
			
		||||
        <path style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#0d47a1;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" d="m 24,17.75 c -2.880662,0 -5.319789,1.984685 -6.033203,4.650391 l 3.212891,0 C 21.734004,21.415044 22.774798,20.75 24,20.75 c 1.812692,0 3.25,1.437308 3.25,3.25 0,1.812693 -1.437308,3.25 -3.25,3.25 -1.307381,0 -2.411251,-0.75269 -2.929688,-1.849609 l -3.154296,0 C 18.558263,28.166146 21.04791,30.25 24,30.25 c 3.434013,0 6.25,-2.815987 6.25,-6.25 0,-3.434012 -2.815987,-6.25 -6.25,-6.25 z" id="path4161" inkscape:connector-curvature="0"/>
 | 
			
		||||
        <circle style="opacity:1;fill:none;fill-opacity:0.40392157;stroke:#0d47a1;stroke-width:1.89999998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" id="path4209" cx="24" cy="24" r="9.5500002"/>
 | 
			
		||||
      </g>
 | 
			
		||||
      <g id="g4989" transform="translate(0,0.50001738)">
 | 
			
		||||
        <ellipse cy="1016.4872" cx="14.375" id="circle4985" style="opacity:1;fill:#263238;fill-opacity:0.2;stroke:none;stroke-width:1.89999998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.69721117" rx="3.375" ry="3.875"/>
 | 
			
		||||
        <circle style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1.89999998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.69721117" id="path4859" cx="14.375" cy="1016.9872" r="3.375"/>
 | 
			
		||||
      </g>
 | 
			
		||||
      <g transform="translate(19.5,0.50001738)" id="g4171">
 | 
			
		||||
        <ellipse ry="3.875" rx="3.375" style="opacity:1;fill:#263238;fill-opacity:0.2;stroke:none;stroke-width:1.89999998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.69721117" id="ellipse4175" cx="14.375" cy="1016.4872"/>
 | 
			
		||||
        <circle r="3.375" cy="1016.9872" cx="14.375" id="circle4177" style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1.89999998;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.69721117"/>
 | 
			
		||||
      </g>
 | 
			
		||||
    </g>
 | 
			
		||||
    <path inkscape:connector-curvature="0" id="path5128" d="m 2.613462,1005.5987 a 1.250125,1.250125 0 0 0 -1.01172,2.0293 l 3.60351,4.6641 c -0.12699,0.3331 -0.20312,0.6915 -0.20312,1.0703 l 0,4 0,2.8652 0,0.1348 c 0,1.662 1.338,3 3,3 l 32,0 c 1.662,0 3,-1.338 3,-3 l 0,-4 0,-2.8652 0,-0.1348 c 0,-0.3803 -0.0771,-0.74 -0.20508,-1.0742 l 3.60156,-4.6602 a 1.250125,1.250125 0 0 0 -1.04882,-2.0273 1.250125,1.250125 0 0 0 -0.92969,0.498 l -3.43164,4.4414 c -0.31022,-0.1079 -0.63841,-0.1777 -0.98633,-0.1777 l -32,0 c -0.34857,0 -0.67757,0.069 -0.98828,0.1777 l -3.4336,-4.4414 a 1.250125,1.250125 0 0 0 -0.96679,-0.5 z m 5.38867,18.7637 c -0.20775,0 -0.40983,0.021 -0.60547,0.061 -1.36951,0.2761 -2.39453,1.4698 -2.39453,2.9101 l 0,0.029 0,19.7793 0,0.029 0,0.1914 c 0,1.662 1.338,3 3,3 l 32,0 c 1.662,0 3,-1.338 3,-3 l 0,-20 0,-0.029 c 0,-1.4403 -1.02502,-2.634 -2.39453,-2.9101 -0.19565,-0.039 -0.39772,-0.061 -0.60547,-0.061 l -32,0 z" style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:url(#radialGradient5220);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"/>
 | 
			
		||||
  </g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 21 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" style="fill:#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" style="fill:#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" style="fill:#609926"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 2.1 KiB  | 
@@ -1,23 +0,0 @@
 | 
			
		||||
<?xml version="1.0" encoding="iso-8859-1"?>
 | 
			
		||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
 | 
			
		||||
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" 
 | 
			
		||||
	 viewBox="0 0 511.999 511.999" xml:space="preserve">
 | 
			
		||||
<g>
 | 
			
		||||
	<path style="fill:#32BBFF;" d="M382.369,175.623C322.891,142.356,227.427,88.937,79.355,6.028
 | 
			
		||||
		C69.372-0.565,57.886-1.429,47.962,1.93l254.05,254.05L382.369,175.623z"/>
 | 
			
		||||
	<path style="fill:#32BBFF;" d="M47.962,1.93c-1.86,0.63-3.67,1.39-5.401,2.308C31.602,10.166,23.549,21.573,23.549,36v439.96
 | 
			
		||||
		c0,14.427,8.052,25.834,19.012,31.761c1.728,0.917,3.537,1.68,5.395,2.314L302.012,255.98L47.962,1.93z"/>
 | 
			
		||||
	<path style="fill:#32BBFF;" d="M302.012,255.98L47.956,510.035c9.927,3.384,21.413,2.586,31.399-4.103
 | 
			
		||||
		c143.598-80.41,237.986-133.196,298.152-166.746c1.675-0.941,3.316-1.861,4.938-2.772L302.012,255.98z"/>
 | 
			
		||||
</g>
 | 
			
		||||
<path style="fill:#2C9FD9;" d="M23.549,255.98v219.98c0,14.427,8.052,25.834,19.012,31.761c1.728,0.917,3.537,1.68,5.395,2.314
 | 
			
		||||
	L302.012,255.98H23.549z"/>
 | 
			
		||||
<path style="fill:#29CC5E;" d="M79.355,6.028C67.5-1.8,53.52-1.577,42.561,4.239l255.595,255.596l84.212-84.212
 | 
			
		||||
	C322.891,142.356,227.427,88.937,79.355,6.028z"/>
 | 
			
		||||
<path style="fill:#D93F21;" d="M298.158,252.126L42.561,507.721c10.96,5.815,24.939,6.151,36.794-1.789
 | 
			
		||||
	c143.598-80.41,237.986-133.196,298.152-166.746c1.675-0.941,3.316-1.861,4.938-2.772L298.158,252.126z"/>
 | 
			
		||||
<path style="fill:#FFD500;" d="M488.45,255.98c0-12.19-6.151-24.492-18.342-31.314c0,0-22.799-12.721-92.682-51.809l-83.123,83.123
 | 
			
		||||
	l83.204,83.205c69.116-38.807,92.6-51.892,92.6-51.892C482.299,280.472,488.45,268.17,488.45,255.98z"/>
 | 
			
		||||
<path style="fill:#FFAA00;" d="M470.108,287.294c12.191-6.822,18.342-19.124,18.342-31.314H294.303l83.204,83.205
 | 
			
		||||
	C446.624,300.379,470.108,287.294,470.108,287.294z"/>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.8 KiB  | 
| 
		 Before Width: | Height: | Size: 86 KiB  | 
@@ -10,6 +10,17 @@
 | 
			
		||||
		<link rel="stylesheet" href="brands.min.css" />
 | 
			
		||||
 | 
			
		||||
		<style>
 | 
			
		||||
			body,
 | 
			
		||||
			h1,
 | 
			
		||||
			h2,
 | 
			
		||||
			h3,
 | 
			
		||||
			h4,
 | 
			
		||||
			h5 {
 | 
			
		||||
				font-family: 'Poppins', sans-serif;
 | 
			
		||||
			}
 | 
			
		||||
			body {
 | 
			
		||||
				font-size: 16px;
 | 
			
		||||
			}
 | 
			
		||||
			img {
 | 
			
		||||
				margin-bottom: -8px;
 | 
			
		||||
			}
 | 
			
		||||
@@ -28,38 +39,34 @@
 | 
			
		||||
						<b>😎 Tilde Friends</b>
 | 
			
		||||
					</h1>
 | 
			
		||||
					<h1 class="w3-xxlarge w3-text-green">
 | 
			
		||||
						<b>a Secure Scuttlebutt decentralized social network client</b>
 | 
			
		||||
						<b>Make apps and friends from the comfort of your web browser.</b>
 | 
			
		||||
					</h1>
 | 
			
		||||
					<p>
 | 
			
		||||
						In addition to participating in Secure Scuttlebutt, Tilde Friends is
 | 
			
		||||
						a platform for building, running, and sharing applications.
 | 
			
		||||
						Tilde Friends is a platform for building, running, and sharing web
 | 
			
		||||
						applications.
 | 
			
		||||
					</p>
 | 
			
		||||
					<p>
 | 
			
		||||
						Available for lots of devices:
 | 
			
		||||
						<i class="fa-brands fa-linux w3-xlarge"></i>
 | 
			
		||||
						<i class="fa-brands fa-android w3-xlarge"></i>
 | 
			
		||||
						<i class="fa-brands fa-apple w3-xlarge"></i>
 | 
			
		||||
						<i class="fa fa-mobile-screen w3-xlarge"></i>
 | 
			
		||||
						<i class="fa-brands fa-windows w3-xlarge"></i>
 | 
			
		||||
					</p>
 | 
			
		||||
					<a
 | 
			
		||||
						class="w3-button w3-blue w3-padding-large"
 | 
			
		||||
						href="https://www.tildefriends.net/~core/ssb/"
 | 
			
		||||
						>🦀 Try It</a
 | 
			
		||||
					>
 | 
			
		||||
					<a
 | 
			
		||||
						class="w3-button w3-black w3-padding-large"
 | 
			
		||||
						href="https://dev.tildefriends.net/cory/tildefriends/releases/latest"
 | 
			
		||||
						href="https://dev.tildefriends.net/cory/tildefriends/releases"
 | 
			
		||||
						><i class="fa fa-download"></i> Download</a
 | 
			
		||||
					>
 | 
			
		||||
					<a
 | 
			
		||||
						class="w3-button w3-black w3-padding-large"
 | 
			
		||||
						href="https://dev.tildefriends.net/cory/tildefriends"
 | 
			
		||||
					>
 | 
			
		||||
						<img src="gitea.svg" style="height: 1em; margin: 0" />
 | 
			
		||||
						Development
 | 
			
		||||
					</a>
 | 
			
		||||
					<a
 | 
			
		||||
						class="w3-button w3-black w3-padding-large"
 | 
			
		||||
						href="https://docs.tildefriends.net/"
 | 
			
		||||
						><i class="fa fa-book"></i> Documentation</a
 | 
			
		||||
						href="https://www.tildefriends.net/~cory/apps/"
 | 
			
		||||
						><i class="fa fa-link"></i> Try It</a
 | 
			
		||||
					>
 | 
			
		||||
					<a
 | 
			
		||||
						class="w3-button w3-black w3-padding-large"
 | 
			
		||||
						href="https://www.tildefriends.net/~cory/tildeblog/"
 | 
			
		||||
						><i class="fa fa-solid fa-square-rss"></i> Blog</a
 | 
			
		||||
						href="https://dev.tildefriends.net/"
 | 
			
		||||
						><i class="fa fa-mug-hot"></i> Code</a
 | 
			
		||||
					>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="w3-col l4 m6">
 | 
			
		||||
@@ -78,138 +85,39 @@
 | 
			
		||||
					<h2>First-time user checklist:</h2>
 | 
			
		||||
					<ol type="1" style="text-align: left">
 | 
			
		||||
						<li>
 | 
			
		||||
							<a
 | 
			
		||||
								href="https://dev.tildefriends.net/cory/tildefriends/releases/latest"
 | 
			
		||||
							<a href="https://dev.tildefriends.net/cory/tildefriends/releases"
 | 
			
		||||
								>Download</a
 | 
			
		||||
							>
 | 
			
		||||
							Tilde Friends or use
 | 
			
		||||
							Tilde Friends and run your own instance, or use
 | 
			
		||||
							<a href="https://www.tildefriends.net/"
 | 
			
		||||
								>https://www.tildefriends.net/</a
 | 
			
		||||
							>.
 | 
			
		||||
							<div class="w3-cell-row">
 | 
			
		||||
								<div class="w3-container w3-cell w3-mobile">
 | 
			
		||||
									<h3>Mobile</h3>
 | 
			
		||||
									<p>
 | 
			
		||||
										<a
 | 
			
		||||
											class="w3-button w3-round-large w3-padding w3-blue-gray w3-margin-top"
 | 
			
		||||
											href="https://f-droid.org/en/packages/com.unprompted.tildefriends.fdroid/"
 | 
			
		||||
											><img src="f-droid.svg" style="height: 2em; margin: 0" />
 | 
			
		||||
											Get it on F-Droid</a
 | 
			
		||||
										>
 | 
			
		||||
										<a
 | 
			
		||||
											class="w3-button w3-round-large w3-padding w3-blue-gray w3-margin-top"
 | 
			
		||||
											href="https://play.google.com/store/apps/details?id=com.unprompted.tildefriends"
 | 
			
		||||
										>
 | 
			
		||||
											<img
 | 
			
		||||
												src="googleplay.svg"
 | 
			
		||||
												style="height: 2em; margin: 0"
 | 
			
		||||
											/>
 | 
			
		||||
											Get it on Google Play
 | 
			
		||||
										</a>
 | 
			
		||||
										<a
 | 
			
		||||
											class="w3-button w3-round-large w3-padding w3-blue-gray w3-margin-top"
 | 
			
		||||
											href="https://testflight.apple.com/join/tXxgtSpE"
 | 
			
		||||
										>
 | 
			
		||||
											<img src="ios.svg" style="height: 2em; margin: 0" />
 | 
			
		||||
											Get it on iOS (TestFlight)
 | 
			
		||||
										</a>
 | 
			
		||||
									</p>
 | 
			
		||||
									<p>Just launch the app.</p>
 | 
			
		||||
								</div>
 | 
			
		||||
								<div class="w3-container w3-cell w3-mobile">
 | 
			
		||||
									<h3>Web</h3>
 | 
			
		||||
									<p>
 | 
			
		||||
										<a
 | 
			
		||||
											class="w3-button w3-round-large w3-blue w3-padding-large"
 | 
			
		||||
											href="https://www.tildefriends.net/~core/ssb/"
 | 
			
		||||
											>🦀 Try It</a
 | 
			
		||||
										>
 | 
			
		||||
									</p>
 | 
			
		||||
									<p>
 | 
			
		||||
										<a href="/login?return=/~core/intro"
 | 
			
		||||
											>Register an account with tildefriends.net</a
 | 
			
		||||
										>
 | 
			
		||||
										to take it for a spin right away.
 | 
			
		||||
									</p>
 | 
			
		||||
									<h3>PeachCloud</h3>
 | 
			
		||||
									<p>
 | 
			
		||||
										Tilde Friends is also a part of 🍑☁️<a
 | 
			
		||||
											href="https://peach-docs.commoninternet.net/"
 | 
			
		||||
											>PeachCloud</a
 | 
			
		||||
										>, which is available on
 | 
			
		||||
										<a href="https://apps.yunohost.org/app/peachpub"
 | 
			
		||||
											>YunoHost</a
 | 
			
		||||
										>
 | 
			
		||||
										for accessible self-hosting.
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
								<div class="w3-container w3-cell w3-mobile">
 | 
			
		||||
									<h3>Desktop</h3>
 | 
			
		||||
									<p>
 | 
			
		||||
										<a
 | 
			
		||||
											class="w3-button w3-round-large w3-black w3-padding-large"
 | 
			
		||||
											href="https://dev.tildefriends.net/cory/tildefriends/releases"
 | 
			
		||||
											><i class="fa fa-download"></i> Download</a
 | 
			
		||||
										>
 | 
			
		||||
										<a
 | 
			
		||||
											class="w3-button w3-round-large w3-padding w3-blue-gray"
 | 
			
		||||
											href="https://dev.tildefriends.net/releases/tildefriends-x86_64.AppImage"
 | 
			
		||||
										>
 | 
			
		||||
											<img src="appimage.svg" style="height: 2em; margin: 0" />
 | 
			
		||||
											Get Linux 64-bit AppImage
 | 
			
		||||
										</a>
 | 
			
		||||
									</p>
 | 
			
		||||
									<p>
 | 
			
		||||
										Tilde Friends is distributed as a single executable file (or
 | 
			
		||||
										source that you can
 | 
			
		||||
										<a href="http://dev.tildefriends.net">build yourself</a>)
 | 
			
		||||
										and stores all of its data in a single
 | 
			
		||||
										file(<code>db.sqlite</code>). You can generally download the
 | 
			
		||||
										latest executable from
 | 
			
		||||
										<a
 | 
			
		||||
											href="https://dev.tildefriends.net/cory/tildefriends/releases"
 | 
			
		||||
											>releases</a
 | 
			
		||||
										>
 | 
			
		||||
										for your platform, mark it as executable (<code
 | 
			
		||||
											>chmod +x tildefriends*</code
 | 
			
		||||
										>
 | 
			
		||||
										on macOS and Linux), and run. Run with <code>-h</code> to
 | 
			
		||||
										learn more.
 | 
			
		||||
									</p>
 | 
			
		||||
									<p>
 | 
			
		||||
										Tilde Friends will run in the console and provide a web
 | 
			
		||||
										interface at
 | 
			
		||||
										<a href="http://localhost:12345/">http://localhost:12345/</a
 | 
			
		||||
										>. You will have to register a username and password to sign
 | 
			
		||||
										into your instance.
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
							<p>
 | 
			
		||||
								After a <a href="/~core/intro">brief introduction</a>, Tilde
 | 
			
		||||
								Friends will take you to the Secure Scuttlebutt social network
 | 
			
		||||
								app.
 | 
			
		||||
							</p>
 | 
			
		||||
						</li>
 | 
			
		||||
						<li>
 | 
			
		||||
							Create an account to identify yourself with that instance by
 | 
			
		||||
							username and password.
 | 
			
		||||
						</li>
 | 
			
		||||
						<li>
 | 
			
		||||
							Create an SSB identity in the <b>ssb</b> app. This will generate a
 | 
			
		||||
							keypair used to identify yourself to other users and sign your
 | 
			
		||||
							messages so that they can be verified as from you.
 | 
			
		||||
						</li>
 | 
			
		||||
						<li>
 | 
			
		||||
							Describe yourself in your profile in the <b>ssb</b> app. Give
 | 
			
		||||
							yourself a name and an avatar if you like.
 | 
			
		||||
						</li>
 | 
			
		||||
						<li>
 | 
			
		||||
							Connect to others.
 | 
			
		||||
							<ul>
 | 
			
		||||
								<li>Automatically discover peers on the same network.</li>
 | 
			
		||||
								<li>
 | 
			
		||||
									Manually connect to rooms and pubs, including
 | 
			
		||||
									<a href="https://www.tildefriends.net/~cory/room/"
 | 
			
		||||
										>tildefriends.net itself</a
 | 
			
		||||
									>.
 | 
			
		||||
								</li>
 | 
			
		||||
								<li>
 | 
			
		||||
									Enable <b>Peer Exchange</b> in the <b>admin</b> to discover
 | 
			
		||||
									internet peers.
 | 
			
		||||
								</li>
 | 
			
		||||
							</ul>
 | 
			
		||||
							Connect to others. You will automatically discover peers on the
 | 
			
		||||
							same instance and same network if there are any. Or use
 | 
			
		||||
							<a href="https://github.com/staltz/ssb-room/blob/master/FAQ.md"
 | 
			
		||||
								>rooms</a
 | 
			
		||||
							>
 | 
			
		||||
							and pubs to reach more distant users.
 | 
			
		||||
							<a href="https://www.tildefriends.net/~cory/room/"
 | 
			
		||||
								>tildefriends.net itself</a
 | 
			
		||||
							>
 | 
			
		||||
							operates as a room, so you can connect and see who else is online
 | 
			
		||||
							and establish a connection.
 | 
			
		||||
						</li>
 | 
			
		||||
						<li>Follow people to grow your network.</li>
 | 
			
		||||
						<li>
 | 
			
		||||
@@ -224,11 +132,11 @@
 | 
			
		||||
		<!-- SSB Section -->
 | 
			
		||||
		<div class="w3-light-grey">
 | 
			
		||||
			<div class="w3-row-padding w3-padding-64">
 | 
			
		||||
				<div class="w3-col l4 m6 s4 w3-center">
 | 
			
		||||
				<div class="w3-col l4 m6 s4">
 | 
			
		||||
					<a href="https://scuttlebutt.nz/"
 | 
			
		||||
						><img
 | 
			
		||||
							class="w3-image"
 | 
			
		||||
							src="hermietildefriends.svg"
 | 
			
		||||
							class="w3-image w3-round-large"
 | 
			
		||||
							src="ssb.png"
 | 
			
		||||
							alt="Secure Scuttlebutt"
 | 
			
		||||
					/></a>
 | 
			
		||||
				</div>
 | 
			
		||||
@@ -298,15 +206,11 @@
 | 
			
		||||
 | 
			
		||||
		<!-- Technlology Section -->
 | 
			
		||||
		<div class="w3-container w3-padding-64 w3-light-grey w3-center">
 | 
			
		||||
			<h1 class="w3-jumbo"><b>Built to Last</b></h1>
 | 
			
		||||
			<p>
 | 
			
		||||
				Tilde Friends strives to use only simple and widely adopted dependencies
 | 
			
		||||
				in order to keep it easy to build for all sorts of platforms and
 | 
			
		||||
				maintainable for a very long time.
 | 
			
		||||
			</p>
 | 
			
		||||
			<h1 class="w3-jumbo"><b>Trusted Technology</b></h1>
 | 
			
		||||
			<p>Tilde Friends is built using boring, trusted tech.</p>
 | 
			
		||||
			<p>
 | 
			
		||||
				Though of course for building Tilde Friends apps, you are free to use
 | 
			
		||||
				whatever fits on top.
 | 
			
		||||
				whatever fits.
 | 
			
		||||
			</p>
 | 
			
		||||
 | 
			
		||||
			<div class="w3-row" style="margin-top: 64px">
 | 
			
		||||
@@ -340,6 +244,10 @@
 | 
			
		||||
					<i class="fa fa-lock w3-text-purple w3-jumbo"></i>
 | 
			
		||||
					<p>libsodium</p>
 | 
			
		||||
				</a>
 | 
			
		||||
				<a href="https://www.openssl.org/" class="w3-col s3">
 | 
			
		||||
					<i class="fa fa-shield-halved w3-text-green w3-jumbo"></i>
 | 
			
		||||
					<p>OpenSSL</p>
 | 
			
		||||
				</a>
 | 
			
		||||
				<a
 | 
			
		||||
					href="https://github.com/ianlancetaylor/libbacktrace"
 | 
			
		||||
					class="w3-col s3"
 | 
			
		||||
@@ -347,13 +255,13 @@
 | 
			
		||||
					<i class="fa fa-burst w3-text-pink w3-jumbo"></i>
 | 
			
		||||
					<p>libbacktrace</p>
 | 
			
		||||
				</a>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div class="w3-row" style="margin-top: 64px">
 | 
			
		||||
				<a href="https://codemirror.net/docs/changelog/" class="w3-col s3">
 | 
			
		||||
					<i class="fa fa-keyboard w3-text-indigo w3-jumbo"></i>
 | 
			
		||||
					<p>CodeMirror</p>
 | 
			
		||||
				</a>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div class="w3-row" style="margin-top: 64px">
 | 
			
		||||
				<a href="https://github.com/jlfwong/speedscope/" class="w3-col s3">
 | 
			
		||||
					<i class="fa fa-microscope w3-text-orange w3-jumbo"></i>
 | 
			
		||||
					<p>Speedscope</p>
 | 
			
		||||
@@ -362,10 +270,6 @@
 | 
			
		||||
					<i class="fa fa-fire w3-text-cyan w3-jumbo"></i>
 | 
			
		||||
					<p>Lit</p>
 | 
			
		||||
				</a>
 | 
			
		||||
				<a href="https://github.com/c-ares/c-ares" class="w3-col s3">
 | 
			
		||||
					<i class="fa fa-book-atlas w3-text-purple w3-jumbo"></i>
 | 
			
		||||
					<p>c-ares</p>
 | 
			
		||||
				</a>
 | 
			
		||||
				<a href="https://www.gnu.org/software/make/" class="w3-col s3">
 | 
			
		||||
					<i class="fa fa-hammer w3-text-teal w3-jumbo"></i>
 | 
			
		||||
					<p>GNU Make</p>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +0,0 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="814" height="1000">
 | 
			
		||||
  <path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 660 B  | 
							
								
								
									
										
											BIN
										
									
								
								apps/welcome/ssb.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 50 KiB  | 
| 
		 Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB  | 
@@ -1,4 +1,4 @@
 | 
			
		||||
/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
 | 
			
		||||
/* W3.CSS 4.15 December 2020 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}
 | 
			
		||||
@@ -108,8 +108,6 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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%}
 | 
			
		||||
@@ -150,7 +148,6 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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}
 | 
			
		||||
@@ -178,19 +175,6 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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}
 | 
			
		||||
@@ -248,4 +232,4 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
 | 
			
		||||
.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}
 | 
			
		||||
.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}
 | 
			
		||||