Compare commits

..

33 commits
main ... dev

Author SHA1 Message Date
ari melody d595b037e6
boosts now reflect visibility of target post 2024-07-08 12:08:33 +01:00
ari melody b9445cf832
tweaked sounds on some buttons, renamed play_sound to playSound 2024-07-08 11:56:26 +01:00
ari melody 6e2e4e0c23
cleaned up notifications functionality, fixed infinite scroll loop 2024-07-08 11:07:19 +01:00
ari melody 956e67566b
fixed inconsistent display of avatars in combined notifs (seriously chromium?) 2024-07-08 10:50:46 +01:00
ari melody ae8a3fbd63
add media to notifications 2024-07-08 10:43:08 +01:00
ari melody 5e92246eeb
fixed emoji not showing in posts + improvements 2024-07-08 10:32:11 +01:00
ari melody e74b088e37
added visibility function to compose box + reply ID 2024-07-08 10:28:38 +01:00
ari melody 49409bac68
updated changelogs 2024-07-07 15:19:59 +01:00
vimaexd 2aa6dfaa43 set bin icon to currentColor 2024-07-07 15:16:06 +01:00
ari melody 6ce2f1f1c4
fixed thread view not updating when opening other posts 2024-07-07 15:13:26 +01:00
ari melody 4d771d8ebd
finish URL rewrite, remove redundant logged_in state 2024-07-07 14:58:59 +01:00
ari melody a3fdd0007c
rewrite URLs to represent instance (#2) 2024-07-07 14:33:28 +01:00
ari melody 41143cdddf
fixed app logo not appearing on login page 2024-07-07 13:18:12 +01:00
ari melody e80e59350f
add delete post button (#7) 2024-07-07 12:57:04 +01:00
ari melody 1773c93617
random placeholder in composer + some cleanup 2024-07-07 12:22:29 +01:00
ari melody 08cd43bd7d
fixed notifications only getting older notifs instead of newer ones 2024-07-05 15:49:20 +01:00
ari melody 048d158209
add stream health api endpoint (#24, #19) 2024-07-05 15:41:11 +01:00
ari melody 0d9c7b1761
add delete post api handler (#7) 2024-07-05 15:39:14 +01:00
ari melody 5ea28dc078
add edit post api handler (#6) 2024-07-05 15:31:35 +01:00
ari melody 3a04ef7f81
fixed alignment of notif content warning 2024-07-05 15:12:53 +01:00
ari melody b064a90753
prefer content warnings in notifications 2024-07-05 15:10:25 +01:00
ari melody d1d3b16cbf
fixed error when retrieving private replies in timeline 2024-07-05 15:03:30 +01:00
ari melody 0d39ddd3d2
do not parse post emojis if post.emoji is undefined 2024-07-05 14:56:46 +01:00
vimaexd abb06a40bc Merge branch 'dev' of ssh://node.arimelody.me:2082/blisstown/campfire into dev 2024-07-05 14:52:04 +01:00
vimaexd 5acb2e1667 very basic post support + misc 2024-07-05 14:51:32 +01:00
ari melody 5e44cdf73a
fixed logout, now using account/app/server architecture 2024-07-05 14:47:17 +01:00
ari melody 7f993ee538
added foreign reaction capabilties (chuckya) 2024-07-05 14:32:23 +01:00
ari melody a9fdd913f3
added border to reaction buttons 2024-07-05 13:42:00 +01:00
vimaexd dacabf250c unfinished compose box 2024-07-04 16:55:57 +01:00
ari melody 231d29a44b
updated changelogs 2024-07-03 22:02:21 +01:00
ari melody 2e64f63caa
huge refactor. addresses #21 w/ inifinite scrolling notifications 2024-07-03 22:00:32 +01:00
ari melody f883b61659
updated changelogs 2024-07-02 22:07:40 +01:00
ari melody ce2f09721d
fixed emote display in usernames and reactions 2024-07-02 21:58:46 +01:00
78 changed files with 1968 additions and 1395 deletions

View file

@ -1,3 +1,12 @@
# Campfire v0.4.0
- Huge refactor, along with some improved documentation
- Custom emotes now show in the sidebar profile display
- Infinite scrolling notifications
- Notifications now show content warnings instead of plain content
- Added compose box, and the ability to create posts
- Added ability to delete posts
- Rewrote Campfire URLs so they can be viewed anonymously
# Campfire v0.3.0 # Campfire v0.3.0
- Added notifications view - Added notifications view
- Many more background tweaks, fixes, and optimisations - Many more background tweaks, fixes, and optimisations

15
package-lock.json generated
View file

@ -8,6 +8,9 @@
"name": "campfire-client", "name": "campfire-client",
"version": "0.3.0", "version": "0.3.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": {
"typescript": "^5.5.3"
},
"devDependencies": { "devDependencies": {
"@poppanator/sveltekit-svg": "^4.2.1", "@poppanator/sveltekit-svg": "^4.2.1",
"@sveltejs/adapter-auto": "^3.2.2", "@sveltejs/adapter-auto": "^3.2.2",
@ -1567,6 +1570,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/typescript": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.1.tgz",

17
src/img/icons/bin.svg Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="currentColor" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<clipPath id="_clip1">
<rect x="4" y="4" width="24" height="24"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g transform="matrix(2.28571,0,0,2.42857,-28.5714,-40)">
<path d="M23,21.412L23,26.765C23,27.447 22.412,28 21.688,28L17.312,28C16.588,28 16,27.447 16,26.765L16,21.412L23,21.412ZM22.125,22.647C22.125,22.42 21.929,22.235 21.688,22.235C21.446,22.235 21.25,22.42 21.25,22.647L21.25,26.765C21.25,26.992 21.446,27.176 21.688,27.176C21.929,27.176 22.125,26.992 22.125,26.765L22.125,22.647ZM19.938,22.647C19.938,22.42 19.741,22.235 19.5,22.235C19.259,22.235 19.063,22.42 19.063,22.647L19.063,26.765C19.063,26.992 19.259,27.176 19.5,27.176C19.741,27.176 19.938,26.992 19.938,26.765L19.938,22.647ZM17.75,22.647C17.75,22.42 17.554,22.235 17.313,22.235C17.071,22.235 16.875,22.42 16.875,22.647L16.875,26.765C16.875,26.992 17.071,27.176 17.313,27.176C17.554,27.176 17.75,26.992 17.75,26.765L17.75,22.647Z"/>
</g>
<g transform="matrix(2.57143,0,0,0.428571,-34.1429,-2)">
<path d="M21.444,21C22.304,21 23,25.179 23,30.333L16,30.333C16,25.179 16.696,21 17.556,21L17.556,20.995C17.556,19.14 17.678,17.361 17.897,16.049C18.116,14.737 18.412,14 18.721,14L20.278,14C20.922,14 21.444,17.134 21.444,21ZM18.333,21L20.667,21C20.667,20.381 20.626,19.788 20.553,19.35C20.48,18.912 20.381,18.667 20.278,18.667L18.722,18.667C18.507,18.667 18.333,19.712 18.333,21Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1,3 +1,3 @@
<svg viewBox="0 0 128 128" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.1414 16C96.4815 16 100 19.5746 100 23.9838C100 40.8497 100 80.4817 100 104.016C100 107.201 98.1365 110.082 95.2586 111.345C92.3797 112.609 89.038 112.013 86.7552 109.83C76.4888 100.013 64 88.0719 64 88.0719C64 88.0719 51.5112 100.013 41.2448 109.83C38.962 112.013 35.6204 112.609 32.7414 111.345C29.8635 110.082 28 107.201 28 104.016C28 80.4817 28 40.8497 28 23.9838C28 19.5746 31.5185 16 35.8586 16C49.7059 16 78.2941 16 92.1414 16Z"/> <path fill-rule="evenodd" d="M23.035 4C24.12 4 25 4.894 25 5.996v20.008c0 .796-.466 1.516-1.185 1.832a1.941 1.941 0 0 1-2.126-.378L16 22.017l-5.689 5.44a1.941 1.941 0 0 1-2.126.378A1.997 1.997 0 0 1 7 26.004V5.996C7 4.894 7.88 4 8.965 4h14.07Z" clip-rule="evenodd"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 580 B

After

Width:  |  Height:  |  Size: 357 B

3
src/img/icons/dm.svg Normal file
View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path fill-rule="evenodd" d="M4.075 10.226c-.05.25-.075.51-.075.774v8a4 4 0 0 0 4 4h16a4 4 0 0 0 4-4v-8a4.02 4.02 0 0 0-.075-.774L16.53 17.348l-.53.331-.53-.331-11.395-7.122Zm.874-1.812L16 15.32l11.051-6.907A3.991 3.991 0 0 0 24 7H8a3.991 3.991 0 0 0-3.051 1.414Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

15
src/img/icons/error.svg Normal file
View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)">
<path fill-rule="evenodd" d="M13 26a7 7 0 1 0 0-14 7 7 0 0 0 0 14Zm0 2a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" clip-rule="evenodd"/>
<path d="M11.417 18.547a1.556 1.556 0 1 1-3.112 0 1.556 1.556 0 0 1 3.112 0Zm6.222 0a1.556 1.556 0 1 1-3.111 0 1.556 1.556 0 0 1 3.11 0Z"/>
<path fill-rule="evenodd" d="M16.146 23.489a.889.889 0 0 1-1.247.156l-.034-.026c-.39-.304-.866-.676-1.865-.676-.998 0-1.475.372-1.865.676l-.034.026a.889.889 0 0 1-1.091-1.403l.027-.022c.484-.377 1.355-1.054 2.963-1.054s2.479.677 2.963 1.054l.027.022c.388.301.458.86.156 1.247Z" clip-rule="evenodd"/>
<path d="M28 11.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Zm-8-4a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm-1-1a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Zm-1 2a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0Z"/>
<path d="M24.5 6.7c.663 0 1.2.672 1.2 1.5s-.537 1.5-1.2 1.5c-.663 0-1.2-.672-1.2-1.5s.537-1.5 1.2-1.5Z"/>
<path fill-rule="evenodd" d="M24.34 5.552c.336.324.543.782.651 1.355.115.604.04 1.088-.124 1.478-.106.25-.244.449-.365.6l.116.03-.236.971a4.28 4.28 0 0 1-.579-.173c-.125-.051-.333-.148-.452-.352a.645.645 0 0 1 .015-.658c.06-.104.149-.204.207-.27l.011-.013c.14-.158.272-.311.361-.524.086-.202.143-.486.064-.903-.085-.449-.225-.689-.363-.822-.132-.127-.312-.204-.582-.226-.28-.023-.626.017-1.05.103-.323.066-.665.153-1.036.247l-.357.09a.5.5 0 0 1-.242-.97l.336-.085c.377-.096.75-.19 1.1-.262.452-.092.909-.154 1.33-.12.432.035.854.174 1.195.504Zm-.212 3.31.006.003a.032.032 0 0 1-.006-.003Z" clip-rule="evenodd"/>
</g>
<defs>
<clipPath id="a">
<path d="M4 4h24v24H4z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,10 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)"> <g clip-path="url(#a)">
<path fill-rule="evenodd" d="M49.698 17.698c-17.673 0-32 14.327-32 32v29.456c0 17.673 14.327 32 32 32h29.456c17.673 0 32-14.327 32-32V49.698c0-17.673-14.327-32-32-32H49.698Zm41.963 23.469c1.636-2.963-1.622-6.221-4.586-4.586l-18.64 10.29a3.375 3.375 0 0 0-.755 5.34l8.35 8.352a3.375 3.375 0 0 0 5.342-.756l10.29-18.64ZM59.657 65.314a1.688 1.688 0 0 1 0-2.386l3.27-3.27a1.688 1.688 0 0 1 2.387 0l3.27 3.27c.66.659.66 1.727 0 2.386l-3.27 3.27a1.688 1.688 0 0 1-2.386 0l-3.27-3.27ZM36.581 87.075c-1.636 2.964 1.623 6.222 4.586 4.586l18.64-10.29a3.375 3.375 0 0 0 .756-5.34l-8.352-8.352a3.375 3.375 0 0 0-5.34.756l-10.29 18.64Z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M12.425 4.425a8 8 0 0 0-8 8v7.363a8 8 0 0 0 8 8h7.363a8 8 0 0 0 8-8v-7.363a8 8 0 0 0-8-8h-7.364Zm10.49 5.867c.41-.741-.405-1.556-1.146-1.147l-4.66 2.573a.844.844 0 0 0-.19 1.335l2.089 2.088a.844.844 0 0 0 1.335-.19l2.572-4.66Zm-8 6.037a.422.422 0 0 1 0-.597l.817-.818a.422.422 0 0 1 .596 0l.818.818a.422.422 0 0 1 0 .597l-.818.817a.422.422 0 0 1-.596 0l-.818-.817Zm-5.77 5.44c-.409.74.406 1.555 1.147 1.146l4.66-2.572a.844.844 0 0 0 .189-1.335l-2.088-2.088a.844.844 0 0 0-1.336.189l-2.572 4.66Z" clip-rule="evenodd"/>
</g> </g>
<defs> <defs>
<clipPath id="a"> <clipPath id="a">
<path d="M16 16h96v96H16z"/> <path d="M4 4h24v24H4z"/>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 865 B

After

Width:  |  Height:  |  Size: 761 B

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)">
<path fill-rule="evenodd" d="M16.5 20.889a7.389 7.389 0 1 0 0-14.778 7.389 7.389 0 0 0 0 14.778Zm0 2.111a9.5 9.5 0 1 0 0-19 9.5 9.5 0 0 0 0 19Z" clip-rule="evenodd"/>
<path d="M14.829 13.022a1.642 1.642 0 1 1-3.284 0 1.642 1.642 0 0 1 3.284 0Zm6.568 0a1.642 1.642 0 1 1-3.284 0 1.642 1.642 0 0 1 3.284 0Z"/>
<path fill-rule="evenodd" d="M13.18 16.148a.938.938 0 0 1 1.351-.137c.412.321.915.713 1.969.713s1.557-.392 1.969-.713c.011-.01.023-.018.035-.027a.938.938 0 1 1 1.123 1.503c-.51.398-1.43 1.114-3.127 1.114-1.698 0-2.616-.716-3.127-1.114l-.03-.022a.938.938 0 0 1-.164-1.317Z" clip-rule="evenodd"/>
<path d="M9.224 28.276a7.276 7.276 0 0 1 14.552 0H9.224Z"/>
</g>
<defs>
<clipPath id="a">
<path d="M4 4h24v24H4z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 889 B

View file

@ -1,10 +1,10 @@
<svg viewBox="0 0 128 128" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#clip0_135_36)"> <g clip-path="url(#a)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.2278 87.9024H24.0002C21.4714 87.9024 19.0909 86.7072 17.5817 84.6778C16.0718 82.6493 15.6106 80.0255 16.3372 77.6038L17.0496 75.2299C18.0649 71.8454 21.179 69.5284 24.7125 69.5284H39.6624L42.7666 56.6666H31.3495C28.8207 56.6666 26.441 55.4714 24.9311 53.442C23.4219 51.4135 22.96 48.7897 23.6873 46.368L24.3989 43.9941C25.4143 40.6096 28.5291 38.2927 32.0619 38.2927H47.2012L50.38 25.1231C51.247 21.531 54.4612 19 58.1565 19H58.7262C61.1784 19 63.495 20.1245 65.012 22.051C66.529 23.9775 67.0782 26.4929 66.5027 28.8769L64.2308 38.2927H78.4208L81.5995 25.1231C82.4666 21.531 85.6807 19 89.376 19H89.9458C92.3979 19 94.7145 20.1245 96.2315 22.051C97.7485 23.9775 98.2977 26.4929 97.7223 28.8769L95.4503 38.2927H103.553C106.081 38.2927 108.461 39.4879 109.971 41.5173C111.48 43.5458 111.941 46.1696 111.215 48.5913L110.503 50.9652C109.488 54.3497 106.373 56.6666 102.84 56.6666H91.0157L87.9115 69.5284H96.2032C98.7319 69.5284 101.112 70.7236 102.621 72.7531C104.131 74.7815 104.592 77.4053 103.865 79.827L103.153 82.201C102.138 85.5854 99.0236 87.9024 95.4908 87.9024H83.4769L80.2982 101.072C79.4312 104.664 76.217 107.195 72.5217 107.195H71.952C69.4998 107.195 67.1832 106.071 65.6662 104.144C64.1492 102.218 63.6 99.7022 64.1755 97.3181L66.4474 87.9024H52.2574L49.0787 101.072C48.2116 104.664 44.9974 107.195 41.3022 107.195H40.7324C38.2803 107.195 35.9636 106.071 34.4466 104.144C32.9297 102.218 32.3805 99.7022 32.9559 97.3181L35.2278 87.9024ZM59.7962 56.6666L56.692 69.5284H70.882L73.9862 56.6666H59.7962Z"/> <path fill-rule="evenodd" d="M8.807 21.976H6A2 2 0 0 1 4.084 19.4l.178-.594a2 2 0 0 1 1.916-1.425h3.737l.777-3.215H7.836a2 2 0 0 1-1.915-2.575l.178-.594a2 2 0 0 1 1.915-1.425H11.8l.795-3.292a2 2 0 0 1 1.944-1.531h.142a2 2 0 0 1 1.944 2.47l-.567 2.353h3.547L20.4 6.28a2 2 0 0 1 1.944-1.531h.142a2 2 0 0 1 1.944 2.47l-.568 2.353h2.026a2 2 0 0 1 1.915 2.575l-.177.593a2 2 0 0 1-1.916 1.426h-2.956l-.776 3.215h2.073a2 2 0 0 1 1.915 2.575l-.178.593a2 2 0 0 1-1.916 1.426H20.87l-.795 3.292a2 2 0 0 1-1.944 1.53h-.142a2 2 0 0 1-1.944-2.468l.568-2.354h-3.548l-.794 3.292a2 2 0 0 1-1.945 1.53h-.142a2 2 0 0 1-1.944-2.468l.568-2.354Zm6.142-7.81-.776 3.216h3.547l.776-3.215H14.95Z" clip-rule="evenodd"/>
</g> </g>
<defs> <defs>
<clipPath id="clip0_135_36"> <clipPath id="a">
<rect width="96" height="89" fill="white" transform="translate(16 19)"/> <path d="M4 4.75h24V27H4z"/>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 910 B

View file

@ -1,5 +1,3 @@
<svg viewBox="0 0 128 128" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g transform="matrix(1,0,0,1,16,16)"> <path fill-rule="evenodd" d="M25 16a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-7-4.5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm-2 3a2 2 0 0 0-2 2v4a2 2 0 1 0 4 0v-4a2 2 0 0 0-2-2Z" clip-rule="evenodd"/>
<path d="M84,48C84,67.882 67.882,84 48,84C28.118,84 12,67.882 12,48C12,28.118 28.118,12 48,12C67.882,12 84,28.118 84,48ZM56,30C56,34.418 52.418,38 48,38C43.582,38 40,34.418 40,30C40,25.582 43.582,22 48,22C52.418,22 56,25.582 56,30ZM48,42C43.582,42 40,45.582 40,50L40,66C40,70.418 43.582,74 48,74C52.418,74 56,70.418 56,66L56,50C56,45.582 52.418,42 48,42Z"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 269 B

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path fill-rule="evenodd" d="M58.719 106.897C31.554 85.804 20.068 67.619 16.989 54.07c-2.574-11.324.138-20.218 5.13-26.454C27.613 20.754 36.142 17 44.84 17c8.436 0 14.687 3.415 18.99 7.126C68.133 20.415 74.384 17 82.82 17c8.698 0 17.226 3.754 22.721 10.616 4.992 6.236 7.705 15.13 5.131 26.454-3.081 13.55-14.566 31.734-41.73 52.827l-5.111 3.969-5.111-3.969Zm5.11-6.582c59.315-46.058 39.64-74.982 18.99-74.982-13.241 0-18.99 11.716-18.99 11.716s-5.747-11.716-18.99-11.716c-20.65 0-40.324 28.924 18.99 74.982Z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M14.68 26.724c-6.791-5.273-9.663-9.82-10.433-13.207-.643-2.83.035-5.054 1.283-6.613C6.903 5.188 9.035 4.25 11.21 4.25c2.109 0 3.672.854 4.747 1.782 1.076-.928 2.639-1.782 4.748-1.782 2.174 0 4.306.938 5.68 2.654 1.248 1.56 1.926 3.783 1.283 6.614-.77 3.387-3.642 7.933-10.433 13.206l-1.278.992-1.277-.992Zm1.277-1.645c14.829-11.515 9.91-18.746 4.748-18.746-3.31 0-4.748 2.93-4.748 2.93s-1.437-2.93-4.747-2.93c-5.163 0-10.081 7.231 4.747 18.746Z" clip-rule="evenodd"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 624 B

After

Width:  |  Height:  |  Size: 587 B

View file

@ -1,10 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 33">
<g clip-path="url(#a)"> <g clip-path="url(#a)">
<path fill-rule="evenodd" d="M58.719 106.897C31.554 85.804 20.068 67.619 16.989 54.07c-2.574-11.324.138-20.218 5.13-26.454C27.613 20.754 36.142 17 44.84 17c8.436 0 14.687 3.415 18.99 7.126C68.133 20.415 74.384 17 82.82 17c8.698 0 17.226 3.754 22.721 10.616 4.992 6.236 7.705 15.13 5.131 26.454-3.081 13.55-14.566 31.734-41.73 52.827l-5.111 3.969-5.111-3.969Z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M14.68 27.474c-6.791-5.273-9.663-9.82-10.433-13.207-.643-2.83.035-5.054 1.283-6.613C6.903 5.938 9.035 5 11.21 5c2.109 0 3.672.854 4.747 1.782C17.033 5.854 18.596 5 20.705 5c2.174 0 4.306.938 5.68 2.654 1.248 1.56 1.926 3.783 1.283 6.614-.77 3.387-3.642 7.933-10.433 13.206l-1.278.992-1.277-.992Z" clip-rule="evenodd"/>
</g> </g>
<defs> <defs>
<clipPath id="a"> <clipPath id="a">
<path d="M16 16h96v96H16z"/> <path d="M4 4.75h24v24H4z"/>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 601 B

After

Width:  |  Height:  |  Size: 565 B

View file

@ -1,3 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path fill-rule="evenodd" d="M36 16h4.03c.517 8.37 7.47 15 15.97 15h16c8.5 0 15.453-6.63 15.97-15H92a8 8 0 0 1 8 8v80a8 8 0 0 1-8 8H36a8 8 0 0 1-8-8V24a8 8 0 0 1 8-8Zm43.938 0H48.062A8.001 8.001 0 0 0 56 23h16a8.001 8.001 0 0 0 7.938-7ZM44 46a4 4 0 0 0 0 8h41a4 4 0 0 0 0-8H44Zm-4 20a4 4 0 0 1 4-4h41a4 4 0 0 1 0 8H44a4 4 0 0 1-4-4Zm4 12a4 4 0 0 0 0 8h41a4 4 0 0 0 0-8H44Z" clip-rule="evenodd"/> <g clip-path="url(#a)">
<path fill-rule="evenodd" d="M9 4h1.008A4 4 0 0 0 14 7.75h4A4 4 0 0 0 21.992 4H23a2 2 0 0 1 2 2v20a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm10.985 0h-7.97A2 2 0 0 0 14 5.75h4A2 2 0 0 0 19.985 4ZM11 11.5a1 1 0 1 0 0 2h10.25a1 1 0 1 0 0-2H11Zm-1 5a1 1 0 0 1 1-1h10.25a1 1 0 1 1 0 2H11a1 1 0 0 1-1-1Zm1 3a1 1 0 1 0 0 2h10.25a1 1 0 1 0 0-2H11Z" clip-rule="evenodd"/>
</g>
<defs>
<clipPath id="a">
<path d="M4 4h24v24H4z"/>
</clipPath>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 583 B

View file

@ -1,11 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)"> <g clip-path="url(#a)">
<path fill-rule="evenodd" d="M42.23 17A9.23 9.23 0 0 0 33 26.23v76.155a9.23 9.23 0 0 0 9.23 9.23h41.54a9.23 9.23 0 0 0 9.23-9.23v-7.247c-7.175 1.296-14.923-3.924-14.923-12.444v-1.88H72.23C62.714 80.813 55 73.098 55 63.581c0-9.516 7.714-17.23 17.23-17.23h5.847v-.727c0-8.52 7.748-13.74 14.923-12.445v-6.95A9.23 9.23 0 0 0 83.77 17H42.23ZM93 41.614c-2.899-1.703-6.923.297-6.923 4.01v8.728H72.23a9.23 9.23 0 0 0 0 18.461h13.846v9.88c0 3.715 4.024 5.715 6.923 4.011v-45.09Z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M10.558 4.25A2.308 2.308 0 0 0 8.25 6.558v19.038a2.308 2.308 0 0 0 2.308 2.308h10.384a2.308 2.308 0 0 0 2.308-2.308v-1.811c-1.794.323-3.73-.982-3.73-3.111v-.47h-1.462a4.308 4.308 0 1 1 0-8.616h1.461v-.182c0-2.13 1.937-3.435 3.731-3.11V6.557a2.308 2.308 0 0 0-2.308-2.308H10.558Zm12.692 6.154c-.725-.426-1.73.074-1.73 1.002v2.182h-3.463a2.308 2.308 0 0 0 0 4.615h3.462v2.47c0 .929 1.006 1.429 1.731 1.003V10.404Z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M110.949 67.263a4.616 4.616 0 0 0 0-6.208l-16.84-18.534c-2.838-3.123-8.032-1.115-8.032 3.104v8.727H72.23a9.23 9.23 0 0 0 0 18.461h13.846v9.88c0 4.22 5.194 6.227 8.031 3.105l16.841-18.535Z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M27.737 16.816c.4-.44.4-1.112 0-1.552l-4.21-4.634c-.71-.78-2.008-.279-2.008.776v2.182h-3.461a2.308 2.308 0 0 0 0 4.615h3.461v2.47c0 1.055 1.299 1.557 2.008.776l4.21-4.633Z" clip-rule="evenodd"/>
</g> </g>
<defs> <defs>
<clipPath id="a"> <clipPath id="a">
<path d="M16 16h96v96H16z"/> <path d="M4 4h24v24H4z"/>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 956 B

After

Width:  |  Height:  |  Size: 906 B

12
src/img/icons/media.svg Normal file
View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)">
<path fill-rule="evenodd" d="M24 7H8a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1ZM8 4a4 4 0 0 0-4 4v16a4 4 0 0 0 4 4h16a4 4 0 0 0 4-4V8a4 4 0 0 0-4-4H8Z" clip-rule="evenodd"/>
<path d="M11.606 16.787a1 1 0 0 1 1.788 0l4.01 8.015a1 1 0 0 1-.893 1.448H8.489a1 1 0 0 1-.894-1.448l4.01-8.015Z"/>
<path d="M18.652 19.355a1 1 0 0 1 1.696 0l3.824 6.115a1 1 0 0 1-.848 1.53h-7.648a1 1 0 0 1-.848-1.53l3.824-6.114ZM23 12.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z"/>
</g>
<defs>
<clipPath id="a">
<path d="M4 4h24v24H4z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 696 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path d="M15.878 27.028c-1.813 0-3.426-.247-4.841-.741-1.415-.489-2.61-1.207-3.588-2.156-.977-.944-1.722-2.097-2.233-3.46-.511-1.364-.767-2.915-.767-4.654 0-1.716.256-3.273.767-4.67.517-1.398 1.27-2.597 2.258-3.597.99-1.006 2.196-1.778 3.623-2.318 1.431-.54 3.062-.81 4.892-.81 1.693 0 3.204.247 4.534.742 1.335.494 2.466 1.199 3.392 2.113a8.977 8.977 0 0 1 2.122 3.247c.489 1.25.73 2.634.724 4.151a13.416 13.416 0 0 1-.272 2.88c-.188.876-.483 1.64-.887 2.293a4.48 4.48 0 0 1-1.568 1.543c-.648.375-1.432.588-2.352.64-.66.05-1.193.002-1.602-.146-.41-.148-.722-.366-.938-.656a2.404 2.404 0 0 1-.41-1.031h-.101c-.137.34-.404.65-.802.929-.397.272-.883.485-1.457.639a5.76 5.76 0 0 1-1.815.162 5.357 5.357 0 0 1-1.91-.469 4.846 4.846 0 0 1-1.593-1.185c-.46-.517-.824-1.156-1.09-1.917-.262-.761-.396-1.642-.402-2.642.006-.989.145-1.85.418-2.583.278-.733.645-1.349 1.1-1.849.46-.5.971-.892 1.534-1.176a5.42 5.42 0 0 1 1.696-.546 5.772 5.772 0 0 1 1.824 0c.573.097 1.056.253 1.448.47.398.215.645.448.742.698h.12v-.955h2.667v8.319c.005.392.093.696.264.912.17.216.4.323.69.323.392 0 .72-.173.98-.52.267-.346.466-.877.597-1.593.136-.716.205-1.628.205-2.736 0-1.068-.143-2.006-.427-2.813-.278-.812-.67-1.502-1.176-2.07a6.196 6.196 0 0 0-1.747-1.398 8.058 8.058 0 0 0-2.165-.784 11.413 11.413 0 0 0-2.386-.248c-1.415 0-2.65.216-3.707.648-1.057.426-1.938 1.026-2.642 1.799A7.604 7.604 0 0 0 8.01 12.53c-.346 1.034-.522 2.162-.528 3.384.006 1.346.193 2.54.562 3.58.375 1.033.927 1.903 1.654 2.607.727.705 1.625 1.239 2.693 1.602 1.068.364 2.296.546 3.682.546.653 0 1.292-.048 1.917-.145.625-.09 1.19-.204 1.697-.34a9.91 9.91 0 0 0 1.218-.384l.844 2.471c-.358.205-.844.395-1.457.571-.608.182-1.296.327-2.063.435a15.86 15.86 0 0 1-2.352.17Zm-.392-7.704c.693 0 1.244-.134 1.653-.4.415-.268.71-.663.887-1.185.181-.529.267-1.18.255-1.952-.005-.682-.093-1.259-.264-1.73a1.959 1.959 0 0 0-.86-1.083c-.404-.25-.967-.375-1.688-.375-.63 0-1.168.134-1.611.401a2.615 2.615 0 0 0-1.006 1.125c-.227.477-.344 1.04-.35 1.688a5.34 5.34 0 0 0 .29 1.713c.188.534.498.968.93 1.303.431.33 1.02.495 1.764.495Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -1,5 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<circle cx="28" cy="64" r="12"/> <path d="M10 16a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm9 0a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm9 0a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
<circle cx="64" cy="64" r="12"/>
<circle cx="100" cy="64" r="12"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 196 B

After

Width:  |  Height:  |  Size: 204 B

View file

@ -1,11 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)"> <g clip-path="url(#a)">
<path d="M28 48c0-17.673 14.327-32 32-32h9c17.673 0 32 14.327 32 32v33a8 8 0 0 1-8 8H36a8 8 0 0 1-8-8V48Z"/> <path d="M7 12a8 8 0 0 1 8-8h2.25a8 8 0 0 1 8 8v8.25a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V12Z"/>
<path fill-rule="evenodd" d="M53.417 84h21.166c-2.82-2.49-6.525-4-10.583-4a15.94 15.94 0 0 0-10.583 4Zm26.552 13H48.031c.516 8.371 7.468 15 15.969 15 8.5 0 15.453-6.629 15.97-15Z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M13.354 21h5.292A3.984 3.984 0 0 0 16 20a3.99 3.99 0 0 0-2.646 1Zm6.638 3.25h-7.984a4 4 0 0 0 7.984 0Z" clip-rule="evenodd"/>
</g> </g>
<defs> <defs>
<clipPath id="a"> <clipPath id="a">
<path d="M16 16h96v96H16z"/> <path d="M4 4h24v24H4z"/>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 463 B

11
src/img/icons/plus.svg Normal file
View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)">
<path d="M15 5a1 1 0 1 1 2 0v22a1 1 0 1 1-2 0V5Z"/>
<path d="M5 17a1 1 0 1 1 0-2h22a1 1 0 1 1 0 2H5Z"/>
</g>
<defs>
<clipPath id="a">
<path d="M4 4h24v24H4z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 322 B

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)">
<path d="M13 5.5A1.5 1.5 0 0 1 14.5 4h3A1.5 1.5 0 0 1 19 5.5v21a1.5 1.5 0 0 1-1.5 1.5h-3a1.5 1.5 0 0 1-1.5-1.5v-21Z"/>
<path d="M5.5 19A1.5 1.5 0 0 1 4 17.5v-3A1.5 1.5 0 0 1 5.5 13h21a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-21Z"/>
</g>
<defs>
<clipPath id="a">
<path d="M4 4h24v24H4z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 456 B

10
src/img/icons/poll.svg Normal file
View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)">
<path d="M10 7a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm0 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm0 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm2-18a2 2 0 0 1 2-2h12a2 2 0 1 1 0 4H14a2 2 0 0 1-2-2Zm0 9a2 2 0 0 1 2-2h3a2 2 0 1 1 0 4h-3a2 2 0 0 1-2-2Zm0 9a2 2 0 0 1 2-2h7a2 2 0 1 1 0 4h-7a2 2 0 0 1-2-2Z"/>
</g>
<defs>
<clipPath id="a">
<path d="M4 4h24v24H4z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 485 B

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path fill-rule="evenodd" d="M36 103c.151 0 .302-.004.45-.012a11.97 11.97 0 0 0 9.035-3.503l3.868-3.868-16.97-16.97-3.868 3.868A11.962 11.962 0 0 0 25 90.993V103h11Zm19.01-13.04 44.475-44.475c4.687-4.686 4.687-12.284 0-16.97-4.686-4.687-12.284-4.687-16.97 0L38.04 72.99l16.97 16.97Z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M9 24.75c.616 0 1.167-.278 1.533-.716.045-.038.089-.078.131-.12l.967-.967-2.828-2.828-.967.967a2.02 2.02 0 0 0-.12.13A1.996 1.996 0 0 0 7 22.75v2h2Zm4.045-3.217 11.12-11.119a2 2 0 1 0-2.83-2.828L10.218 18.705l2.828 2.828Z" clip-rule="evenodd"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 364 B

12
src/img/icons/public.svg Normal file
View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)">
<path d="M16 5.162c0-.642.521-1.168 1.16-1.106A12 12 0 0 1 27.944 14.84c.062.639-.464 1.16-1.106 1.16-.641 0-1.154-.522-1.23-1.159a9.678 9.678 0 0 0-8.45-8.448C16.523 6.316 16 5.803 16 5.162Z"/>
<path d="M16 9.37c0-.757.617-1.382 1.363-1.253a8 8 0 0 1 6.52 6.52c.129.745-.496 1.363-1.252 1.363-.757 0-1.352-.623-1.547-1.354a5.248 5.248 0 0 0-1.364-2.366 5.26 5.26 0 0 0-2.366-1.364c-.73-.194-1.354-.79-1.354-1.547Zm1.95 14.58A6.998 6.998 0 0 1 6 19a7 7 0 0 1 2.05-4.95L13 19l4.95 4.95Z"/>
<path d="M6.399 27.55a3.377 3.377 0 0 1 6.753 0H6.399Zm8.241-11.559a1 1 0 1 1 1.415 1.414l-3.934 3.934a1 1 0 0 1-1.414-1.414l3.934-3.934ZM19 14.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z"/>
</g>
<defs>
<clipPath id="a">
<path d="M4 4h24v24H4z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 897 B

View file

@ -1,4 +1,3 @@
<svg viewBox="0 0 128 128" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.25 102C36.8317 102 33.25 98.4183 33.25 94V81H30.75C23.018 81 16.75 74.732 16.75 67V40C16.75 32.268 23.018 26 30.75 26H49.5C57.232 26 63.5 32.268 63.5 40V73C63.5 75.6212 62.7488 78.5715 61.9055 81.062C60.9986 83.7402 59.7131 86.6735 58.1334 89.4379C56.5813 92.154 54.5609 95.032 52.0686 97.3339C49.6611 99.5574 45.9736 102 41.25 102ZM41.25 73V94C44.1431 94 46.9245 91.6565 49.25 88.4761C50.8958 86.2252 52.3132 83.5551 53.3802 81C54.7139 77.806 55.5 74.7916 55.5 73V40C55.5 36.6863 52.8137 34 49.5 34H30.75C27.4363 34 24.75 36.6863 24.75 40V67C24.75 70.3137 27.4363 73 30.75 73H41.25ZM40.5032 86.1254C40.5032 86.1249 40.512 86.1202 40.5295 86.1129C40.5119 86.1223 40.5032 86.126 40.5032 86.1254Z"/> <path fill-rule="evenodd" d="M10.313 25.5a2 2 0 0 1-2-2v-3.25h-.626a3.5 3.5 0 0 1-3.5-3.5V10a3.5 3.5 0 0 1 3.5-3.5h4.688a3.5 3.5 0 0 1 3.5 3.5v8.25c0 .655-.188 1.393-.399 2.015-.226.67-.548 1.403-.943 2.095-.388.678-.893 1.398-1.516 1.974-.602.555-1.524 1.166-2.704 1.166Zm0-7.25v5.25c.723 0 1.418-.586 2-1.381a9.323 9.323 0 0 0 1.032-1.869c.333-.799.53-1.552.53-2V10a1.5 1.5 0 0 0-1.5-1.5H7.687a1.5 1.5 0 0 0-1.5 1.5v6.75a1.5 1.5 0 0 0 1.5 1.5h2.625Zm-.187 3.281.006-.003a.04.04 0 0 1-.006.003ZM22 25.5a2 2 0 0 1-2-2v-3.25h-.625a3.5 3.5 0 0 1-3.5-3.5V10a3.5 3.5 0 0 1 3.5-3.5h4.688a3.5 3.5 0 0 1 3.5 3.5v8.25c0 .655-.188 1.393-.4 2.015-.226.67-.547 1.403-.942 2.095-.388.678-.893 1.398-1.516 1.974-.602.555-1.524 1.166-2.705 1.166Zm0-7.25v5.25c.723 0 1.419-.586 2-1.381a9.323 9.323 0 0 0 1.032-1.869c.334-.799.53-1.552.53-2V10a1.5 1.5 0 0 0-1.5-1.5h-4.687a1.5 1.5 0 0 0-1.5 1.5v6.75a1.5 1.5 0 0 0 1.5 1.5H22Zm-.187 3.281s.003 0 .007-.003a.04.04 0 0 1-.007.003Z" clip-rule="evenodd"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M88 102C83.5817 102 80 98.4183 80 94V81H77.5C69.768 81 63.5 74.732 63.5 67V40C63.5 32.268 69.768 26 77.5 26H96.25C103.982 26 110.25 32.268 110.25 40V73C110.25 75.6212 109.499 78.5715 108.655 81.062C107.749 83.7402 106.463 86.6735 104.883 89.4379C103.331 92.154 101.311 95.032 98.8186 97.3339C96.4111 99.5574 92.7236 102 88 102ZM88 73V94C90.8931 94 93.6745 91.6565 96 88.4761C97.6458 86.2252 99.0632 83.5551 100.13 81C101.464 77.806 102.25 74.7916 102.25 73V40C102.25 36.6863 99.5637 34 96.25 34H77.5C74.1863 34 71.5 36.6863 71.5 40V67C71.5 70.3137 74.1863 73 77.5 73H88ZM87.2532 86.1254C87.2532 86.1249 87.262 86.1202 87.2795 86.1129C87.2619 86.1223 87.2532 86.126 87.2532 86.1254Z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,15 +1,14 @@
<svg viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#clip0_114_221)"> <g clip-path="url(#a)">
<circle cx="52" cy="76" r="32" stroke="currentColor" stroke-width="8"/> <path fill-rule="evenodd" d="M13 26a7 7 0 1 0 0-14 7 7 0 0 0 0 14Zm0 2a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" clip-rule="evenodd"/>
<circle cx="39.4444" cy="74.1898" r="6.22222" fill="currentColor"/> <path d="M11.417 18.547a1.556 1.556 0 1 1-3.112 0 1.556 1.556 0 0 1 3.112 0Zm6.222 0a1.556 1.556 0 1 1-3.111 0 1.556 1.556 0 0 1 3.11 0Z"/>
<circle cx="64.3333" cy="74.1898" r="6.22222" fill="currentColor"/> <path fill-rule="evenodd" d="M9.854 21.509a.889.889 0 0 1 1.247-.156l.034.026c.39.304.866.675 1.865.675s1.475-.371 1.865-.675l.034-.026a.889.889 0 1 1 1.064 1.424c-.484.377-1.355 1.055-2.963 1.055s-2.479-.678-2.963-1.055l-.027-.021a.889.889 0 0 1-.156-1.247Z" clip-rule="evenodd"/>
<path d="M42.2222 88.2176C43.9722 89.5787 46.6666 91.7731 51.9999 91.7731C57.3333 91.7731 60.0278 89.5787 61.7777 88.2176" stroke="currentColor" stroke-width="7.11111" stroke-linecap="round" stroke-linejoin="round"/> <path d="M22.5 5.25a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-7Z"/>
<path d="M90 21C90 19.8954 90.8954 19 92 19H96C97.1046 19 98 19.8954 98 21V49C98 50.1046 97.1046 51 96 51H92C90.8954 51 90 50.1046 90 49V21Z" fill="currentColor"/> <path d="M20 9.75a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-7Z"/>
<path d="M80 39C78.8954 39 78 38.1046 78 37V33C78 31.8954 78.8954 31 80 31H108C109.105 31 110 31.8954 110 33V37C110 38.1046 109.105 39 108 39H80Z" fill="currentColor"/> </g>
</g> <defs>
<defs> <clipPath id="a">
<clipPath id="clip0_114_221"> <path d="M4 4h24v24H4z"/>
<rect width="96" height="96" fill="currentColor" transform="translate(16 16)"/> </clipPath>
</clipPath> </defs>
</defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1,017 B

After

Width:  |  Height:  |  Size: 975 B

16
src/img/icons/reload.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)">
<g clip-path="url(#b)">
<path d="M9.578 10.611c-.684-.574-.78-1.606-.104-2.188a10 10 0 1 1-3.385 8.906c-.119-.885.639-1.592 1.531-1.567.893.025 1.577.78 1.763 1.652a6.766 6.766 0 1 0 2.61-6.867c-.719.53-1.73.638-2.415.064Z"/>
<path d="M8.285 14.095a1 1 0 0 1-1.326-1.113l.902-5.117a1 1 0 0 1 1.628-.592l3.98 3.34a1 1 0 0 1-.3 1.705l-4.884 1.777Z"/>
</g>
</g>
<defs>
<clipPath id="a">
<path d="M0 0h32v32H0z"/>
</clipPath>
<clipPath id="b">
<path d="M17.48 32.906-.907 17.479 14.521-.906 32.906 14.52z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 692 B

View file

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path fill-rule="evenodd" d="M78 34H49c-13.255 0-24 10.745-24 24v11c0 13.255 10.745 24 24 24h53V58c0-13.255-10.745-24-24-24Zm-29-8c-17.673 0-32 14.327-32 32v11c0 17.673 14.327 32 32 32h61V58c0-17.673-14.327-32-32-32H49Z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M19.5 8.5h-7.25a6 6 0 0 0-6 6v2.75a6 6 0 0 0 6 6H25.5V14.5a6 6 0 0 0-6-6Zm-7.25-2a8 8 0 0 0-8 8v2.75a8 8 0 0 0 8 8H27.5V14.5a8 8 0 0 0-8-8h-7.25Z" clip-rule="evenodd"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 335 B

After

Width:  |  Height:  |  Size: 288 B

View file

@ -1,12 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)"> <g clip-path="url(#a)">
<path d="M30.57 39.698 16.057 64.835h29.026L30.57 39.698Zm43.572 57.816a2.514 2.514 0 1 0 0-5.028v5.028Zm-41.058-15.92V62.32h-5.028v19.272h5.028Zm10.893 15.92H74.14v-5.028H43.977v5.028Zm-15.92-15.92c0 8.792 7.127 15.92 15.92 15.92v-5.028c-6.016 0-10.893-4.877-10.893-10.893h-5.028Z"/> <path fill-rule="evenodd" d="m4.014 16.209 3.629-6.285 3.628 6.285h-3v4.19a2.723 2.723 0 0 0 2.723 2.723h7.541a.628.628 0 0 1 0 1.256h-7.54a3.98 3.98 0 0 1-3.98-3.98v-4.19h-3Z" clip-rule="evenodd"/>
<path d="M74.139 99.486a4.692 4.692 0 1 0 0-9.385v9.385Zm-30.162 0h30.162v-9.385H43.977v9.385Zm-8.715-18.099v-16.99h-9.384v16.99h9.384Zm8.715 8.714a8.714 8.714 0 0 1-8.715-8.714h-9.384c0 9.996 8.103 18.099 18.099 18.099v-9.385Zm52.453-.373 14.513-25.138H81.917L96.43 89.728ZM52.86 31.486a2.514 2.514 0 1 0 0 5.028v-5.028Zm41.055 15.92v19.698h5.028V47.407h-5.028Zm-10.893-15.92H52.861v5.028h30.162v-5.028Zm15.92 15.92c0-8.792-7.127-15.92-15.92-15.92v5.028c6.016 0 10.893 4.877 10.893 10.893h5.028Z"/> <path fill-rule="evenodd" d="M8.816 20.347c0 1.203.975 2.178 2.178 2.178h7.54a1.173 1.173 0 0 1 0 2.347h-7.54a4.525 4.525 0 0 1-4.525-4.525v-4.248h2.347v4.248Zm18.92-4.199-3.628 6.284-3.629-6.284h3v-4.296a2.723 2.723 0 0 0-2.723-2.724h-7.54a.628.628 0 0 1 0-1.256h7.54a3.98 3.98 0 0 1 3.98 3.98v4.296h3Z" clip-rule="evenodd"/>
<path d="M52.858 29.308a4.692 4.692 0 1 0 0 9.384v-9.384Zm30.165 0H52.86v9.384h30.164v-9.384Zm8.715 18.099V68h9.384V47.407h-9.384Zm-8.715-8.715a8.714 8.714 0 0 1 8.715 8.715h9.384c0-9.996-8.103-18.1-18.099-18.1v9.385Z"/> <path fill-rule="evenodd" d="M22.934 11.852a2.179 2.179 0 0 0-2.178-2.179h-7.541a1.173 1.173 0 1 1 0-2.346h7.54a4.525 4.525 0 0 1 4.526 4.525V17h-2.347v-5.148Z" clip-rule="evenodd"/>
</g> </g>
<defs> <defs>
<clipPath id="a"> <clipPath id="a">
<path d="M16 16h96v96H16z"/> <path d="M4 4h24v24H4z"/>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 931 B

View file

@ -1,11 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)"> <g clip-path="url(#a)">
<circle cx="53.5" cy="54.5" r="22.5"/> <path fill-rule="evenodd" d="M13.375 17.75a4.125 4.125 0 1 0 0-8.25 4.125 4.125 0 0 0 0 8.25Zm0 3a7.125 7.125 0 1 0 0-14.25 7.125 7.125 0 0 0 0 14.25Zm10.992 6-6-6 2.122-2.121 6 6a1.5 1.5 0 1 1-2.122 2.121Z" clip-rule="evenodd"/>
<path d="M97.47 107.001a6 6 0 0 0 8.485-8.485l-8.485 8.485Zm-24-24 24 24 8.485-8.485-24-24-8.485 8.485Z"/>
</g> </g>
<defs> <defs>
<clipPath id="a"> <clipPath id="a">
<path d="M16 16h96v96H16z"/> <path d="M4 4h24v24H4z"/>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 369 B

After

Width:  |  Height:  |  Size: 444 B

View file

@ -1,11 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)"> <g clip-path="url(#a)">
<path d="M107.742 41.773a26.944 26.944 0 0 1-2.808 16.212c-2.601 4.963-6.685 8.954-11.66 11.394a25.559 25.559 0 0 1-16.01 2.178c-5.428-1.025-10.396-3.785-14.183-7.88a26.72 26.72 0 0 1-6.88-14.894 26.931 26.931 0 0 1 3.076-16.162c2.683-4.917 6.832-8.837 11.846-11.19a25.553 25.553 0 0 1 16.045-1.902l-3.334 16.756a9.066 9.066 0 0 0-5.693.675 9.296 9.296 0 0 0-4.203 3.97 9.555 9.555 0 0 0-1.092 5.735 9.479 9.479 0 0 0 2.441 5.285 9.176 9.176 0 0 0 5.033 2.795 9.068 9.068 0 0 0 5.68-.773 9.304 9.304 0 0 0 4.137-4.042 9.558 9.558 0 0 0 .996-5.752l16.609-2.405Z"/> <path d="M26.935 10.443a6.736 6.736 0 0 1-.702 4.053 6.555 6.555 0 0 1-2.914 2.849 6.39 6.39 0 0 1-4.003.544 6.467 6.467 0 0 1-3.546-1.97 6.68 6.68 0 0 1-1.72-3.723 6.733 6.733 0 0 1 .77-4.04 6.55 6.55 0 0 1 2.96-2.798 6.388 6.388 0 0 1 4.012-.476l-.834 4.19c-.48-.1-.978-.04-1.423.168a2.324 2.324 0 0 0-1.05.993 2.389 2.389 0 0 0 .337 2.754 2.268 2.268 0 0 0 2.678.506c.441-.216.803-.57 1.034-1.01.231-.44.318-.944.25-1.439l4.151-.6Z"/>
</g> </g>
<rect width="25" height="75.773" x="67.609" y="43.703" rx="12.5" transform="rotate(46.112 67.61 43.703)"/> <path d="M14.65 13.092a3.125 3.125 0 1 1 4.333 4.505l-9.148 8.8a3.125 3.125 0 0 1-4.333-4.505l9.148-8.8Z"/>
<defs> <defs>
<clipPath id="a"> <clipPath id="a">
<path d="M16 16h96v96H16z"/> <path d="M4 4h24v24H4z"/>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 892 B

After

Width:  |  Height:  |  Size: 762 B

View file

@ -1,12 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 128 128"> <svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<g clip-path="url(#a)"> <g clip-path="url(#a)">
<rect width="73" height="25" x="27.5" y="16" rx="8"/> <path d="M6.875 6a2 2 0 0 1 2-2h14.25a2 2 0 0 1 2 2v2.25a2 2 0 0 1-2 2H8.875a2 2 0 0 1-2-2V6Zm0 7.75a2 2 0 0 1 2-2h14.25a2 2 0 0 1 2 2V20a2 2 0 0 1-2 2H8.875a2 2 0 0 1-2-2v-6.25Zm0 11.75a2 2 0 0 1 2-2h14.25a2 2 0 0 1 2 2v2.25a2 2 0 0 1-2 2H8.875a2 2 0 0 1-2-2V25.5Z"/>
<rect width="73" height="41" x="27.5" y="47" rx="8"/>
<rect width="73" height="25" x="27.5" y="94" rx="8"/>
</g> </g>
<defs> <defs>
<clipPath id="a"> <clipPath id="a">
<path d="M16 16h96v96H16z"/> <path d="M4 4h24v24H4z"/>
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 389 B

After

Width:  |  Height:  |  Size: 483 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path fill-rule="evenodd" d="M11 10a5 5 0 0 0-5 5v11a2 2 0 0 0 2 2h4v-6a4 4 0 0 1 8 0v6h4a2 2 0 0 0 2-2V15a5 5 0 0 0-5-5H11Z" clip-rule="evenodd"/>
<path d="M14.593 4.392a2 2 0 0 1 2.814 0l7.26 7.186c1.27 1.257.38 3.422-1.407 3.422H8.74c-1.787 0-2.677-2.165-1.407-3.421l7.26-7.187Z"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 32 32">
<path fill-rule="evenodd" d="M14.268 7c.77-1.333 2.694-1.333 3.464 0l8.66 15c.77 1.333-.192 3-1.732 3H7.34c-1.54 0-2.502-1.667-1.732-3l8.66-15Zm3.805 14.035c.041 1.124-.82 2.068-1.924 2.108-1.104.041-2.032-.837-2.074-1.96-.041-1.124.82-2.068 1.924-2.11 1.104-.04 2.032.838 2.074 1.962ZM16 10a2 2 0 0 0-2 2v4a2 2 0 0 0 4 0v-4a2 2 0 0 0-2-2Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 453 B

52
src/lib/account.js Normal file
View file

@ -0,0 +1,52 @@
import { server } from '$lib/client/server.js';
import { parseEmoji, renderEmoji } from '$lib/emoji.js';
import { get, writable } from 'svelte/store';
const cache = writable({});
/**
* Parses an account using API data, and returns a writable store object.
* @param {Object} data
* @param {number} ancestor_count
*/
export function parseAccount(data) {
if (!data) {
console.error("Attempted to parse account data but no data was provided");
return null;
}
let account = get(cache)[data.id];
if (account) return account;
// cache miss!
account = {};
account.id = data.id;
account.nickname = data.display_name.trim();
account.username = data.username;
account.name = account.nickname || account.username;
account.avatar_url = data.avatar;
account.url = data.url;
if (data.acct.includes('@'))
account.host = data.acct.split('@')[1];
else
account.host = get(server).host;
account.mention = "@" + account.username;
if (account.host != get(server).host)
account.mention += "@" + account.host;
account.emojis = {};
data.emojis.forEach(emoji => {
account.emojis[emoji.shortcode] = parseEmoji(emoji.shortcode, emoji.url);
});
account.rich_name = account.nickname ? renderEmoji(account.nickname, account.emojis) : account.username;
cache.update(cache => {
cache[account.id] = account;
return cache;
});
return account;
}

422
src/lib/api.js Normal file
View file

@ -0,0 +1,422 @@
/**
* GET /api/v1/instance
* @param {string} host - The domain of the target server.
*/
export async function getInstance(host) {
const data = await fetch(`https://${host}/api/v1/instance`)
.then(res => res.json())
.catch(error => console.error(error));
return data ? data : false;
}
/**
* POST /api/v1/apps
* Attempts to create an application for a given server host.
* @param {string} host - The domain of the target server.
*/
export async function createApp(host) {
let form = new FormData();
form.append("client_name", "Campfire");
form.append("redirect_uris", `${location.origin}/callback`);
form.append("scopes", "read write push");
form.append("website", "https://campfire.bliss.town");
const res = await fetch(`https://${host}/api/v1/apps`, {
method: "POST",
body: form,
})
.then(res => res.json())
.catch(error => {
console.error(error);
return false;
});
if (!res || !res.client_id) return false;
return {
id: res.client_id,
secret: res.client_secret,
};
}
/**
* Returns the OAuth authorization url for the target server.
* @param {string} host - The domain of the target server.
* @param {string} app_id - The application id for the target server.
*/
export function getOAuthUrl(host, app_id) {
return `https://${host}/oauth/authorize` +
`?client_id=${app_id}` +
"&scope=read+write+push" +
`&redirect_uri=${location.origin}/callback` +
"&response_type=code";
}
/**
* POST /oauth/token
* Attempts to generate an OAuth token.
* Returns false on failure.
* @param {string} host - The domain of the target server.
* @param {string} client_id - The application id.
* @param {string} secret - The application secret.
* @param {string} code - The authorization code provided by OAuth.
*/
export async function getToken(host, client_id, secret, code) {
let form = new FormData();
form.append("client_id", client_id);
form.append("client_secret", secret);
form.append("redirect_uri", `${location.origin}/callback`);
form.append("grant_type", "authorization_code");
form.append("code", code);
form.append("scope", "read write push");
const res = await fetch(`https://${host}/oauth/token`, {
method: "POST",
body: form,
})
.then(res => res.json())
.catch(error => {
console.error(error);
return false;
});
if (!res || !res.access_token) return false;
return res.access_token;
}
/**
* POST /oauth/revoke
* Attempts to revoke an OAuth token.
* Returns false on failure.
* @param {string} host - The domain of the target server.
* @param {string} client_id - The application id.
* @param {string} secret - The application secret.
* @param {string} token - The application token.
*/
export async function revokeToken(host, client_id, secret, token) {
let form = new FormData();
form.append("client_id", client_id);
form.append("client_secret", secret);
form.append("token", token);
const res = await fetch(`https://${host}/oauth/revoke`, {
method: "POST",
body: form,
})
.catch(error => {
console.error(error);
return false;
});
if (!res.ok) return false;
return true;
}
/**
* GET /api/v1/accounts/verify_credentials
* This endpoint returns information about the client account,
* and other useful data.
* Returns false on failure.
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
*/
export async function verifyCredentials(host, token) {
let url = `https://${host}/api/v1/accounts/verify_credentials`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + token }
}).then(res => res.json());
return data;
}
/**
* GET /api/v1/streaming/health
* Checks if the server's streaming service is alive
*/
export async function getStreamingHealth(host) {
let url = `https://${host}/api/v1/streaming/health`;
const res = await fetch(url, {
method: 'GET'
});
return res.ok;
}
/**
* GET /api/v1/notifications
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} min_id - If provided, only shows notifications after this ID.
* @param {string} limit - The maximum number of notifications to retrieve (default 40).
* @param {string} types - A list of notification types to filter to.
*/
export async function getNotifications(host, token, min_id, max_id, limit, types) {
let url = `https://${host}/api/v1/notifications`;
let params = new URLSearchParams();
if (min_id) params.append("min_id", min_id);
if (max_id) params.append("max_id", max_id);
if (limit) params.append("limit", limit);
if (types) params.append("types", types.join(','));
const params_string = params.toString();
if (params_string) url += '?' + params_string;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + token }
}).then(res => res.json());
return data;
}
/**
* GET /api/v1/timelines/{timeline}
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} timeline - The name of the timeline to pull (default "home").
* @param {string} max_id - If provided, only shows posts after this ID.
*/
export async function getTimeline(host, token, timeline, max_id) {
let url = `https://${host}/api/v1/timelines/${timeline || "home"}`;
let params = new URLSearchParams();
if (max_id) params.append("max_id", max_id);
const params_string = params.toString();
if (params_string) url += '?' + params_string;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": token ? `Bearer ${token}` : null }
}).then(res => res.json());
return data;
}
/**
* GET /api/v1/statuses/{post_id}.
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} post_id - The ID of the post to fetch.
*/
export async function getPost(host, token, post_id) {
let url = `https://${host}/api/v1/statuses/${post_id}`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": token ? `Bearer ${token}` : null }
}).then(res => res.json())
if (!data || data.error) return false;
return data;
}
/**
* POST /api/v1/statuses
* @param {string} host - The domain of the target server.
* @param {string} token - The application token
* @param {any} post_data - The post content
*/
export async function createPost(host, token, post_data) {
let formdata = new FormData();
for (const key in post_data) {
formdata.append(key, post_data[key]);
}
let url = `https://${host}/api/v1/statuses`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": `Bearer ${token}` },
body: formdata
})
return await data.json();
}
/**
* PUT /api/v1/statuses/{post_id}
* @param {string} host - The domain of the target server.
* @param {string} token - The application token
* @param {any} post_id - The ID of the post to edit.
* @param {any} post_data - The post content
*/
export async function editPost(host, token, post_id, post_data) {
let formdata = new FormData();
for (const key in post_data) {
formdata.append(key, post_data[key]);
}
let url = `https://${host}/api/v1/statuses/${post_id}`;
const data = await fetch(url, {
method: 'PUT',
headers: { "Authorization": `Bearer ${token}` },
body: formdata
})
return await data.json();
}
/**
* DELETE /api/v1/statuses/{post_id}
* Returns the deleted post's data, in the case of republishing.
* @param {string} host - The domain of the target server.
* @param {string} token - The application token
* @param {any} post_id - The ID of the post to delete.
*/
export async function deletePost(host, token, post_id) {
let url = `https://${host}/api/v1/statuses/${post_id}`;
const data = await fetch(url, {
method: 'DELETE',
headers: { "Authorization": `Bearer ${token}` },
})
return await data.json();
}
/**
* GET /api/v1/statuses/{post_id}/context.
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} post_id - The ID of the post to fetch.
*/
export async function getPostContext(host, token, post_id) {
let url = `https://${host}/api/v1/statuses/${post_id}/context`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": token ? `Bearer ${token}` : null }
}).then(res => res.json());
return data;
}
/**
* POST /api/v1/statuses/{post_id}/reblog.
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} post_id - The ID of the post to boost.
* @param {string} visibility - The visibility with which to boost the post.
*/
export async function boostPost(host, token, post_id, visibility) {
let url = `https://${host}/api/v1/statuses/${post_id}/reblog`;
let form = new FormData();
if (visibility) form.append("visibility", visibility);
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": `Bearer ${token}` },
body: form,
}).then(res => res.json());
return data;
}
/**
* POST /api/v1/statuses/{post_id}/unreblog.
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} post_id - The ID of the post to unboost.
*/
export async function unboostPost(host, token, post_id) {
let url = `https://${host}/api/v1/statuses/${post_id}/unreblog`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": `Bearer ${token}` }
}).then(res => res.json());
return data;
}
/**
* POST /api/v1/statuses/{post_id}/favourite.
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} post_id - The ID of the post to favourite.
*/
export async function favouritePost(host, token, post_id) {
let url = `https://${host}/api/v1/statuses/${post_id}/favourite`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": `Bearer ${token}` }
}).then(res => res.json());
return data;
}
/**
* POST /api/v1/statuses/{post_id}/unfavourite.
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} post_id - The ID of the post to unfavourite.
*/
export async function unfavouritePost(host, token, post_id) {
let url = `https://${host}/api/v1/statuses/${post_id}/unfavourite`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": `Bearer ${token}` }
}).then(res => res.json());
return data;
}
/**
* POST /api/v1/statuses/{post_id}/react/{shortcode}
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} post_id - The ID of the post to favourite.
* @param {string} shortcode - The shortcode of the emote to react with.
*/
export async function reactPost(host, token, post_id, shortcode) {
// note: reacting with foreign emotes is unsupported on most servers
// chuckya appears to allow this, but other servers tested have
// not demonstrated this.
let url = `https://${host}/api/v1/statuses/${post_id}/react/${encodeURIComponent(shortcode)}`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": `Bearer ${token}` }
}).then(res => res.json());
return data;
}
/**
* POST /api/v1/statuses/{post_id}/unreact/{shortcode}
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} post_id - The ID of the post to favourite.
* @param {string} shortcode - The shortcode of the reaction emote to remove.
*/
export async function unreactPost(host, token, post_id, shortcode) {
let url = `https://${host}/api/v1/statuses/${post_id}/unreact/${encodeURIComponent(shortcode)}`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": `Bearer ${token}` }
}).then(res => res.json());
return data;
}
/**
* GET /api/v1/accounts/{user_id}
* @param {string} host - The domain of the target server.
* @param {string} token - The application token.
* @param {string} user_id - The ID of the user to fetch.
*/
export async function getUser(host, token, user_id) {
let url = `https://${host}/api/v1/accounts/${user_id}`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": token ? `Bearer ${token}` : null }
}).then(res => res.json());
return data;
}

View file

@ -74,6 +74,11 @@ main {
width: 732px; width: 732px;
} }
img.emoji {
height: 1.2em;
margin: -.2em 0;
}
.throb { .throb {
animation: .25s throb alternate infinite ease-in; animation: .25s throb alternate infinite ease-in;
} }

View file

@ -1,339 +0,0 @@
import { client } from '$lib/client/client.js';
import { user } from '$lib/stores/user.js';
import { capabilities } from '../client/instance.js';
import Post from '$lib/post.js';
import User from '$lib/user/user.js';
import Emoji from '$lib/emoji.js';
import { get } from 'svelte/store';
export async function createApp(host) {
let form = new FormData();
form.append("client_name", "Campfire");
form.append("redirect_uris", `${location.origin}/callback`);
form.append("scopes", "read write push");
form.append("website", "https://campfire.bliss.town");
const res = await fetch(`https://${host}/api/v1/apps`, {
method: "POST",
body: form,
})
.then(res => res.json())
.catch(error => {
console.error(error);
return false;
});
if (!res || !res.client_id) return false;
return {
id: res.client_id,
secret: res.client_secret,
};
}
export function getOAuthUrl() {
return `https://${get(client).instance.host}/oauth/authorize` +
`?client_id=${get(client).app.id}` +
"&scope=read+write+push" +
`&redirect_uri=${location.origin}/callback` +
"&response_type=code";
}
export async function getToken(code) {
let form = new FormData();
form.append("client_id", get(client).app.id);
form.append("client_secret", get(client).app.secret);
form.append("redirect_uri", `${location.origin}/callback`);
form.append("grant_type", "authorization_code");
form.append("code", code);
form.append("scope", "read write push");
const res = await fetch(`https://${get(client).instance.host}/oauth/token`, {
method: "POST",
body: form,
})
.then(res => res.json())
.catch(error => {
console.error(error);
return false;
});
if (!res || !res.access_token) return false;
return res.access_token;
}
export async function revokeToken() {
let form = new FormData();
form.append("client_id", get(client).app.id);
form.append("client_secret", get(client).app.secret);
form.append("token", get(client).app.token);
const res = await fetch(`https://${get(client).instance.host}/oauth/revoke`, {
method: "POST",
body: form,
})
.catch(error => {
console.error(error);
return false;
});
if (!res.ok) return false;
return true;
}
export async function verifyCredentials() {
let url = `https://${get(client).instance.host}/api/v1/accounts/verify_credentials`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => res.json());
return data;
}
export async function getNotifications(since_id, limit, types) {
if (!get(user)) return false;
let url = `https://${get(client).instance.host}/api/v1/notifications`;
let params = new URLSearchParams();
if (since_id) params.append("since_id", since_id);
if (limit) params.append("limit", limit);
if (types) params.append("types", types.join(','));
const params_string = params.toString();
if (params_string) url += '?' + params_string;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => res.json());
return data;
}
export async function getTimeline(last_post_id) {
if (!get(user)) return false;
let url = `https://${get(client).instance.host}/api/v1/timelines/home`;
if (last_post_id) url += "?max_id=" + last_post_id;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => res.json());
return data;
}
export async function getPost(post_id, ancestor_count) {
let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function getPostContext(post_id) {
let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/context`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function boostPost(post_id) {
let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/reblog`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function unboostPost(post_id) {
let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unreblog`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function favouritePost(post_id) {
let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/favourite`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function unfavouritePost(post_id) {
let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unfavourite`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function reactPost(post_id, shortcode) {
// for whatever reason (at least in my testing on iceshrimp)
// using shortcodes for external emoji results in a fallback
// to the default like emote.
// identical api calls on chuckya instances do not display
// this behaviour.
let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/react/${encodeURIComponent(shortcode)}`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function unreactPost(post_id, shortcode) {
let url = `https://${get(client).instance.host}/api/v1/statuses/${post_id}/unreact/${encodeURIComponent(shortcode)}`;
const data = await fetch(url, {
method: 'POST',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => { return res.ok ? res.json() : false });
if (data === false) return false;
return data;
}
export async function parsePost(data, ancestor_count) {
let post = new Post();
post.text = data.content;
post.html = data.content;
post.reply = null;
if ((data.in_reply_to_id || data.reply) &&
ancestor_count !== 0
) {
const reply_data = data.reply || await getPost(data.in_reply_to_id, ancestor_count - 1);
// if the post returns false, we probably don't have permission to read it.
// we'll respect the thread's privacy, and leave it alone :)
if (!reply_data) return false;
post.reply = await parsePost(reply_data, ancestor_count - 1, false);
}
post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null;
post.id = data.id;
post.created_at = new Date(data.created_at);
post.user = await parseUser(data.account);
post.warning = data.spoiler_text;
post.boost_count = data.reblogs_count;
post.reply_count = data.replies_count;
post.favourite_count = data.favourites_count;
post.favourited = data.favourited;
post.boosted = data.reblogged;
post.mentions = data.mentions;
post.files = data.media_attachments;
post.url = data.url;
post.visibility = data.visibility;
post.emojis = [];
if (data.emojis) {
data.emojis.forEach(emoji_data => {
let name = emoji_data.shortcode.split('@')[0];
post.emojis.push(parseEmoji({
id: name + '@' + post.user.host,
name: name,
host: post.user.host,
url: emoji_data.url,
}));
});
}
if (data.reactions && get(client).instance.capabilities.includes(capabilities.REACTIONS)) {
post.reactions = parseReactions(data.reactions);
}
return post;
}
export async function parseUser(data) {
if (!data) {
console.error("Attempted to parse user data but no data was provided");
return null;
}
let user = await get(client).getCacheUser(data.id);
if (user) return user;
// cache miss!
user = new User();
user.id = data.id;
user.nickname = data.display_name.trim();
user.username = data.username;
user.avatar_url = data.avatar;
user.url = data.url;
if (data.acct.includes('@'))
user.host = data.acct.split('@')[1];
else
user.host = get(client).instance.host;
user.emojis = [];
data.emojis.forEach(emoji_data => {
emoji_data.id = emoji_data.shortcode + '@' + user.host;
emoji_data.name = emoji_data.shortcode;
emoji_data.host = user.host;
user.emojis.push(parseEmoji(emoji_data));
});
get(client).putCacheUser(user);
return user;
}
export function parseReactions(data) {
let reactions = [];
data.forEach(reaction_data => {
let reaction = {
count: reaction_data.count,
name: reaction_data.name,
me: reaction_data.me,
};
if (reaction_data.url) reaction.url = reaction_data.url;
reactions.push(reaction);
});
return reactions;
}
export function parseEmoji(data) {
let emoji = new Emoji(
data.id,
data.name,
data.host,
data.url,
);
get(client).putCacheEmoji(emoji);
return emoji;
}
export async function getUser(user_id) {
let url = `https://${get(client).instance.host}/api/v1/accounts/${user_id}`;
const data = await fetch(url, {
method: 'GET',
headers: { "Authorization": "Bearer " + get(client).app.token }
}).then(res => res.json());
return data;
}

37
src/lib/client/app.js Normal file
View file

@ -0,0 +1,37 @@
import { writable } from 'svelte/store';
import { app_name } from '$lib/config.js';
import { browser } from "$app/environment";
// if app is falsy, assume user has not begun the login process.
// if app.token is falsy, assume user has not logged in.
export const app = writable(loadApp());
// write to localStorage on each update
app.subscribe(app => {
saveApp(app);
});
/**
* Saves the provided app to localStorage.
* If `app` is falsy, data is removed from localStorage.
* @param {Object} app
*/
function saveApp(app) {
if (!browser) return;
if (!app) {
localStorage.removeItem(app_name + "_app");
return;
}
localStorage.setItem(app_name + "_app", JSON.stringify(app));
}
/**
* Returns application data loaded from localStorage, if it exists.
* Otherwise, returns false.
*/
function loadApp() {
if (!browser) return;
let data = localStorage.getItem(app_name + "_app");
if (!data) return false;
return JSON.parse(data);
}

View file

@ -1,192 +0,0 @@
import { Instance, server_types } from './instance.js';
import * as api from './api.js';
import { get, writable } from 'svelte/store';
import { last_read_notif_id } from '$lib/notifications.js';
import { user, logged_in } from '$lib/stores/user.js';
export const client = writable(false);
const save_name = "campfire";
export class Client {
instance;
app;
#cache;
constructor() {
this.instance = null;
this.app = null;
this.cache = {
users: {},
emojis: {},
};
}
async init(host) {
if (host.startsWith("https://")) host = host.substring(8);
const url = `https://${host}/api/v1/instance`;
const data = await fetch(url).then(res => res.json()).catch(error => { console.error(error) });
if (!data) {
console.error(`Failed to connect to ${host}`);
return `Failed to connect to ${host}!`;
}
this.instance = new Instance(host, data.version);
if (this.instance.type == server_types.UNSUPPORTED) {
console.warn(`Server ${host} is unsupported - ${data.version}`);
if (!confirm(
`This app does not officially support ${host}. ` +
`Things may break, or otherwise not work as epxected! ` +
`Are you sure you wish to continue?`
)) return false;
} else {
console.log(`Server is "${this.instance.type}" (or compatible) with capabilities: [${this.instance.capabilities}].`);
}
this.app = await api.createApp(host);
if (!this.app || !this.instance) {
console.error("Failed to create app. Check the network logs for details.");
return false;
}
this.save();
client.set(this);
return true;
}
getOAuthUrl() {
return api.getOAuthUrl(this.app.secret);
}
async getToken(code) {
const token = await api.getToken(code);
if (!token) {
console.error("Failed to obtain access token");
return false;
}
return token;
}
async revokeToken() {
return await api.revokeToken();
}
async getNotifications(since_id, limit, types) {
return await api.getNotifications(since_id, limit, types);
}
async getTimeline(last_post_id) {
return await api.getTimeline(last_post_id);
}
async getPost(post_id, parent_replies, child_replies) {
return await api.getPost(post_id, parent_replies, child_replies);
}
async getPostContext(post_id) {
return await api.getPostContext(post_id);
}
async boostPost(post_id) {
return await api.boostPost(post_id);
}
async unboostPost(post_id) {
return await api.unboostPost(post_id);
}
async favouritePost(post_id) {
return await api.favouritePost(post_id);
}
async unfavouritePost(post_id) {
return await api.unfavouritePost(post_id);
}
async reactPost(post_id, shortcode) {
return await api.reactPost(post_id, shortcode);
}
async unreactPost(post_id, shortcode) {
return await api.unreactPost(post_id, shortcode);
}
putCacheUser(user) {
this.cache.users[user.id] = user;
client.set(this);
}
async getCacheUser(user_id) {
let user = this.cache.users[user_id];
if (user) return user;
return false;
}
async getUserByMention(mention) {
let users = Object.values(this.cache.users);
for (let i in users) {
const user = users[i];
if (user.mention == mention) return user;
}
return false;
}
putCacheEmoji(emoji) {
this.cache.emojis[emoji.id] = emoji;
client.set(this);
}
getEmoji(emoji_id) {
let emoji = this.cache.emojis[emoji_id];
if (!emoji) return false;
return emoji;
}
async getUser(user_id) {
return await api.getUser(user_id);
}
save() {
if (typeof localStorage === typeof undefined) return;
localStorage.setItem(save_name, JSON.stringify({
version: APP_VERSION,
instance: {
host: this.instance.host,
version: this.instance.version,
},
last_read_notif_id: get(last_read_notif_id),
app: this.app,
}));
}
load() {
if (typeof localStorage === typeof undefined) return;
let json = localStorage.getItem(save_name);
if (!json) return false;
let saved = JSON.parse(json);
if (!saved.version || saved.version !== APP_VERSION) {
localStorage.removeItem(save_name);
return false;
}
this.instance = new Instance(saved.instance.host, saved.instance.version);
last_read_notif_id.set(saved.last_read_notif_id || 0);
this.app = saved.app;
client.set(this);
return true;
}
async logout() {
if (!this.instance || !this.app) return;
if (!await this.revokeToken()) {
console.warn("Failed to log out correctly; ditching the old tokens anyways.");
}
localStorage.removeItem(save_name);
logged_in.set(false);
client.set(new Client());
console.log("Logged out successfully.");
}
}

View file

@ -1,70 +0,0 @@
export const server_types = {
UNSUPPORTED: "unsupported",
MASTODON: "mastodon",
GLITCHSOC: "glitchsoc",
CHUCKYA: "chuckya",
FIREFISH: "firefish",
ICESHRIMP: "iceshrimp",
SHARKEY: "sharkey",
};
export const capabilities = {
MARKDOWN_CONTENT: "mdcontent",
REACTIONS: "reactions",
};
export class Instance {
host;
version;
capabilities;
type = server_types.UNSUPPORTED;
constructor(host, version) {
this.host = host;
this.version = version;
this.#setType(version);
this.capabilities = this.#getCapabilities(this.type);
}
#setType(version) {
this.type = server_types.UNSUPPORTED;
if (version.constructor !== String) return;
let version_lower = version.toLowerCase();
for (let i = 1; i < Object.keys(server_types).length; i++) {
const check_type = Object.values(server_types)[i];
if (version_lower.includes(check_type)) {
this.type = check_type;
return;
}
}
}
#getCapabilities(type) {
let c = [];
switch (type) {
case server_types.MASTODON:
break;
case server_types.GLITCHSOC:
c.push(capabilities.REACTIONS);
break;
case server_types.CHUCKYA:
c.push(capabilities.REACTIONS);
break;
case server_types.FIREFISH:
c.push(capabilities.REACTIONS);
break;
case server_types.ICESHRIMP:
// more trouble than it's worth atm
// the server already hands this to us ;p
//c.push(capabilities.MARKDOWN_CONTENT);
c.push(capabilities.REACTIONS);
break;
case server_types.SHARKEY:
c.push(capabilities.REACTIONS);
break;
default:
break;
}
return c;
}
}

143
src/lib/client/server.js Normal file
View file

@ -0,0 +1,143 @@
import * as api from '$lib/api.js';
import { writable } from 'svelte/store';
import { app_name } from '$lib/config.js';
import { browser } from "$app/environment";
const server_types = {
UNSUPPORTED: "unsupported",
MASTODON: "mastodon",
GLITCHSOC: "glitchsoc",
CHUCKYA: "chuckya",
FIREFISH: "firefish",
ICESHRIMP: "iceshrimp",
SHARKEY: "sharkey",
AKKOMA: "akkoma", // TODO: verify
PLEROMA: "pleroma", // TODO: verify
};
export const capabilities = {
MARKDOWN_CONTENT: "markdown_content",
REACTIONS: "reactions",
FOREIGN_REACTIONS: "foreign_reactions",
};
// if server is falsy, assume user has not begun the login process.
export let server = writable(loadServer());
// write to localStorage on each update
server.subscribe(server => {
saveServer(server);
});
/**
* Attempts to create an server object using a given hostname.
* @param {string} host - The domain of the target server.
*/
export async function createServer(host) {
if (!host) {
console.error("Attempted to create server without providing a hostname");
return false;
}
if (host.startsWith("http://")) {
console.error("Cowardly refusing to connect to an insecure server");
return false;
}
let server = {};
server.host = host;
if (host.startsWith("https://")) host = host.substring(8);
const data = await api.getInstance(host);
if (!data) {
console.error(`Failed to connect to ${host}`);
return false;
}
server.version = data.version;
server.type = getType(server.version);
server.capabilities = getCapabilities(server.type);
if (server.type === server_types.UNSUPPORTED) {
console.warn(`Server ${host} is unsupported (${server.version}). Things may break, or not work as expected`);
} else {
console.log(`Server detected as "${server.type}" (${server.version}) with capabilities: {${server.capabilities.join(', ')}}`);
}
return server;
}
/**
* Saves the provided server to localStorage.
* If `server` is falsy, data is removed from localStorage.
* @param {Object} server
*/
function saveServer(server) {
if (!browser) return;
if (!server) {
localStorage.removeItem(app_name + "_server");
return;
}
localStorage.setItem(app_name + "_server", JSON.stringify(server));
}
/**
* Returns server data loaded from localStorage, if it exists.
* Otherwise, returns false.
*/
function loadServer() {
if (!browser) return;
let data = localStorage.getItem(app_name + "_server");
if (!data) return false;
return JSON.parse(data);
}
/**
* Returns the type of an server, inferred from its version string.
* @param {string} version
* @returns the inferred server_type
*/
function getType(version) {
if (version.constructor !== String) return;
let version_lower = version.toLowerCase();
for (let i = 1; i < Object.keys(server_types).length; i++) {
const type = Object.values(server_types)[i];
if (version_lower.includes(type)) {
return type;
}
}
return server_types.UNSUPPORTED;
}
/**
* Returns a list of capabilities for a given server_type.
* @param {string} type
*/
function getCapabilities(type) {
let c = [];
switch (type) {
case server_types.MASTODON:
break;
case server_types.GLITCHSOC:
c.push(capabilities.REACTIONS);
break;
case server_types.CHUCKYA:
c.push(capabilities.REACTIONS);
c.push(capabilities.FOREIGN_REACTIONS);
break;
case server_types.FIREFISH:
c.push(capabilities.REACTIONS);
break;
case server_types.ICESHRIMP:
// more trouble than it's worth atm
// mastodon API already hands html to us
//c.push(capabilities.MARKDOWN_CONTENT);
c.push(capabilities.REACTIONS);
break;
case server_types.SHARKEY:
c.push(capabilities.REACTIONS);
break;
default:
break;
}
return c;
}

1
src/lib/config.js Normal file
View file

@ -0,0 +1 @@
export const app_name = "campfire";

View file

@ -1,52 +1,27 @@
import { client } from './client/client.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
export const EMOJI_REGEX = /:[\w\-.]{0,32}:/g;
export const EMOJI_REGEX = /:[\w\-.]{0,32}@[\w\-.]{0,32}:/g; export function parseEmoji(shortcode, url) {
export const EMOJI_NAME_REGEX = /:[\w\-.]{0,32}:/g; let emoji = { shortcode, url };
if (emoji.shortcode == '❤') emoji.shortcode = '❤️'; // stupid heart unicode
export default class Emoji { emoji.html = `<img src="${emoji.url}" class="emoji" height="20" title="${emoji.shortcode}" alt="${emoji.shortcode}"/>`;
name; return emoji;
url;
constructor(id, name, host, url) {
this.id = id;
this.name = name;
this.host = host;
this.url = url;
}
get html() {
if (this.url)
return `<img src="${this.url}" class="emoji" height="20" title="${this.name}" alt="${this.name}">`;
else
return `${this.name}`;
}
} }
export function parseText(text, host) { export function renderEmoji(text, emoji_list) {
if (!text) return text; if (!text) return text;
let index = text.search(EMOJI_NAME_REGEX); let index = text.search(EMOJI_REGEX);
if (index === -1) return text; if (index === -1) return text;
// find the emoji name // find the closing comma
let length = text.substring(index + 1).search(':'); let length = text.substring(index + 1).search(':');
if (length <= 0) return text; if (length <= 0) return text;
let emoji_name = text.substring(index + 1, index + length + 1);
let emoji = get(client).getEmoji(emoji_name + '@' + host);
if (emoji) { // see if emoji is valid
return text.substring(0, index) + emoji.html + let shortcode = text.substring(index + 1, index + length + 1);
parseText(text.substring(index + length + 2), host); let emoji = emoji_list[shortcode];
} let replace = emoji ? emoji.html : shortcode;
return text.substring(0, index + length + 1) +
parseText(text.substring(index + length + 1), host);
}
export function parseOne(emoji_id) { return text.substring(0, index) + replace + renderEmoji(text.substring(index + length + 2), emoji_list);
if (emoji_id == '❤') return '❤️'; // stupid heart unicode
if (EMOJI_REGEX.exec(':' + emoji_id + ':')) return emoji_id;
let cached_emoji = get(client).getEmoji(emoji_id);
if (!cached_emoji) return emoji_id;
return cached_emoji.html;
} }

View file

@ -1,22 +1,67 @@
import { client } from '$lib/client/client.js'; import * as api from '$lib/api.js';
import * as api from '$lib/client/api.js'; import { server } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { app_name } from '$lib/config.js';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { browser } from '$app/environment';
import { parsePost } from '$lib/post.js';
import { parseAccount } from '$lib/account.js';
export let notifications = writable([]); const prefix = app_name + '_notif_';
export let unread_notif_count = writable(0);
export let last_read_notif_id = writable(0); export const notifications = writable([]);
export const unread_notif_count = writable(load("unread_count"));
export const last_read_notif_id = writable(load("last_read"));
unread_notif_count.subscribe(count => save("unread_count", count));
last_read_notif_id.subscribe(id => save("last_read", id));
/**
* Saves the provided data to localStorage.
* If `data` is falsy, the record is removed from localStorage.
* @param {Object} name
* @param {any} data
*/
function save(name, data) {
if (!browser) return;
if (data) {
localStorage.setItem(prefix + name, data);
} else {
localStorage.removeItem(prefix + name);
}
}
/**
* Returns named data loaded from localStorage, if it exists.
* Otherwise, returns false.
*/
function load(name) {
if (!browser) return;
let data = localStorage.getItem(prefix + name);
return data ? data : false;
}
let loading; let loading;
export async function getNotifications() { export async function getNotifications(min_id, max_id) {
if (loading) return; // no spamming!! if (loading) return; // no spamming!!
loading = true; loading = true;
api.getNotifications().then(async data => { const notif_data = await api.getNotifications(
if (!data || data.length <= 0) return; get(server).host,
notifications.set([]); get(app).token,
for (let i in data) { min_id,
let notif = data[i]; max_id,
notif.accounts = [ await api.parseUser(notif.account) ]; );
if (!notif_data) {
console.error(`Failed to retrieve notifications.`);
loading = false;
return;
}
for (let i in notif_data) {
let notif = notif_data[i];
notif.accounts = [ await parseAccount(notif.account) ];
if (get(notifications).length > 0) { if (get(notifications).length > 0) {
let prev = get(notifications)[get(notifications).length - 1]; let prev = get(notifications)[get(notifications).length - 1];
if (notif.type === prev.type) { if (notif.type === prev.type) {
@ -29,12 +74,8 @@ export async function getNotifications() {
} }
} }
} }
notif.status = notif.status ? await api.parsePost(notif.status, 0, false) : null; notif.status = notif.status ? await parsePost(notif.status, 0, false) : null;
notifications.update(notifications => [...notifications, notif]); notifications.update(notifications => [...notifications, notif]);
} }
last_read_notif_id.set(data[0].id);
unread_notif_count.set(0);
get(client).save();
loading = false; loading = false;
});
} }

View file

@ -1,177 +1,84 @@
import { parseText as parseEmoji } from './emoji.js'; import * as api from '$lib/api.js';
import { server } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { parseAccount } from '$lib/account.js';
import { parseEmoji, renderEmoji } from '$lib/emoji.js';
import { get, writable } from 'svelte/store';
export default class Post { const cache = writable({});
id;
created_at;
user;
text;
warning;
boost_count;
reply_count;
favourite_count;
favourited;
boosted;
mentions;
reactions;
emojis;
files;
url;
reply;
reply_id;
replies;
boost;
visibility;
async rich_text() { /**
return parseEmoji(this.text, this.user.host); * Parses a post using API data, and returns a writable store object.
} * @param {Object} data
* @param {number} ancestor_count
/*
async rich_text() {
let text = this.text;
if (!text) return text;
let client = Client.get();
const markdown_tokens = [
{ tag: "pre", token: "```" },
{ tag: "code", token: "`" },
{ tag: "strong", token: "**" },
{ tag: "strong", token: "__" },
{ tag: "em", token: "*" },
{ tag: "em", token: "_" },
];
let response = "";
let md_layer;
let index = 0;
while (index < text.length) {
let sample = text.substring(index);
let md_nostack = !(md_layer && md_layer.nostack);
// handle newlines
if (md_nostack && sample.startsWith('\n')) {
response += "<br>";
index++;
continue;
}
// handle mentions
if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT)
&& md_nostack
&& sample.match(/^@[\w\-.]+@[\w\-.]+/g)
) {
// find end of the mention
let length = 1;
while (index + length < text.length && /[a-z0-9-_.]/.test(text[index + length])) length++;
length++; // skim the middle @
while (index + length < text.length && /[a-z0-9-_.]/.test(text[index + length])) length++;
let mention = text.substring(index, index + length);
// attempt to resolve mention to a user
let user = await client.getUserByMention(mention);
if (user) {
const out = `<a href="/${user.mention}" class="mention">` +
`<img src="${user.avatar_url}" class="mention-avatar" width="20" height="20">` +
'@' + user.username + '@' + user.host + "</a>";
if (md_layer) md_layer.text += out;
else response += out;
} else {
response += mention;
}
index += mention.length;
continue;
}
// handle links
if (client.instance.capabilities.includes(capabilities.MARKDOWN_CONTENT)
&& md_nostack
&& sample.match(/^[a-z]{3,6}:\/\/[^\s]+/g)
) {
// get length of link
let length = text.substring(index).search(/\s|$/g);
let url = text.substring(index, index + length);
let out = `<a href="${url}">${url}</a>`;
if (md_layer) md_layer.text += out;
else response += out;
index += length;
continue;
}
// handle emojis
if (md_nostack && sample.match(/^:[\w\-.]{0,32}:/g)) {
// find the emoji name
let length = text.substring(index + 1).search(':');
if (length <= 0) return text;
let emoji_name = text.substring(index + 1, index + length + 1);
let emoji = client.getEmoji(emoji_name + '@' + this.user.host);
index += length + 2;
if (!emoji) {
let out = ':' + emoji_name + ':';
if (md_layer) md_layer.text += out;
else response += out;
continue;
}
let out = emoji.html;
if (md_layer) md_layer.text += out;
else response += out;
continue;
}
// handle markdown
// TODO: handle misskey-flavoured markdown(?)
if (md_layer) {
// try to pop layer
if (sample.startsWith(md_layer.token)) {
index += md_layer.token.length;
let out = `<${md_layer.tag}>${md_layer.text}</${md_layer.tag}>`;
if (md_layer.token === '```')
out = `<code><pre>${md_layer.text}</pre></code>`;
if (md_layer.parent) md_layer.parent.text += out;
else response += out;
md_layer = md_layer.parent;
} else {
md_layer.text += sample[0];
index++;
}
} else if (md_nostack) {
// should we add a layer?
let pushed = false;
for (let i = 0; i < markdown_tokens.length; i++) {
let item = markdown_tokens[i];
if (sample.startsWith(item.token)) {
let new_md_layer = {
token: item.token,
tag: item.tag,
text: "",
parent: md_layer,
};
if (item.token === '```' || item.token === '`') new_md_layer.nostack = true;
md_layer = new_md_layer;
pushed = true;
index += md_layer.token.length;
break;
}
}
if (!pushed) {
response += sample[0];
index++;
}
}
}
// destroy the remaining stack
while (md_layer) {
let out = md_layer.token + md_layer.text;
if (md_layer.parent) md_layer.parent.text += out;
else response += out;
md_layer = md_layer.parent;
}
return response;
}
*/ */
export async function parsePost(data, ancestor_count) {
let post = {};
if (!ancestor_count) ancestor_count = 0;
post.html = data.content;
post.reply = null;
if ((data.in_reply_to_id || data.reply) && ancestor_count !== 0) {
const reply_data = data.reply || await api.getPost(get(server).host, get(app).token, data.in_reply_to_id);
// if the post returns false, we probably don't have permission to read it.
// we'll respect the thread's privacy, and leave it alone :)
if (!reply_data) return false;
post.reply = await parsePost(reply_data, ancestor_count - 1, false);
}
post.boost = data.reblog ? await parsePost(data.reblog, 1, false) : null;
post.id = data.id;
post.created_at = new Date(data.created_at);
post.account = await parseAccount(data.account);
post.warning = data.spoiler_text;
post.reply_count = data.replies_count;
post.boost_count = data.reblogs_count;
post.boosted = data.reblogged;
post.favourite_count = data.favourites_count;
post.favourited = data.favourited;
post.mentions = data.mentions;
post.media = data.media_attachments;
post.url = data.url;
post.visibility = data.visibility;
post.emojis = [];
if (post.emojis) {
data.emojis.forEach(emoji => {
post.emojis[emoji.shortcode] = parseEmoji(emoji.shortcode, emoji.url);
});
}
if (data.reactions) post.reactions = parseReactions(data.reactions);
post.rich_text = renderEmoji(post.html, post.emojis);
return post;
// let cache_post = get(cache)[post.id];
// if (cache_post) {
// cache_post.set(post);
// } else {
// cache.update(cache => {
// cache[post.id] = writable(post);
// return cache;
// });
// }
// return get(cache)[post.id];
}
export function parseReactions(data) {
let reactions = [];
data.forEach(reaction_data => {
let reaction = {
count: reaction_data.count,
name: reaction_data.name,
me: reaction_data.me,
};
if (reaction_data.url) reaction.url = reaction_data.url;
reactions.push(reaction);
});
return reactions;
} }

View file

@ -6,12 +6,12 @@ let sounds;
if (typeof Audio !== typeof undefined) { if (typeof Audio !== typeof undefined) {
sounds = { sounds = {
"default": new Audio(sound_log), "default": new Audio(sound_log),
"post": new Audio(sound_hello), "post": new Audio(sound_success),
"boost": new Audio(sound_success), "boost": new Audio(sound_hello),
}; };
} }
export function play_sound(name) { export function playSound(name) {
if (name === false) return; if (name === false) return;
if (!name) name = "default"; if (!name) name = "default";
const sound = sounds[name]; const sound = sounds[name];

View file

@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export let account = writable(false);

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const show = writable(false);
export const reply_post = writable(null);

View file

@ -1,22 +0,0 @@
import { client } from '$lib/client/client.js';
import * as api from '$lib/client/api.js';
import { get, writable } from 'svelte/store';
export let user = writable(0);
export let logged_in = writable(false);
export async function getUser() {
// already known
if (get(user)) return get(user);
// cannot provide- not logged in
if (!get(client).app || !get(client).app.token) return false;
// logged in- attempt to retrieve using token
const data = await api.verifyCredentials();
if (!data) return false;
user.set(await api.parseUser(data));
console.log(`Logged in as @${get(user).username}@${get(user).host}`);
return get(user);
}

View file

@ -1,8 +1,10 @@
import { client } from '$lib/client/client.js'; import * as api from '$lib/api.js';
import { server } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { get, writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { parsePost } from '$lib/client/api.js'; import { parsePost } from '$lib/post.js';
export let timeline = writable([]); export const timeline = writable([]);
let loading = false; let loading = false;
@ -10,9 +12,16 @@ export async function getTimeline(clean) {
if (loading) return; // no spamming!! if (loading) return; // no spamming!!
loading = true; loading = true;
let timeline_data; let last_post = false;
if (clean || get(timeline).length === 0) timeline_data = await get(client).getTimeline() if (!clean && get(timeline).length > 0)
else timeline_data = await get(client).getTimeline(get(timeline)[get(timeline).length - 1].id); last_post = get(timeline)[get(timeline).length - 1].id;
const timeline_data = await api.getTimeline(
get(server).host,
get(app).token,
"home",
last_post
);
if (!timeline_data) { if (!timeline_data) {
console.error(`Failed to retrieve timeline.`); console.error(`Failed to retrieve timeline.`);
@ -24,7 +33,7 @@ export async function getTimeline(clean) {
for (let i in timeline_data) { for (let i in timeline_data) {
const post_data = timeline_data[i]; const post_data = timeline_data[i];
const post = await parsePost(post_data, 1, false); const post = await parsePost(post_data, 1);
if (!post) { if (!post) {
if (post === null || post === undefined) { if (post === null || post === undefined) {
if (post_data.id) { if (post_data.id) {

View file

@ -1,5 +1,5 @@
<script> <script>
import { play_sound } from '../sound.js'; import { playSound } from '../sound.js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { afterUpdate } from 'svelte'; import { afterUpdate } from 'svelte';
@ -21,7 +21,7 @@
location = href; location = href;
return; return;
} }
play_sound(sound); playSound(sound);
dispatch('click'); dispatch('click');
} }

246
src/lib/ui/Composer.svelte Normal file
View file

@ -0,0 +1,246 @@
<script lang="js">
import { account } from '@cf/store/account';
import * as api from '$lib/api';
import { server } from '$lib/client/server';
import { app } from '$lib/client/app';
import { parsePost } from '$lib/post.js';
import { timeline } from '$lib/timeline.js';
import { createEventDispatcher } from 'svelte';
import { playSound } from '$lib/sound';
import Button from '@cf/ui/Button.svelte';
import PostIcon from '@cf/icons/post.svg';
import MediaIcon from '@cf/icons/media.svg';
import PollIcon from '@cf/icons/poll.svg';
import WarningIcon from '@cf/icons/warning.svg';
import PublicVisIcon from '@cf/icons/public.svg';
import UnlistedVisIcon from '@cf/icons/unlisted.svg';
import FollowersVisIcon from '@cf/icons/followers.svg';
import PrivateVisIcon from '@cf/icons/dm.svg';
export let reply_id;
let content_warning = ""
let content = "";
// let media_ids = [];
let show_cw = false;
let visibility = "Public";
const placeholders = [
"What's cooking, $1?",
"Speak your mind!",
"Federate something...",
"I sure love posting!",
"Another day, another $1 post!",
];
let placeholder = placeholders[Math.floor(placeholders.length * Math.random())]
.replaceAll("$1", $account.username);
const dispatch = createEventDispatcher();
async function buildPost() {
let postdata = {}
if (!content) return;
postdata.status = content;
switch (visibility) {
case "Public":
postdata.visibility = "public";
break;
case "Unlisted":
postdata.visibility = "unlisted";
break;
case "Followers only":
postdata.visibility = "private";
break;
case "Private":
postdata.visibility = "direct";
break;
}
if (show_cw) {
postdata.spoiler_text = content_warning;
postdata.sensitive = true;
}
if (reply_id) postdata.in_reply_to_id = reply_id;
let new_post = await api.createPost($server.host, $app.token, postdata);
let new_post_parsed = await parsePost(new_post);
timeline.update(current => [new_post_parsed, ...current]);
playSound("post");
dispatch("compose_finished")
}
function cycleVisibility() {
switch (visibility) {
case "Public":
visibility = "Unlisted";
break;
case "Unlisted":
visibility = "Followers only";
break;
case "Followers only":
visibility = "Private";
break;
case "Private":
visibility = "Public";
break;
}
}
</script>
<div class="composer">
<div class="composer-header-container">
<a href={$account.url} target="_blank" class="composer-avatar-container" on:mouseup|stopPropagation>
<img src={$account.avatar_url} type={$account.avatar_type} alt="" width="48" height="48" class="composer-avatar" loading="lazy" decoding="async">
</a>
<header class="composer-header">
<div class="composer-user-info" on:mouseup|stopPropagation>
<span class="display-name">{@html $account.rich_name}</span>
<span class="username">{$account.mention}</span>
</div>
<div class="composer-info" on:mouseup|stopPropagation>
</div>
</header>
<div title={visibility}>
<Button centered={true} on:click={() => {cycleVisibility()}}>
<svelte:fragment slot="icon">
<!-- TODO: this should be a drop-down option!...later -->
{#if visibility === "Public"}
<PublicVisIcon/>
{:else if visibility === "Unlisted"}
<UnlistedVisIcon/>
{:else if visibility === "Followers only"}
<FollowersVisIcon/>
{:else if visibility === "Private"}
<PrivateVisIcon/>
{/if}
</svelte:fragment>
</Button>
</div>
</div>
{#if show_cw}
<input type="text" id="" placeholder="Content warning" bind:value={content_warning}/>
{/if}
<textarea placeholder="{placeholder}" class="textbox" bind:value={content}></textarea>
<div class="composer-footer">
<div class="actions">
<Button centered={true} disabled>
<svelte:fragment slot="icon">
<MediaIcon/>
</svelte:fragment>
</Button>
<Button centered={true} disabled>
<svelte:fragment slot="icon">
<PollIcon/>
</svelte:fragment>
</Button>
<Button centered={true} active={show_cw} on:click={() => show_cw = !show_cw}>
<svelte:fragment slot="icon">
<WarningIcon/>
</svelte:fragment>
</Button>
</div>
<Button filled={true} centered={true} class="postbtn" on:click={buildPost} sound={false}>
<svelte:fragment slot="icon">
<PostIcon/>
</svelte:fragment>
Post
</Button>
</div>
</div>
<style>
.composer {
display: flex;
flex-direction: column;
gap: 16px;
}
.composer-footer {
display: flex;
}
.composer-footer div {
width: 100%;
display: flex;
gap: 16px;
}
.composer-footer div :global(button) {
width: 58px;
}
.composer-footer :global(button) {
width: 30%;
}
input[type="text"], textarea {
box-sizing: border-box;
padding: 8px 8px;
border-radius: 8px;
border-style: none;
font-size: 1em;
width: 100%;
background-color: var(--bg-700);
color: var(--text);
font-family: inherit;
border-radius: 8px;
border: 1px solid color-mix(in srgb, transparent, var(--accent) 25%);
}
input[type="text"]:focus, textarea:focus {
outline: none;
box-shadow: 0 0 16px color-mix(in srgb, transparent, var(--accent) 25%);
}
.textbox {
resize: none;
height: 160px;
}
.composer-header-container {
width: 100%;
display: flex;
flex-direction: row;
}
.composer-avatar {
border-radius: 8px;
margin-right: 12px;
display: flex;
}
.composer-header {
display: flex;
flex-grow: 1;
flex-direction: row;
}
.composer-info {
margin-left: auto;
}
.composer-user-info {
margin-top: -2px;
display: flex;
flex-direction: column;
justify-content: center;
}
.composer-user-info .name :global(.emoji) {
position: relative;
top: .2em;
height: 1.2em;
}
.composer-user-info .username {
opacity: .8;
font-size: .9em;
}
</style>

View file

@ -1,62 +0,0 @@
<script>
import Button from './Button.svelte';
import Post from './post/Post.svelte';
import { getTimeline } from '$lib/timeline.js';
export let posts = [];
</script>
<header>
<h1>Home</h1>
<nav>
<Button centered active>Home</Button>
<Button centered disabled>Local</Button>
<Button centered disabled>Federated</Button>
</nav>
</header>
<div id="feed" role="feed">
{#if posts.length <= 0}
<div class="loading throb">
<span>getting the feed...</span>
</div>
{/if}
{#each posts as post}
<Post post_data={post} />
{/each}
</div>
<style>
header {
width: 100%;
height: 64px;
margin: 16px 0 8px 0;
display: flex;
flex-direction: row;
}
header h1 {
font-size: 1.5em;
}
nav {
margin-left: auto;
display: flex;
flex-direction: row;
gap: 8px;
}
#feed {
margin-bottom: 20vh;
}
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
</style>

View file

@ -1,37 +1,42 @@
<script> <script>
import { client } from '$lib/client/client.js'; import * as api from '$lib/api.js';
import { server, createServer } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import Logo from '$lib/../img/campfire-logo.svg'; import Logo from '$lib/../img/campfire-logo.svg';
let instance_url_error = false; let display_error = false;
let logging_in = false; let logging_in = false;
function log_in(event) { async function log_in(event) {
event.preventDefault(); event.preventDefault();
instance_url_error = false; display_error = false;
logging_in = true; logging_in = true;
const host = event.target.host.value; const host = event.target.host.value;
if (!host || host === "") { if (!host || host === "") {
instance_url_error = "Please enter an instance domain."; display_error = "Please enter an server domain.";
logging_in = false; logging_in = false;
return; return;
} }
console.log(client); server.set(await createServer(host));
if (!get(server)) {
get(client).init(host).then(res => { display_error = "Failed to connect to the server.\nCheck the browser console for details!"
logging_in = false; logging_in = false;
if (!res) return;
if (res.constructor === String) {
instance_url_error = res;
return; return;
}; }
let oauth_url = get(client).getOAuthUrl();
location = oauth_url; app.set(await api.createApp(get(server).host));
}); if (!get(app)) {
display_error = "Failed to create an application for this server."
logging_in = false;
return;
}
location = api.getOAuthUrl(get(server).host, get(app).id);
} }
</script> </script>
@ -40,11 +45,11 @@
<Logo /> <Logo />
</div> </div>
<p>Welcome, fediverse user!</p> <p>Welcome, fediverse user!</p>
<p>Please enter your instance domain to log in.</p> <p>Please enter your server domain to log in.</p>
<div class="input-wrapper"> <div class="input-wrapper">
<input type="text" id="host" aria-label="instance domain" class={logging_in ? "throb" : ""}> <input type="text" id="host" aria-label="server domain" class={logging_in ? "throb" : ""}>
{#if instance_url_error} {#if display_error}
<p class="error">{instance_url_error}</p> <p class="error">{display_error}</p>
{/if} {/if}
</div> </div>
<br> <br>
@ -70,6 +75,10 @@
text-align: center; text-align: center;
} }
.app-logo :global(svg) {
width: 100%;
}
.input-wrapper { .input-wrapper {
width: 360px; width: 360px;
margin: 0 auto; margin: 0 auto;

95
src/lib/ui/Modal.svelte Normal file
View file

@ -0,0 +1,95 @@
<script>
import { beforeUpdate } from "svelte";
export let visible = true;
export let centered = false;
beforeUpdate(() => {
// disable scrolling hack: this has to be on body
if(visible) {
document.body.style.overflowY = "hidden";
} else {
document.body.style.overflowY = "scroll";
}
})
</script>
{#if visible}
<div class="overlay" on:click={() => visible = !visible}></div>
<div class="container">
<div class="modal" class:modal-top={!centered} class:modal-center={centered}>
<slot/>
</div>
</div>
{/if}
<style>
.container {
z-index: 101;
display: flex;
justify-content: center;
position: absolute;
width: 100vw;
height: 100vh;
pointer-events: none;
}
.modal {
background-color: var(--bg-800);
z-index: 101;
padding: 16px;
width: 732px;
border-radius: 8px;
box-shadow: 0px 16px 64px 4px rgba(0,0,0,0.5);
animation: modal_pop_up .15s cubic-bezier(0.22, 1, 0.36, 1);
height: fit-content;
pointer-events: all;
}
.overlay {
width: 100vw;
height: 100vw;
position: absolute;
top: 0;
left: 0;
z-index: 100;
background-color: rgba(0,0,0,0.2);
backdrop-filter: blur(32px) saturate(1.25);
user-select: none;
animation: modal_bg .15s cubic-bezier(0.22, 1, 0.36, 1);
}
.modal-top {
margin-top: 8em;
}
.modal-center {
align-items: center;
}
@keyframes modal_bg {
from {
background-color: rgba(0,0,0,0);
backdrop-filter: blur(0px) saturate(1.0);
}
to {
background-color: rgba(0,0,0,0.2);
backdrop-filter: blur(32px) saturate(1.25);
}
}
@keyframes modal_pop_up {
from {
opacity: 0%;
transform: translateY(16px) scale(0.95);
}
to {
opacity: 100%;
transform: translateY(0px) scale(1);
}
}
</style>

View file

@ -1,17 +1,18 @@
<script> <script>
import Logo from '$lib/../img/campfire-logo.svg'; import * as api from '$lib/api.js';
import Button from './Button.svelte'; import { account } from '$lib/stores/account.js';
import Feed from './Feed.svelte'; import { server } from '$lib/client/server.js';
import { client } from '$lib/client/client.js'; import { app } from '$lib/client/app.js';
import { user } from '$lib/stores/user.js'; import { playSound } from '$lib/sound.js';
import { play_sound } from '$lib/sound.js';
import { getTimeline } from '$lib/timeline.js'; import { getTimeline } from '$lib/timeline.js';
import { getNotifications } from '$lib/notifications.js'; import { getNotifications } from '$lib/notifications.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { get } from 'svelte/store'; import { createEventDispatcher } from 'svelte';
import { logged_in } from '$lib/stores/user.js'; import { notifications, unread_notif_count } from '$lib/notifications.js';
import { unread_notif_count, last_read_notif_id } from '$lib/notifications.js';
import Logo from '$lib/../img/campfire-logo.svg';
import Button from './Button.svelte';
import TimelineIcon from '../../img/icons/timeline.svg'; import TimelineIcon from '../../img/icons/timeline.svg';
import NotificationsIcon from '../../img/icons/notifications.svg'; import NotificationsIcon from '../../img/icons/notifications.svg';
@ -27,8 +28,10 @@
const VERSION = APP_VERSION; const VERSION = APP_VERSION;
const dispatch = createEventDispatcher();
function handle_btn(name) { function handle_btn(name) {
if (!get(logged_in)) return; if (!$account) return;
let route; let route;
switch (name) { switch (name) {
case "timeline": case "timeline":
@ -37,6 +40,7 @@
break; break;
case "notifications": case "notifications":
route = "/notifications"; route = "/notifications";
notifications.set([]);
getNotifications(); getNotifications();
break; break;
case "explore": case "explore":
@ -57,19 +61,33 @@
async function log_out() { async function log_out() {
if (!confirm("This will log you out. Are you sure?")) return; if (!confirm("This will log you out. Are you sure?")) return;
await get(client).logout();
const res = await api.revokeToken(
$server.host,
$app.id,
$app.secret,
$app.token
);
if (!res.ok)
console.warn("Token revocation failed! Dumping data anyways");
account.set(false);
app.set(false);
server.set(false);
goto("/"); goto("/");
} }
</script> </script>
<div id="navigation"> <div id="navigation">
<header class="instance-header"> <header class="server-header">
<div class="app-logo"> <div class="app-logo">
<Logo /> <Logo />
</div> </div>
</header> </header>
{#if $logged_in} {#if $account}
<div id="nav-items"> <div id="nav-items">
<Button label="Timeline" <Button label="Timeline"
on:click={() => handle_btn("timeline")} on:click={() => handle_btn("timeline")}
@ -123,7 +141,7 @@
</Button> </Button>
</div> </div>
<Button filled label="Post" disabled> <Button filled label="Post" on:click={() => dispatch("compose")}>
<svelte:fragment slot="icon"> <svelte:fragment slot="icon">
<PostIcon/> <PostIcon/>
</svelte:fragment> </svelte:fragment>
@ -151,16 +169,17 @@
</div> </div>
<div id="account-button"> <div id="account-button">
<img src={$user.avatar_url} class="account-avatar" height="64px" alt="" aria-hidden="true" on:click={() => play_sound()}> <img src={$account.avatar_url} class="account-avatar" height="64px" alt="" aria-hidden="true" on:click={() => playSound()}>
<div class="account-name" aria-hidden="true"> <div class="account-name" aria-hidden="true">
<a href={$user.url} class="nickname" title={$user.nickname}>{$user.nickname}</a> <a href={$account.url} class="nickname" title={$account.nickname}>{@html $account.rich_name}</a>
<span class="username" title={`@${$user.username}@${$user.host}`}> <span class="username" title={`@${$account.username}@${$account.host}`}>
{`@${$user.username}@${$user.host}`} {`@${$account.username}@${$account.host}`}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
<span class="version"> <span class="version">
campfire v{VERSION} campfire v{VERSION}
<br> <br>
@ -183,7 +202,7 @@
background-color: var(--bg-800); background-color: var(--bg-800);
} }
.instance-header { .server-header {
width: 100%; width: 100%;
height: 172px; height: 172px;
display: flex; display: flex;
@ -196,7 +215,7 @@
background-image: linear-gradient(to top, var(--bg-800), var(--bg-600)); background-image: linear-gradient(to top, var(--bg-800), var(--bg-600));
} }
.instance-icon { .server-icon {
height: 50%; height: 50%;
border-radius: 8px; border-radius: 8px;
} }
@ -345,6 +364,11 @@
font-size: .65em; font-size: .65em;
} }
.nickname :global(.emoji) {
height: 1.2em;
margin: -.1em 0;
}
.flex-row { .flex-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View file

@ -1,12 +1,12 @@
<script> <script>
import * as api from '$lib/client/api.js'; import { server } from '$lib/client/server';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import ReplyIcon from '$lib/../img/icons/reply.svg'; import ReplyIcon from '$lib/../img/icons/reply.svg';
import RepostIcon from '$lib/../img/icons/repost.svg'; import RepostIcon from '$lib/../img/icons/repost.svg';
import FavouriteIcon from '$lib/../img/icons/like.svg'; import FavouriteIcon from '$lib/../img/icons/like.svg';
import ReactIcon from '$lib/../img/icons/react.svg'; import ReactIcon from '$lib/../img/icons/react.svg';
import QuoteIcon from '$lib/../img/icons/quote.svg'; // import QuoteIcon from '$lib/../img/icons/quote.svg';
import ReactionBar from '$lib/ui/post/ReactionBar.svelte'; import ReactionBar from '$lib/ui/post/ReactionBar.svelte';
import ActionBar from '$lib/ui/post/ActionBar.svelte'; import ActionBar from '$lib/ui/post/ActionBar.svelte';
@ -54,15 +54,15 @@
event.ctrlKey)) return; event.ctrlKey)) return;
if (event.key && event.key !== "Enter") return; if (event.key && event.key !== "Enter") return;
} }
goto(`/post/${data.status.id}`); goto(`/${$server.host}/${data.status.account.mention}/${data.status.id}`);
} }
let aria_label = function () { let aria_label = function (data) {
if (accounts.length == 1) if (data.accounts.length == 1)
return activity_text.replace("%1", account.username) + ' ' + new Date(data.created_at); return activity_text.replace("%1", account.username) + ' ' + new Date(data.created_at);
else else
return activity_text.replace("%1", `${account.username} and ${accounts.length - 1} others`) + ' ' + new Date(data.created_at); return activity_text.replace("%1", `${account.username} and ${data.accounts.length - 1} others`) + ' ' + new Date(data.created_at);
} }(data);
</script> </script>
<article <article
@ -101,7 +101,33 @@
</header> </header>
{#if data.status} {#if data.status}
<div class="notif-content"> <div class="notif-content">
{#if data.status.warning}
<div class="warning">
{data.status.warning}
</div>
{:else}
{@html data.status.html} {@html data.status.html}
{/if}
{#if data.status.media && data.status.media.length > 0}
<div class="notif-media-container" data-count={data.status.media.length}>
{#each data.status.media as media}
<div class="notif-media {media.type}" on:click|stopPropagation on:mouseup|stopPropagation>
{#if ["image", "gifv", "gif"].includes(media.type)}
<a href={media.url} target="_blank">
<img src={media.url} alt={media.description} title={media.description} height="200" loading="lazy" decoding="async">
</a>
{:else if media.type === "video"}
<video controls height="200">
<source src={media.url} alt={media.description} title={media.description} type={media.url.endsWith('.mp4') ? 'video/mp4' : 'video/webm'}>
<p>{media.description} &ensp; <a href={media.url}>[link]</a></p>
<!-- <media src={media.url} alt={media.description} loading="lazy" decoding="async"> -->
</video>
{/if}
</div>
{/each}
</div>
{/if}
</div> </div>
{#if data.type === "mention"} {#if data.type === "mention"}
{#if data.status.reactions} {#if data.status.reactions}
@ -154,6 +180,7 @@
} }
header .notif-avatars img:not(:first-child) { header .notif-avatars img:not(:first-child) {
box-shadow: 4px 0 8px -2px rgba(0,0,0,.33); box-shadow: 4px 0 8px -2px rgba(0,0,0,.33);
z-index: 0;
} }
header .notif-avatars img:not(:last-child) { header .notif-avatars img:not(:last-child) {
margin-left: -8px; margin-left: -8px;
@ -247,4 +274,52 @@
margin-right: 4px; margin-right: 4px;
border-radius: 4px; border-radius: 4px;
} }
.notif-content .warning {
width: calc(100% - 16px);
margin-bottom: 10px;
padding: 4px 8px;
--warn-bg: color-mix(in srgb, var(--bg-700), var(--accent) 1%);
background: repeating-linear-gradient(-45deg, transparent, transparent 10px, var(--warn-bg) 10px, var(--warn-bg) 20px);
font-family: inherit;
font-size: inherit;
color: inherit;
text-align: left;
border: none;
border-radius: 8px;
cursor: pointer;
outline-color: var(--warn-bg);
transition: outline .05s, box-shadow .05s;
}
.notif-media-container {
margin: 16px 0 4px 0;
display: flex;
flex-direction: row;
gap: 8px;
font-size: 14px;
line-height: 1.45em;
}
.notif-media {
display: inline-block;
border-radius: 12px;
background-color: #000;
overflow: hidden;
}
.notif-media a {
width: 5em;
height: 5em;
display: block;
cursor: zoom-in;
}
.notif-media img,
.notif-media video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
</style> </style>

View file

@ -1,7 +1,11 @@
<script> <script>
import { client } from '../../client/client.js'; import * as api from '$lib/api';
import * as api from '../../client/api.js'; import { server } from '$lib/client/server';
import { get } from 'svelte/store'; import { app } from '$lib/client/app';
import { account } from '@cf/store/account';
import { timeline } from '$lib/timeline';
import { parseReactions } from '$lib/post';
import { playSound } from '$lib/sound';
import ActionButton from './ActionButton.svelte'; import ActionButton from './ActionButton.svelte';
@ -9,18 +13,24 @@
import RepostIcon from '../../../img/icons/repost.svg'; import RepostIcon from '../../../img/icons/repost.svg';
import FavouriteIcon from '../../../img/icons/like.svg'; import FavouriteIcon from '../../../img/icons/like.svg';
import FavouriteIconFill from '../../../img/icons/like_fill.svg'; import FavouriteIconFill from '../../../img/icons/like_fill.svg';
import ReactIcon from '../../../img/icons/react.svg';
import QuoteIcon from '../../../img/icons/quote.svg'; import QuoteIcon from '../../../img/icons/quote.svg';
import MoreIcon from '../../../img/icons/more.svg'; import MoreIcon from '../../../img/icons/more.svg';
import DeleteIcon from '../../../img/icons/bin.svg';
export let post; export let post;
async function toggleBoost() { async function toggleBoost() {
if (!$app || !$app.token) return;
let data; let data;
if (post.boosted) if (post.boosted) {
data = await get(client).unboostPost(post.id); playSound();
else data = await api.unboostPost($server.host, $app.token, post.id);
data = await get(client).boostPost(post.id); } else {
playSound("boost");
data = await api.boostPost($server.host, $app.token, post.id, post.visibility);
}
if (!data) { if (!data) {
console.error(`Failed to boost post ${post.id}`); console.error(`Failed to boost post ${post.id}`);
return; return;
@ -30,49 +40,50 @@
} }
async function toggleFavourite() { async function toggleFavourite() {
if (!$app || !$app.token) return;
let data; let data;
if (post.favourited) if (post.favourited)
data = await get(client).unfavouritePost(post.id); data = await api.unfavouritePost($server.host, $app.token, post.id);
else else
data = await get(client).favouritePost(post.id); data = await api.favouritePost($server.host, $app.token, post.id);
if (!data) { if (!data) {
console.error(`Failed to favourite post ${post.id}`); console.error(`Failed to favourite post ${post.id}`);
return; return;
} }
post.favourited = data.favourited; post.favourited = data.favourited;
post.favourite_count = data.favourites_count; post.favourite_count = data.favourites_count;
if (data.reactions) post.reactions = api.parseReactions(data.reactions); if (data.reactions) post.reactions = parseReactions(data.reactions);
} }
async function toggleReaction(reaction) { async function deletePost() {
if (reaction.name.includes('@')) return; if (!$account || post.account.id !== $account.id) return;
let data; if (!confirm("Are you sure you want to delete this post? This action cannot be undone."))
if (reaction.me) return;
data = await get(client).unreactPost(post.id, reaction.name);
else const res = await api.deletePost($server.host, $app.token, post.id);
data = await get(client).reactPost(post.id, reaction.name);
if (!data) { if (!res || res.error) {
console.error(`Failed to favourite post ${post.id}`); console.error(`Error while deleting post ${post.id}`);
return; return;
} }
post.favourited = data.favourited;
post.favourite_count = data.favourites_count; timeline.update(timeline => timeline.filter(p => p.id !== post.id));
if (data.reactions) post.reactions = api.parseReactions(data.reactions);
} }
</script> </script>
<div class="post-actions" aria-label="Post actions" on:mouseup|stopPropagation on:keydown|stopPropagation> <div class="post-actions" aria-label="Post actions" role="toolbar" tabindex="0" on:mouseup|stopPropagation on:keydown|stopPropagation>
<ActionButton type="reply" label="Reply" bind:count={post.reply_count} sound="post" disabled> <ActionButton type="reply" label="Reply" bind:count={post.reply_count} sound="post" disabled>
<ReplyIcon/> <ReplyIcon/>
</ActionButton> </ActionButton>
<ActionButton type="boost" label="Boost" on:click={toggleBoost} bind:active={post.boosted} bind:count={post.boost_count} sound="boost"> <ActionButton type="boost" label="Boost" on:click={toggleBoost} bind:active={post.boosted} bind:count={post.boost_count} disabled={!$account}>
<RepostIcon/> <RepostIcon/>
<svelte:fragment slot="activeIcon"> <svelte:fragment slot="activeIcon">
<RepostIcon/> <RepostIcon/>
</svelte:fragment> </svelte:fragment>
</ActionButton> </ActionButton>
<ActionButton type="favourite" label="Favourite" on:click={toggleFavourite} bind:active={post.favourited} bind:count={post.favourite_count}> <ActionButton type="favourite" label="Favourite" on:click={toggleFavourite} bind:active={post.favourited} bind:count={post.favourite_count} disabled={!$account}>
<FavouriteIcon/> <FavouriteIcon/>
<svelte:fragment slot="activeIcon"> <svelte:fragment slot="activeIcon">
<FavouriteIconFill/> <FavouriteIconFill/>
@ -84,6 +95,11 @@
<ActionButton type="more" label="More" disabled> <ActionButton type="more" label="More" disabled>
<MoreIcon/> <MoreIcon/>
</ActionButton> </ActionButton>
{#if $account && post.account.id === $account.id}
<ActionButton type="delete" label="Delete" on:click={deletePost}>
<DeleteIcon/>
</ActionButton>
{/if}
</div> </div>
<style> <style>

View file

@ -1,5 +1,5 @@
<script> <script>
import { play_sound } from '../../sound.js'; import { playSound } from '../../sound.js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -13,7 +13,7 @@
function click() { function click() {
if (disabled) return; if (disabled) return;
play_sound(sound); playSound(sound);
dispatch('click'); dispatch('click');
} }
</script> </script>

View file

@ -1,8 +1,6 @@
<script> <script>
export let post; export let post;
let rich_text;
post.rich_text().then(res => {rich_text = res});
let open_warned = false; let open_warned = false;
</script> </script>
@ -22,22 +20,24 @@
</button> </button>
{/if} {/if}
{#if !post.warning || open_warned} {#if !post.warning || open_warned}
{#if post.text} {#if post.rich_text}
<span class="post-text">{@html rich_text}</span> <span class="post-text">{@html post.rich_text}</span>
{:else if post.html}
<span class="post-text">{@html post.html}</span>
{/if} {/if}
{#if post.files && post.files.length > 0} {#if post.media && post.media.length > 0}
<div class="post-media-container" data-count={post.files.length}> <div class="post-media-container" data-count={post.media.length}>
{#each post.files as file} {#each post.media as media}
<div class="post-media {file.type}" on:click|stopPropagation on:mouseup|stopPropagation> <div class="post-media {media.type}" on:click|stopPropagation on:mouseup|stopPropagation>
{#if ["image", "gifv", "gif"].includes(file.type)} {#if ["image", "gifv", "gif"].includes(media.type)}
<a href={file.url} target="_blank"> <a href={media.url} target="_blank">
<img src={file.url} alt={file.description} title={file.description} height="200" loading="lazy" decoding="async"> <img src={media.url} alt={media.description} title={media.description} height="200" loading="lazy" decoding="async">
</a> </a>
{:else if file.type === "video"} {:else if media.type === "video"}
<video controls height="200"> <video controls height="200">
<source src={file.url} alt={file.description} title={file.description} type={file.url.endsWith('.mp4') ? 'video/mp4' : 'video/webm'}> <source src={media.url} alt={media.description} title={media.description} type={media.url.endsWith('.mp4') ? 'video/mp4' : 'video/webm'}>
<p>{file.description} &ensp; <a href={file.url}>[link]</a></p> <p>{media.description} &ensp; <a href={media.url}>[link]</a></p>
<!-- <media src={file.url} alt={file.description} loading="lazy" decoding="async"> --> <!-- <media src={media.url} alt={media.description} loading="lazy" decoding="async"> -->
</video> </video>
{/if} {/if}
</div> </div>
@ -90,10 +90,11 @@
} }
.post-text :global(.emoji) { .post-text :global(.emoji) {
position: relative; transition: transform .1s ease-out;
top: 6px; }
margin-top: -10px;
height: 24px!important; .post-text :global(.emoji):hover {
transform: scale(1.2);
} }
.post-text :global(blockquote) { .post-text :global(blockquote) {

View file

@ -1,17 +1,16 @@
<script> <script>
import { parseText as parseEmojis } from '../../emoji.js'; import { shorthand as short_time } from '$lib/time.js';
import { shorthand as short_time } from '../../time.js';
export let post; export let post;
let time_string = post.created_at.toLocaleString(); const time_string = post.created_at.toLocaleString();
</script> </script>
<div class="post-context"> <div class="post-context">
<span class="post-context-icon">🔁</span> <span class="post-context-icon">🔁</span>
<span class="post-context-action"> <span class="post-context-action">
<a href={post.user.url} target="_blank"><span class="name"> <a href={post.account.url} target="_blank"><span class="name">
{@html parseEmojis(post.user.rich_name)}</span> {@html post.account.rich_name}</span>
</a> </a>
boosted this post. boosted this post.
</span> </span>

View file

@ -1,8 +1,7 @@
<script> <script>
import { parseOne as parseEmoji } from '../../emoji.js';
import { play_sound } from '../../sound.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { server } from '$lib/client/server';
import BoostContext from './BoostContext.svelte'; import BoostContext from './BoostContext.svelte';
import ReplyContext from './ReplyContext.svelte'; import ReplyContext from './ReplyContext.svelte';
@ -11,14 +10,6 @@
import ActionBar from './ActionBar.svelte'; import ActionBar from './ActionBar.svelte';
import ReactionBar from './ReactionBar.svelte'; import ReactionBar from './ReactionBar.svelte';
import ReplyIcon from '../../../img/icons/reply.svg';
import RepostIcon from '../../../img/icons/repost.svg';
import FavouriteIcon from '../../../img/icons/like.svg';
import FavouriteIconFill from '../../../img/icons/like_fill.svg';
import ReactIcon from '../../../img/icons/react.svg';
import QuoteIcon from '../../../img/icons/quote.svg';
import MoreIcon from '../../../img/icons/more.svg';
export let post_data; export let post_data;
export let focused = false; export let focused = false;
@ -41,7 +32,7 @@
event.ctrlKey)) return; event.ctrlKey)) return;
if (event.key && event.key !== "Enter") return; if (event.key && event.key !== "Enter") return;
} }
goto(`/post/${post.id}`); goto(`/${$server.host}/${post.account.mention}/${post.id}`);
} }
let el; let el;
@ -51,7 +42,7 @@
} }
}); });
let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at; let aria_label = post.account.username + '; ' + post.text + '; ' + post.created_at;
</script> </script>
<div class="post-container"> <div class="post-container">

View file

@ -1,6 +1,5 @@
<script> <script>
import { parseText as parseEmojis } from '../../emoji.js'; import { shorthand as short_time } from '$lib/time.js';
import { shorthand as short_time } from '../../time.js';
export let post; export let post;
export let reply = undefined; export let reply = undefined;
@ -9,13 +8,13 @@
</script> </script>
<div class={"post-header-container" + (reply ? " reply" : "")}> <div class={"post-header-container" + (reply ? " reply" : "")}>
<a href={post.user.url} target="_blank" class="post-avatar-container" on:mouseup|stopPropagation> <a href={post.account.url} target="_blank" class="post-avatar-container" on:mouseup|stopPropagation>
<img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async"> <img src={post.account.avatar_url} type={post.account.avatar_type} alt="" width="48" height="48" class="post-avatar" loading="lazy" decoding="async">
</a> </a>
<header class="post-header"> <header class="post-header">
<div class="post-user-info" on:mouseup|stopPropagation> <div class="post-user-info" on:mouseup|stopPropagation>
<a href={post.user.url} target="_blank" class="name">{@html post.user.rich_name}</a> <a href={post.account.url} target="_blank" class="name">{@html post.account.rich_name}</a>
<span class="username">{post.user.mention}</span> <span class="username">{post.account.mention}</span>
</div> </div>
<div class="post-info" on:mouseup|stopPropagation> <div class="post-info" on:mouseup|stopPropagation>
<a href={post.url} target="_blank" class="created-at"> <a href={post.url} target="_blank" class="created-at">
@ -80,12 +79,6 @@
display: block; display: block;
} }
.post-user-info .name :global(.emoji) {
position: relative;
top: .2em;
height: 1.2em;
}
.post-user-info .username { .post-user-info .username {
opacity: .8; opacity: .8;
font-size: .9em; font-size: .9em;

View file

@ -1,8 +1,37 @@
<script> <script>
import * as api from '$lib/api.js';
import { server, capabilities } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { account } from '@cf/store/account';
import { parseReactions } from '$lib/post.js';
import ReactionButton from './ReactionButton.svelte'; import ReactionButton from './ReactionButton.svelte';
import ReactIcon from '../../../img/icons/react.svg'; import ReactIcon from '../../../img/icons/react.svg';
export let post; export let post;
async function toggleReaction(reaction) {
if (!$app || !$app.token) return;
if (
reaction.name.includes('@') &&
!$server.capabilities.includes(capabilities.FOREIGN_REACTIONS)
) return;
let data;
if (reaction.me)
data = await api.unreactPost($server.host, $app.token, post.id, reaction.name);
else
data = await api.reactPost($server.host, $app.token, post.id, reaction.name);
if (!data) {
console.error(`Failed to favourite post ${post.id}`);
return;
}
post.favourited = data.favourited;
post.favourite_count = data.favourites_count;
if (data.reactions) post.reactions = parseReactions(data.reactions);
}
</script> </script>
<div class="post-reactions" aria-label="Reactions" on:mouseup|stopPropagation on:keydown|stopPropagation> <div class="post-reactions" aria-label="Reactions" on:mouseup|stopPropagation on:keydown|stopPropagation>
@ -12,7 +41,7 @@
on:click={() => toggleReaction(reaction)} on:click={() => toggleReaction(reaction)}
bind:active={reaction.me} bind:active={reaction.me}
bind:count={reaction.count} bind:count={reaction.count}
disabled={reaction.name.includes('@')} disabled={!$account || (reaction.name.includes('@') && !$server.capabilities.includes(capabilities.FOREIGN_REACTIONS))}
title={reaction.name} title={reaction.name}
label=""> label="">
{#if reaction.url} {#if reaction.url}
@ -39,6 +68,6 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 2px; gap: 4px;
} }
</style> </style>

View file

@ -1,5 +1,5 @@
<script> <script>
import { play_sound } from '../../sound.js'; import { playSound } from '../../sound.js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -12,7 +12,7 @@
export let sound = "default"; export let sound = "default";
function click() { function click() {
play_sound(sound); playSound(sound);
dispatch('click'); dispatch('click');
} }
</script> </script>
@ -49,6 +49,7 @@
border-radius: 8px; border-radius: 8px;
transition: background-color .1s, color .1s; transition: background-color .1s, color .1s;
cursor: pointer; cursor: pointer;
border: 1px solid var(--bg-700);
} }
button.active { button.active {
@ -72,7 +73,7 @@
} }
.icon { .icon {
width: 20px; min-width: 20px;
height: 20px; height: 20px;
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -1,18 +1,15 @@
<script> <script>
import { parseText as parseEmojis, parseOne as parseEmoji } from '../../emoji.js'; import { server } from '$lib/client/server';
import { shorthand as short_time } from '../../time.js';
import * as api from '../../client/api.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import PostHeader from './PostHeader.svelte'; import PostHeader from './PostHeader.svelte';
import Body from './Body.svelte'; import Body from './Body.svelte';
import Post from './Post.svelte';
import ActionBar from './ActionBar.svelte'; import ActionBar from './ActionBar.svelte';
import ReactionBar from './ReactionBar.svelte'; import ReactionBar from './ReactionBar.svelte';
export let post; export let post;
let time_string = post.created_at.toLocaleString(); let time_string = post.created_at.toLocaleString();
let aria_label = post.user.username + '; ' + post.text + '; ' + post.created_at; let aria_label = post.account.username + '; ' + post.text + '; ' + post.created_at;
let mouse_pos = { top: 0, left: 0 }; let mouse_pos = { top: 0, left: 0 };
@ -25,7 +22,7 @@
event.ctrlKey)) return; event.ctrlKey)) return;
if (event.key && event.key !== "Enter") return; if (event.key && event.key !== "Enter") return;
} }
goto(`/post/${post.id}`); goto(`/${$server.host}/${post.account.mention}/${post.id}`);
} }
</script> </script>

View file

@ -1,29 +0,0 @@
import { client } from '../client/client.js';
import { parseText as parseEmojis } from '../emoji.js';
import { get } from 'svelte/store';
export default class User {
id;
nickname;
username;
host;
avatar_url;
emojis;
url;
get name() {
return this.nickname || this.username;
}
get mention() {
let res = "@" + this.username;
if (this.host != get(client).instance.host)
res += "@" + this.host;
return res;
}
get rich_name() {
if (!this.nickname) return this.username;
return parseEmojis(this.nickname, this.host);
}
}

View file

@ -1,49 +1,52 @@
<script> <script>
import '$lib/app.css'; import '$lib/app.css';
import Navigation from '$lib/ui/Navigation.svelte'; import * as api from '$lib/api.js';
import Widgets from '$lib/ui/Widgets.svelte'; import { server } from '$lib/client/server.js';
import { client, Client } from '$lib/client/client.js'; import { app } from '$lib/client/app.js';
import { user, getUser } from '$lib/stores/user.js'; import { account } from '$lib/stores/account.js';
import { get } from 'svelte/store'; import { parseAccount } from '$lib/account.js';
import { logged_in } from '$lib/stores/user.js';
import { unread_notif_count, last_read_notif_id } from '$lib/notifications.js'; import { unread_notif_count, last_read_notif_id } from '$lib/notifications.js';
let ready = new Promise(resolve => { import Navigation from '$lib/ui/Navigation.svelte';
if (get(client)) { import Modal from '@cf/ui/Modal.svelte';
if (get(user)) logged_in.set(true); import Composer from '@cf/ui/Composer.svelte';
return resolve(); import Widgets from '$lib/ui/Widgets.svelte';
let show_composer = false;
async function init() {
if (!$app || !$app.token) {
account.set(false);
return;
} }
let new_client = new Client();
new_client.load();
client.set(new_client);
return getUser().then(new_user => { // logged in- attempt to retrieve using token
if (!new_user) return resolve(); const data = await api.verifyCredentials($server.host, $app.token);
if (!data) return;
logged_in.set(true); account.set(parseAccount(data));
user.set(new_user); console.log(`Logged in as @${$account.username}@${$account.host}`);
// spin up async task to fetch notifications // spin up async task to fetch notifications
get(client).getNotifications( const notif_data = await api.getNotifications(
get(last_read_notif_id) $server.host,
).then(notif_data => { $app.token,
if (!notif_data) return; $last_read_notif_id
unread_notif_count.set(notif_data.length); );
});
return resolve(); if (!notif_data) return;
});
}); unread_notif_count.set(notif_data.length);
};
</script> </script>
<div id="app"> <div id="app">
<header> <header>
<Navigation /> <Navigation on:compose={() => show_composer = true} />
</header> </header>
<main> <main>
{#await ready} {#await init()}
<div class="loading throb"> <div class="loading throb">
<span>just a moment...</span> <span>just a moment...</span>
</div> </div>
@ -56,6 +59,9 @@
<Widgets /> <Widgets />
</div> </div>
<Modal bind:visible={show_composer}>
<Composer on:compose_finished={() => show_composer = false }/>
</Modal>
</div> </div>
<style> <style>

View file

@ -1,43 +1,52 @@
<script> <script>
import { page } from '$app/stores'; import { page } from '$app/stores';
import { get } from 'svelte/store'; import { account } from '$lib/stores/account.js';
import { logged_in } from '$lib/stores/user.js';
import { timeline, getTimeline } from '$lib/timeline.js'; import { timeline, getTimeline } from '$lib/timeline.js';
import LoginForm from '$lib/ui/LoginForm.svelte'; import LoginForm from '$lib/ui/LoginForm.svelte';
import Feed from '$lib/ui/Feed.svelte';
import User from '$lib/user/user.js';
import Button from '$lib/ui/Button.svelte'; import Button from '$lib/ui/Button.svelte';
import Post from '$lib/ui/post/Post.svelte';
logged_in.subscribe(logged_in => { account.subscribe(account => {
if (logged_in) getTimeline(); if (account) getTimeline();
}); });
document.addEventListener("scroll", event => {
if (get(logged_in) && get(page).url.pathname !== "/") return; document.addEventListener("scroll", () => {
if ($account && $page.url.pathname !== "/") return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) { if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
getTimeline(); getTimeline();
} }
}); });
</script> </script>
{#if $logged_in} {#if $account}
<Feed posts={$timeline} /> <header>
<h1>Home</h1>
<nav>
<Button centered active>Home</Button>
<Button centered disabled>Local</Button>
<Button centered disabled>Federated</Button>
</nav>
</header>
<div id="feed" role="feed">
{#if $timeline.length <= 0}
<div class="loading throb">
<span>getting the feed...</span>
</div>
{/if}
{#each $timeline as post}
<Post post_data={post} />
{/each}
</div>
{:else} {:else}
<LoginForm /> <LoginForm />
{/if} {/if}
<style> <style>
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
header { header {
width: 100%; width: 100%;
height: 64px;
margin: 16px 0 8px 0; margin: 16px 0 8px 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -47,16 +56,24 @@
font-size: 1.5em; font-size: 1.5em;
} }
header nav { nav {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 8px; gap: 8px;
} }
:global(.app-logo) { #feed {
height: auto; margin-bottom: 20vh;
width: 320px; }
margin: 8px;
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
} }
</style> </style>

View file

@ -1,5 +1,5 @@
export async function load({ params }) { export async function load({ params }) {
return { return {
post_id: params.id server_domain: params.server
}; };
} }

View file

@ -0,0 +1,8 @@
import { error } from '@sveltejs/kit';
export async function load({ params }) {
return error(404, 'Not Found');
// return {
// account_name: params.account
// };
}

View file

@ -0,0 +1,7 @@
export async function load({ params }) {
return {
server_host: params.server,
account_handle: params.account,
post_id: params.post
};
}

View file

@ -0,0 +1,146 @@
<script>
import * as api from '$lib/api.js';
import { server, createServer } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { parsePost } from '$lib/post.js';
import { goto, afterNavigate } from '$app/navigation';
import { base } from '$app/paths'
import Post from '$lib/ui/post/Post.svelte';
import Button from '$lib/ui/Button.svelte';
export let data;
let post = fetchPost(data.post_id);
let error = false;
let previous_page = base;
afterNavigate(({from}) => {
previous_page = from?.url.pathname || previous_page;
post = fetchPost(data.post_id);
})
async function fetchPost(post_id) {
let token = $app ? $app.token : null;
if (!$server || $server.host !== data.server_host) {
// this will probably break in the odd case the user is logged in
// while accessing a URI paired to another instance, as it
// overrides the current server state to match the new target.
// TODO: make `server` a key/value pair to support multiple servers
server.set(await createServer(data.server_host));
if (!$server) {
error = `Failed to connect to <code>${data.server_host}</code>.`;
console.error(`Failed to connect to ${data.server_host}.`);
return;
}
}
const post_data = await api.getPost($server.host, token, post_id);
if (!post_data || post_data.error) {
error = `Failed to retrieve post <code>${post_id}</code>.`;
console.error(`Failed to retrieve post ${post_id}.`);
return;
}
let post = await parsePost(post_data, 0);
const post_context = await api.getPostContext($server.host, token, post_id);
if (!post_context || !post_context.ancestors || !post_context.descendants)
return post;
// handle ancestors (above post)
let thread_top = post;
while (post_context.ancestors.length > 0) {
thread_top.reply = await parsePost(post_context.ancestors.pop(), 0);
thread_top = thread_top.reply;
}
// handle descendants (below post)
post.replies = [];
for (let i in post_context.descendants) {
post.replies.push(parsePost(post_context.descendants[i], 0));
}
return post;
}
</script>
{#await post}
<div class="loading throb">
<span>loading post...</span>
</div>
{:then post}
{#if error}
<p>{@html error}</p>
{:else}
<header>
{#if previous_page}
<nav>
<Button centered on:click={() => {goto(previous_page)}}>Back</Button>
</nav>
{/if}
<img src={post.account.avatar_url} type={post.account.avatar_type || "image/png"} alt="" width="40" height="40" class="header-avatar" loading="lazy" decoding="async">
<h1>
Post by {@html post.account.rich_name}
</h1>
</header>
<div id="feed" role="feed">
<Post post_data={post} focused />
<br>
{#each post.replies as reply}
{#await reply then reply}
<Post post_data={reply} />
{/await}
{/each}
</div>
{/if}
{/await}
<style>
header {
width: 100%;
height: 64px;
margin: 16px 0 8px 0;
display: flex;
flex-direction: row;
}
header .header-avatar {
width: 40px;
height: 40px;
margin: auto 0;
border-radius: 4px;
}
header h1 {
margin: auto auto auto 8px;
font-size: 1.5em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
header nav {
margin-right: 8px;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
#feed {
margin-bottom: 20vh;
}
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
</style>

View file

@ -1,42 +1,46 @@
<script> <script>
import { client } from '$lib/client/client.js'; import * as api from '$lib/api.js';
import { server } from '$lib/client/server.js';
import { app } from '$lib/client/app.js';
import { parseAccount } from '$lib/account.js';
import { get } from 'svelte/store';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { get } from 'svelte/store'; import { unread_notif_count, last_read_notif_id } from '$lib/notifications.js';
import { last_read_notif_id } from '$lib/notifications.js'; import { account } from '$lib/stores/account.js';
import { logged_in, user, getUser } from '$lib/stores/user.js';
export let data; export let data;
let auth_code = data.code; let auth_code = data.code;
if (!auth_code) { if (!auth_code || !get(server) || !get(app)) {
error(400, { message: "Bad request" }); error(400, { message: "Bad request" });
} else { } else {
get(client).getToken(auth_code).then(token => { api.getToken(get(server).host, get(app).id, get(app).secret, auth_code).then(token => {
if (!token) { if (!token) {
error(400, { message: "Invalid auth code provided" }); error(400, { message: "Invalid auth code provided" });
return;
} }
client.update(c => { app.update(app => {
c.app.token = token; app.token = token;
c.save(); return app;
return c;
}); });
getUser().then(new_user => { api.verifyCredentials(get(server).host, get(app).token).then(data => {
if (!new_user) return; if (!data) return goto("/");
logged_in.set(true); account.set(parseAccount(data));
user.set(new_user); console.log(`Logged in as @${get(account).username}@${get(account).host}`);
return get(client).getNotifications( // spin up async task to fetch notifications
return api.getNotifications(
get(server).host,
get(app).token,
get(last_read_notif_id) get(last_read_notif_id)
).then(notif_data => { ).then(notif_data => {
unread_notif_count.set(0);
if (notif_data.constructor === Array && notif_data.length > 0) if (notif_data.constructor === Array && notif_data.length > 0)
last_read_notif_id.set(notif_data[0].id); last_read_notif_id.set(notif_data[0].id);
get(client).save();
goto("/"); goto("/");
}); });
}); });

View file

@ -1,21 +1,25 @@
<script> <script>
import { notifications, getNotifications } from '$lib/notifications.js'; import { notifications, last_read_notif_id, unread_notif_count, getNotifications } from '$lib/notifications.js';
import { logged_in } from '$lib/stores/user.js'; import { account } from '$lib/stores/account.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { get } from 'svelte/store'; import { page } from '$app/stores';
import Notification from '$lib/ui/Notification.svelte'; import Notification from '$lib/ui/Notification.svelte';
if (!get(logged_in)) goto("/"); if (!$account) goto("/");
getNotifications(); getNotifications().then(notif_data => {
/* if (notif_data && notif_data.constructor === Array) {
document.addEventListener("scroll", event => { last_read_notif_id.set(notif_data[0].id);
if (get(logged_in) && get(page).url.pathname !== "/") return; }
unread_notif_count.set(0);
});
document.addEventListener("scroll", () => {
if ($account && $page.url.pathname !== "/notifications") return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) { if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 2048) {
getNotifications(); let max_id = $notifications.length > 0 ? $notifications[$notifications.length - 1].id : null;
getNotifications(null, max_id);
} }
}); });
*/
</script> </script>
<header> <header>

View file

@ -1,5 +0,0 @@
import { error } from '@sveltejs/kit';
export function load(event) {
error(404, 'Not Found');
}

View file

@ -1,132 +0,0 @@
<script>
import { client } from '$lib/client/client.js';
import * as api from '$lib/client/api.js';
import { logged_in } from '$lib/stores/user.js';
import { get } from 'svelte/store';
import { goto, afterNavigate } from '$app/navigation';
import { base } from '$app/paths'
import Post from '$lib/ui/post/Post.svelte';
import Button from '$lib/ui/Button.svelte';
export let data;
let error = false;
if (!get(logged_in)) goto("/");
let previous_page = base;
afterNavigate(({from}) => {
previous_page = from?.url.pathname || previous_page
})
$: post = (async resolve => {
const post_data = await get(client).getPost(data.post_id, 0, false);
if (!post_data) {
error = `Failed to retrieve post <code>${data.post_id}</code>.`;
console.error(`Failed to retrieve post ${data.post_id}.`);
return;
}
let post = await api.parsePost(post_data, 0, false);
const post_context = await get(client).getPostContext(data.post_id);
if (!post_context || !post_context.ancestors || !post_context.descendants)
return post;
// handle ancestors (above post)
let thread_top = post;
while (post_context.ancestors.length > 0) {
thread_top.reply = await api.parsePost(post_context.ancestors.pop(), 0, false);
thread_top = thread_top.reply;
}
// handle descendants (below post)
post.replies = [];
for (let i in post_context.descendants) {
post.replies.push(
api.parsePost(post_context.descendants[i], 0, false)
);
}
return post;
})();
</script>
{#if !error}
<header>
{#await post then post}
<nav>
<Button centered on:click={() => {goto(previous_page)}}>Back</Button>
</nav>
<img src={post.user.avatar_url} type={post.user.avatar_type} alt="" width="40" height="40" class="header-avatar" loading="lazy" decoding="async">
<h1>
Post by {@html post.user.rich_name}
</h1>
{/await}
</header>
<div id="feed" role="feed">
{#await post}
<div class="loading throb">
<span>loading post...</span>
</div>
{:then post}
<Post post_data={post} focused />
<br>
{#each post.replies as reply}
{#await reply then reply}
<Post post_data={reply} />
{/await}
{/each}
{/await}
</div>
{:else}
<p>{@html error}</p>
{/if}
<style>
header {
width: 100%;
height: 64px;
margin: 16px 0 8px 0;
display: flex;
flex-direction: row;
}
header .header-avatar {
width: 40px;
height: 40px;
margin: auto 0;
border-radius: 4px;
}
header h1 {
margin: auto auto auto 8px;
font-size: 1.5em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
header nav {
margin-right: 8px;
display: flex;
flex-direction: row;
gap: 8px;
}
#feed {
margin-bottom: 20vh;
}
.loading {
width: 100%;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
font-size: 2em;
font-weight: bold;
}
</style>

View file

@ -17,6 +17,11 @@ const config = {
}), }),
version: { version: {
name: child_process.execSync('git rev-parse HEAD').toString().trim() name: child_process.execSync('git rev-parse HEAD').toString().trim()
},
alias: {
'@cf/ui/*': "./src/lib/ui",
'@cf/icons/*': "./src/img/icons",
'@cf/store/*': "./src/lib/stores"
} }
}, },
}; };