mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: migrate static content

+992 -135
+3
assets/flutter.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"> 2 + <path fill="currentColor" d="M6.746 14.856L3.866 12l9.307-9.308h5.737zm6.427 6.452l-5.021-5.021l5.021-5.002h5.737l-5.021 5.002l5.02 5.02z" /> 3 + </svg>
+3
assets/gh.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 640 640"> 2 + <path fill="currentColor" d="M237.9 461.4c0 2-2.3 3.6-5.2 3.6c-3.3.3-5.6-1.3-5.6-3.6c0-2 2.3-3.6 5.2-3.6c3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9c2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9c.3 2 2.9 3.3 5.9 2.6c2.9-.7 4.9-2.6 4.6-4.6c-.3-1.9-3-3.2-5.9-2.9M316.8 72C178.1 72 72 177.3 72 316c0 110.9 69.8 205.8 169.5 239.2c12.8 2.3 17.3-5.6 17.3-12.1c0-6.2-.3-40.4-.3-61.4c0 0-70 15-84.7-29.8c0 0-11.4-29.1-27.8-36.6c0 0-22.9-15.7 1.6-15.4c0 0 24.9 2 38.6 25.8c21.9 38.6 58.6 27.5 72.9 20.9c2.3-16 8.8-27.1 16-33.7c-55.9-6.2-112.3-14.3-112.3-110.5c0-27.5 7.6-41.3 23.6-58.9c-2.6-6.5-11.1-33.3 2.6-67.9c20.9-6.5 69 27 69 27c20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27c13.7 34.7 5.2 61.4 2.6 67.9c16 17.7 25.8 31.5 25.8 58.9c0 96.5-58.9 104.2-114.8 110.5c9.2 7.9 17 22.9 17 46.4c0 33.7-.3 75.4-.3 83.6c0 6.5 4.6 14.4 17.3 12.1C500.2 521.8 568 426.9 568 316c0-138.7-112.5-244-251.2-244M169.2 416.9c-1.3 1-1 3.3.7 5.2c1.6 1.6 3.9 2.3 5.2 1c1.3-1 1-3.3-.7-5.2c-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9c1.6 1 3.6.7 4.3-.7c.7-1.3-.3-2.9-2.3-3.9c-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2c2.3 2.3 5.2 2.6 6.5 1c1.3-1.3.7-4.3-1.3-6.2c-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2c-1.4-2.3-4-3.3-5.6-2" /> 3 + </svg>
+3
assets/logo.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 512 512"> 2 + <path fill="currentColor" d="M128 16v99.3l119 118.9V120.1zm256 0L265 120.1v114.1l119-119zM16 128l104 119h114.2L115.3 128zm380.8 0l-119 119h114.1l104-119zM120 265L16 384h99.2l119-119zm157.8 0l119 119h99.1l-104-119zM247 277.8l-119 119V496l119-104.1zm18 0v114.1L384 496v-99.2z" /> 3 + </svg>
+27
assets/tangled.svg
··· 1 + <svg xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg1" width="24.122343" height="23.274094" viewBox="0 0 24.122343 23.274094" sodipodi:docname="tangled_dolly_face_only.svg" inkscape:export-filename="tangled_dolly_face_only_white_on_trans.svg" inkscape:export-xdpi="96" inkscape:export-ydpi="96" inkscape:version="1.4 (e7c3feb100, 2024-10-09)"> 2 + <defs id="defs1"> 3 + <filter style="color-interpolation-filters:sRGB" inkscape:menu-tooltip="Fades hue progressively to white" inkscape:menu="Color" inkscape:label="Hue to White" id="filter24" x="0" y="0" width="1" height="1"> 4 + <feColorMatrix values="1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1 " type="matrix" result="r" in="SourceGraphic" id="feColorMatrix17"/> 5 + <feColorMatrix values="0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 " type="matrix" result="g" in="SourceGraphic" id="feColorMatrix18"/> 6 + <feColorMatrix values="0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 1 " type="matrix" result="b" in="SourceGraphic" id="feColorMatrix19"/> 7 + <feBlend result="minrg" in="r" mode="darken" in2="g" id="feBlend19"/> 8 + <feBlend result="p" in="minrg" mode="darken" in2="b" id="feBlend20"/> 9 + <feBlend result="maxrg" in="r" mode="lighten" in2="g" id="feBlend21"/> 10 + <feBlend result="q" in="maxrg" mode="lighten" in2="b" id="feBlend22"/> 11 + <feComponentTransfer result="q2" in="q" id="feComponentTransfer22"> 12 + <feFuncR slope="0" type="linear" id="feFuncR22"/> 13 + </feComponentTransfer> 14 + <feBlend result="pq" in="p" mode="lighten" in2="q2" id="feBlend23"/> 15 + <feColorMatrix values="-1 1 0 0 0 -1 1 0 0 0 -1 1 0 0 0 0 0 0 0 1 " type="matrix" result="qminp" in="pq" id="feColorMatrix23"/> 16 + <feComposite k3="1" operator="arithmetic" result="qminpc" in="qminp" in2="qminp" id="feComposite23" k1="0" k2="0" k4="0"/> 17 + <feBlend result="result2" in2="SourceGraphic" mode="screen" id="feBlend24"/> 18 + <feComposite operator="in" in="result2" in2="SourceGraphic" result="result1" id="feComposite24"/> 19 + </filter> 20 + </defs> 21 + <sodipodi:namedview id="namedview1" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="true" inkscape:deskcolor="#d5d5d5" inkscape:zoom="7.0916564" inkscape:cx="38.84847" inkscape:cy="31.515909" inkscape:window-width="1920" inkscape:window-height="1080" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="0" inkscape:current-layer="g1"> 22 + <inkscape:page x="0" y="0" width="24.122343" height="23.274094" id="page2" margin="0" bleed="0"/> 23 + </sodipodi:namedview> 24 + <g inkscape:groupmode="layer" inkscape:label="Image" id="g1" transform="translate(-0.4388285,-0.8629527)"> 25 + <path style="fill:#000000;fill-opacity:1;stroke-width:0.111183;filter:url(#filter24)" d="m 16.348974,24.09935 -0.06485,-0.03766 -0.202005,-0.0106 -0.202008,-0.01048 -0.275736,-0.02601 -0.275734,-0.02602 v -0.02649 -0.02648 l -0.204577,-0.04019 -0.204578,-0.04019 -0.167616,-0.08035 -0.167617,-0.08035 -0.0014,-0.04137 -0.0014,-0.04137 -0.266473,-0.143735 -0.266475,-0.143735 -0.276098,-0.20335 -0.2761,-0.203347 -0.262064,-0.251949 -0.262064,-0.25195 -0.22095,-0.284628 -0.220948,-0.284629 -0.170253,-0.284631 -0.170252,-0.284628 -0.01341,-0.0144 -0.0134,-0.0144 -0.141982,0.161297 -0.14198,0.1613 -0.22313,0.21426 -0.223132,0.214264 -0.186025,0.146053 -0.186023,0.14605 -0.252501,0.163342 -0.252502,0.163342 -0.249014,0.115348 -0.249013,0.115336 0.0053,0.03241 0.0053,0.03241 -0.1716725,0.04599 -0.171669,0.046 -0.3379966,0.101058 -0.3379972,0.101058 -0.1778925,0.04506 -0.1778935,0.04508 -0.3913655,0.02601 -0.3913643,0.02603 -0.3557868,-0.03514 -0.3557863,-0.03514 -0.037426,-0.03029 -0.037427,-0.03029 -0.076924,0.02011 -0.076924,0.02011 -0.050508,-0.05051 -0.050405,-0.05056 L 6.6604532,23.110188 6.451745,23.063961 6.1546135,22.960559 5.8574835,22.857156 5.5319879,22.694039 5.2064938,22.530922 4.8793922,22.302961 4.5522905,22.075005 4.247598,21.786585 3.9429055,21.49817 3.7185335,21.208777 3.4941628,20.919385 3.3669822,20.705914 3.239803,20.492443 3.1335213,20.278969 3.0272397,20.065499 2.9015252,19.7275 2.7758105,19.389504 2.6925225,18.998139 2.6092345,18.606774 2.6096814,17.91299 2.6101284,17.219208 2.6744634,16.90029 2.7387984,16.581374 2.8474286,16.242088 2.9560588,15.9028 3.1137374,15.583492 3.2714148,15.264182 3.3415068,15.150766 3.4115988,15.03735 3.3127798,14.96945 3.2139618,14.90157 3.0360685,14.800239 2.8581753,14.698908 2.5913347,14.503228 2.3244955,14.307547 2.0621238,14.055599 1.7997507,13.803651 1.6111953,13.56878 1.4226411,13.333906 1.2632237,13.087474 1.1038089,12.841042 0.97442,12.575195 0.8450307,12.30935 0.724603,11.971351 0.6041766,11.633356 0.52150365,11.241991 0.4388285,10.850626 0.44091592,10.156842 0.44300333,9.4630594 0.54235911,9.0369608 0.6417149,8.6108622 0.7741173,8.2694368 0.9065196,7.9280115 1.0736303,7.6214262 1.2407515,7.3148397 1.45931,7.0191718 1.6778685,6.7235039 1.9300326,6.4611321 2.1821966,6.1987592 2.4134579,6.0137228 2.6447193,5.8286865 2.8759792,5.6776409 3.1072406,5.526594 3.4282004,5.3713977 3.7491603,5.2162016 3.9263009,5.1508695 4.1034416,5.0855373 4.2813348,4.7481598 4.4592292,4.4107823 4.6718,4.108422 4.8843733,3.8060618 5.198353,3.4805372 5.5123313,3.155014 5.7685095,2.9596425 6.0246877,2.7642722 6.329187,2.5851365 6.6336863,2.406002 6.9497657,2.2751596 7.2658453,2.1443184 7.4756394,2.0772947 7.6854348,2.01027 8.0825241,1.931086 8.4796139,1.851902 l 0.5870477,0.00291 0.5870469,0.00291 0.4447315,0.092455 0.444734,0.092455 0.302419,0.1105495 0.302417,0.1105495 0.329929,0.1646046 0.32993,0.1646033 0.239329,-0.2316919 0.239329,-0.2316919 0.160103,-0.1256767 0.160105,-0.1256767 0.160102,-0.1021909 0.160105,-0.1021899 0.142315,-0.082328 0.142314,-0.082328 0.231262,-0.1090091 0.231259,-0.1090091 0.26684,-0.098743 0.266839,-0.098743 0.320208,-0.073514 0.320209,-0.073527 0.355787,-0.041833 0.355785,-0.041834 0.426942,0.023827 0.426945,0.023828 0.355785,0.071179 0.355788,0.0711791 0.284627,0.09267 0.284629,0.09267 0.28514,0.1310267 0.28514,0.1310255 0.238179,0.1446969 0.238174,0.1446979 0.259413,0.1955332 0.259413,0.1955319 0.290757,0.296774 0.290758,0.2967753 0.151736,0.1941581 0.151734,0.1941594 0.135326,0.2149951 0.135327,0.2149952 0.154755,0.3202073 0.154758,0.3202085 0.09409,0.2677358 0.09409,0.267737 0.06948,0.3319087 0.06948,0.3319099 0.01111,0.00808 0.01111,0.00808 0.444734,0.2173653 0.444734,0.2173665 0.309499,0.2161102 0.309497,0.2161101 0.309694,0.2930023 0.309694,0.2930037 0.18752,0.2348726 0.187524,0.2348727 0.166516,0.2574092 0.166519,0.2574108 0.15273,0.3260252 0.152734,0.3260262 0.08972,0.2668403 0.08971,0.2668391 0.08295,0.3913655 0.08295,0.3913652 -6.21e-4,0.6582049 -6.21e-4,0.658204 -0.06362,0.315725 -0.06362,0.315725 -0.09046,0.289112 -0.09046,0.289112 -0.122759,0.281358 -0.12276,0.281356 -0.146626,0.252323 -0.146629,0.252322 -0.190443,0.258668 -0.190448,0.258671 -0.254911,0.268356 -0.254911,0.268355 -0.286872,0.223127 -0.286874,0.223127 -0.320203,0.187693 -0.320209,0.187693 -0.04347,0.03519 -0.04347,0.03521 0.0564,0.12989 0.0564,0.129892 0.08728,0.213472 0.08728,0.213471 0.189755,0.729363 0.189753,0.729362 0.0652,0.302417 0.0652,0.302419 -0.0018,0.675994 -0.0018,0.675995 -0.0801,0.373573 -0.08009,0.373577 -0.09,0.266839 -0.09,0.26684 -0.190389,0.391364 -0.19039,0.391366 -0.223169,0.320207 -0.223167,0.320209 -0.303585,0.315294 -0.303584,0.315291 -0.284631,0.220665 -0.284629,0.220663 -0.220128,0.132359 -0.220127,0.132358 -0.242395,0.106698 -0.242394,0.106699 -0.08895,0.04734 -0.08895,0.04733 -0.249052,0.07247 -0.24905,0.07247 -0.322042,0.0574 -0.322044,0.0574 -0.282794,-0.003 -0.282795,-0.003 -0.07115,-0.0031 -0.07115,-0.0031 -0.177894,-0.0033 -0.177893,-0.0033 -0.124528,0.02555 -0.124528,0.02555 z m -4.470079,-5.349839 0.214838,-0.01739 0.206601,-0.06782 0.206602,-0.06782 0.244389,-0.117874 0.244393,-0.11786 0.274473,-0.206822 0.27447,-0.20682 0.229308,-0.257201 0.229306,-0.2572 0.219161,-0.28463 0.219159,-0.284629 0.188541,-0.284628 0.188543,-0.28463 0.214594,-0.373574 0.214593,-0.373577 0.133861,-0.312006 0.133865,-0.312007 0.02861,-0.01769 0.02861,-0.01769 0.197275,0.26212 0.197278,0.262119 0.163613,0.150814 0.163614,0.150814 0.201914,0.09276 0.201914,0.09276 0.302417,0.01421 0.302418,0.01421 0.213472,-0.08025 0.213471,-0.08025 0.200606,-0.204641 0.200606,-0.204642 0.09242,-0.278887 0.09241,-0.278888 0.05765,-0.302418 0.05764,-0.302416 L 18.41327,13.768114 18.39502,13.34117 18.31849,12.915185 18.24196,12.4892 18.15595,12.168033 18.06994,11.846867 17.928869,11.444534 17.787801,11.042201 17.621278,10.73296 17.454757,10.423723 17.337388,10.263619 17.220021,10.103516 17.095645,9.9837986 16.971268,9.8640816 16.990048,9.6813736 17.008828,9.4986654 16.947568,9.249616 16.886308,9.0005655 16.752419,8.7159355 16.618521,8.4313217 16.435707,8.2294676 16.252892,8.0276114 16.079629,7.9004245 15.906366,7.773238 l -0.20429,0.1230127 -0.204289,0.1230121 -0.26702,0.059413 -0.267022,0.059413 -0.205761,-0.021508 -0.205766,-0.021508 -0.23495,-0.08844 -0.234953,-0.08844 -0.118429,-0.090334 -0.118428,-0.090333 h -0.03944 -0.03944 L 13.711268,7.8540732 13.655958,7.9706205 13.497227,8.1520709 13.338499,8.3335203 13.168394,8.4419112 12.998289,8.550301 12.777045,8.624223 12.5558,8.698155 H 12.275611 11.995429 L 11.799973,8.6309015 11.604513,8.5636472 11.491311,8.5051061 11.37811,8.446565 11.138172,8.2254579 10.898231,8.0043497 l -0.09565,-0.084618 -0.09565,-0.084613 -0.218822,0.198024 -0.218822,0.1980231 -0.165392,0.078387 -0.1653925,0.078387 -0.177894,0.047948 -0.177892,0.047948 L 9.3635263,8.4842631 9.144328,8.4846889 8.9195029,8.4147138 8.6946778,8.3447386 8.5931214,8.4414036 8.491565,8.5380686 8.3707618,8.7019598 8.2499597,8.8658478 8.0802403,8.9290726 7.9105231,8.9922974 7.7952769,9.0780061 7.6800299,9.1637148 7.5706169,9.2778257 7.4612038,9.3919481 7.1059768,9.9205267 6.7507497,10.449105 l -0.2159851,0.449834 -0.2159839,0.449834 -0.2216572,0.462522 -0.2216559,0.462523 -0.1459343,0.337996 -0.1459342,0.337998 -0.055483,0.220042 -0.055483,0.220041 -0.015885,0.206903 -0.015872,0.206901 0.034307,0.242939 0.034307,0.24294 0.096281,0.196632 0.096281,0.196634 0.143607,0.125222 0.1436071,0.125222 0.1873143,0.08737 0.1873141,0.08737 0.2752084,0.002 0.2752084,0.002 0.2312297,-0.09773 0.231231,-0.09772 0.1067615,-0.07603 0.1067614,-0.07603 0.3679062,-0.29377 0.3679065,-0.293771 0.026804,0.01656 0.026804,0.01656 0.023626,0.466819 0.023626,0.466815 0.088326,0.513195 0.088326,0.513193 0.08897,0.364413 0.08897,0.364411 0.1315362,0.302418 0.1315352,0.302418 0.1051964,0.160105 0.1051954,0.160103 0.1104741,0.11877 0.1104731,0.118769 0.2846284,0.205644 0.2846305,0.205642 0.144448,0.07312 0.144448,0.07312 0.214787,0.05566 0.214787,0.05566 0.245601,0.03075 0.245602,0.03075 0.204577,-0.0125 0.204578,-0.0125 z m 0.686342,-3.497495 -0.11281,-0.06077 -0.106155,-0.134033 -0.106155,-0.134031 -0.04406,-0.18371 -0.04406,-0.183707 0.02417,-0.553937 0.02417,-0.553936 0.03513,-0.426945 0.03513,-0.426942 0.07225,-0.373576 0.07225,-0.373575 0.05417,-0.211338 0.05417,-0.211339 0.0674,-0.132112 0.0674,-0.132112 0.132437,-0.10916 0.132437,-0.109161 0.187436,-0.04195 0.187438,-0.04195 0.170366,0.06469 0.170364,0.06469 0.114312,0.124073 0.114313,0.124086 0.04139,0.18495 0.04139,0.184951 -0.111218,0.459845 -0.111219,0.459844 -0.03383,0.26584 -0.03382,0.265841 -0.03986,0.818307 -0.03986,0.818309 -0.0378,0.15162 -0.03779,0.151621 -0.11089,0.110562 -0.110891,0.110561 -0.114489,0.04913 -0.114489,0.04913 -0.187932,-0.0016 -0.187929,-0.0016 z m -2.8087655,-0.358124 -0.146445,-0.06848 -0.088025,-0.119502 -0.088024,-0.119502 -0.038581,-0.106736 -0.038581,-0.106736 -0.02237,-0.134956 -0.02239,-0.134957 -0.031955,-0.46988 -0.031955,-0.469881 0.036203,-0.444733 0.036203,-0.444731 0.048862,-0.215257 0.048862,-0.215255 0.076082,-0.203349 0.076081,-0.203348 0.0936,-0.111244 0.0936,-0.111245 0.143787,-0.06531 0.1437865,-0.06532 h 0.142315 0.142314 l 0.142314,0.06588 0.142316,0.06588 0.093,0.102325 0.093,0.102325 0.04042,0.120942 0.04042,0.120942 v 0.152479 0.152477 l -0.03347,0.08804 -0.03347,0.08805 -0.05693,0.275653 -0.05693,0.275651 2.11e-4,0.430246 2.12e-4,0.430243 0.04294,0.392646 0.04295,0.392647 -0.09189,0.200702 -0.09189,0.200702 -0.148688,0.0984 -0.148687,0.0984 -0.20136,0.01212 -0.2013595,0.01212 z" id="path4"/> 26 + </g> 27 + </svg>
+3
assets/typography.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"> 2 + <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m2.25 18.25l1.875-4.404m0 0h6.75m-6.75 0L6.998 7.1a.536.536 0 0 1 1.004 0l2.873 6.747m0 0l1.875 4.404m9-4.948l-.07-.014c-2.88-.557-6.308-.472-6.308 2.462c0 .824.464 1.598 1.14 2.02c.67.417 1.515.429 2.305.429c1.086 0 2.277-.626 2.754-1.602q.137-.279.179-.633V14.84m0-1.537v-.587c0-.734-.085-1.704-.517-2.297c-.47-.646-1.303-1.375-2.672-1.375c-2.261 0-2.991 1.595-2.991 1.595m6.18 2.664v1.537m0 3.26v-3.26" /> 3 + </svg>
+116 -95
docs/specs/phase-3.md
··· 119 119 Build a `NotificationBloc` with events: `NotificationsRequested`, 120 120 `NotificationsRefreshed`, `NotificationsPageLoaded`, `NotificationsMarkedRead`. 121 121 122 - ## Direct Messages 122 + ## Post & Profile Actions 123 123 124 - DMs use the `chat.bsky.*` lexicon namespace. The DM feature has two views: a 125 - conversation list and a message thread. 124 + All post and profile interactions use the AT Protocol record model. Actions 125 + that create a relationship (like, repost, follow, block) write a record via 126 + `com.atproto.repo.createRecord` and undo by deleting via 127 + `com.atproto.repo.deleteRecord`. Muting is a server-side procedure call with 128 + no persistent record. 126 129 127 130 ### API 128 131 129 - | Endpoint | Purpose | 130 - | -------------------------------------- | ----------------------------- | 131 - | `chat.bsky.convo.listConvos` | Paginated conversation list | 132 - | `chat.bsky.convo.getConvo` | Single conversation metadata | 133 - | `chat.bsky.convo.getMessages` | Paginated messages in a convo | 134 - | `chat.bsky.convo.sendMessage` | Send a message | 135 - | `chat.bsky.convo.deleteMessageForSelf` | Delete a message locally | 136 - | `chat.bsky.convo.muteConvo` | Mute a conversation | 137 - | `chat.bsky.convo.unmuteConvo` | Unmute a conversation | 138 - | `chat.bsky.convo.updateRead` | Mark conversation as read | 139 - | `chat.bsky.convo.getLog` | Polling for new events | 132 + | Endpoint | Purpose | 133 + | ------------------------------------- | --------------------------------------- | 134 + | `com.atproto.repo.createRecord` | Create like/repost/follow/block records | 135 + | `com.atproto.repo.deleteRecord` | Delete like/repost/follow/block records | 136 + | `app.bsky.graph.muteActor` | Mute an account | 137 + | `app.bsky.graph.unmuteActor` | Unmute an account | 138 + | `com.atproto.moderation.createReport` | Report a post or account | 140 139 141 - ### Conversation List 140 + ### Like 142 141 143 - `listConvos` returns conversations sorted by last message time. Each convo 144 - includes `id`, `members` (array of `profileViewBasic`), `lastMessage`, 145 - `unreadCount`, `muted`. 142 + Collection: `app.bsky.feed.like`. Record contains a `subject` (RepoStrongRef 143 + with the post's AT-URI and CID) and `createdAt`. 146 144 147 - Filter conversations into two tabs: **Primary** (accepted) and **Requests** 148 - (conversations the user has not yet responded to). A conversation is a 149 - "request" if the user has never sent a message in it. 145 + To unlike, extract the record key (rkey) from the `viewer.like` AT-URI and 146 + call `deleteRecord` with collection `app.bsky.feed.like` and that rkey. 150 147 151 - ### Message Thread 148 + The `PostView.viewer.like` field is non-null when the current user has liked 149 + the post. Use this to drive the filled/outlined heart icon state. 152 150 153 - `getMessages` returns paginated `messageView` objects. Each message has `id`, 154 - `text`, `sender` (DID), `sentAt`. Messages are displayed in a standard chat 155 - bubble layout — the current user's messages right-aligned, others left-aligned. 151 + ### Repost 156 152 157 - Support long-press to copy individual messages. Provide a "Copy All" option in 158 - the conversation overflow menu to copy the full thread. 153 + Collection: `app.bsky.feed.repost`. Record structure is identical to like — 154 + `subject` (RepoStrongRef) + `createdAt`. 159 155 160 - ### Sending Messages 156 + To un-repost, extract the rkey from `viewer.repost` and delete the record. 161 157 162 - `sendMessage` takes `convoId` and `message` (object with `text`). To start a 163 - new conversation, the app calls `chat.bsky.convo.getConvoForMembers` with the 164 - target DID(s) — this returns an existing convo or creates a new one. 158 + The `PostView.viewer.repost` field is non-null when the current user has 159 + reposted. Use this for the repost icon state. 160 + 161 + ### Follow 162 + 163 + Collection: `app.bsky.graph.follow`. Record contains `subject` (the target 164 + user's DID as a string) and `createdAt`. 165 + 166 + To unfollow, extract the rkey from `viewer.following` and delete the record. 167 + 168 + Viewer state fields on profiles: 169 + 170 + - `viewer.following` — non-null AT-URI if the current user follows this profile 171 + - `viewer.followedBy` — non-null AT-URI if this profile follows the current user 172 + 173 + ### Mute 174 + 175 + Mute and unmute are procedure calls (not record creation): 176 + 177 + - `app.bsky.graph.muteActor` — input: `{ actor: DID }` 178 + - `app.bsky.graph.unmuteActor` — input: `{ actor: DID }` 179 + 180 + Both return empty responses. The `viewer.muted` boolean on profiles reflects 181 + the current mute state. Muted accounts' posts are still fetched but should be 182 + visually de-emphasised or filtered in the UI based on user preference. 183 + 184 + ### Block 165 185 166 - | Endpoint | Purpose | 167 - | ------------------------------------ | --------------------- | 168 - | `chat.bsky.convo.getConvoForMembers` | Get or create a convo | 186 + Collection: `app.bsky.graph.block`. Record contains `subject` (the target 187 + user's DID) and `createdAt`. 169 188 170 - Build a `ConvoListBloc` with events: `ConvosRequested`, `ConvosRefreshed`, 171 - `ConvoMuted`, `ConvoUnmuted`. 189 + To unblock, extract the rkey from `viewer.blocking` and delete the record. 172 190 173 - Build a `MessageBloc` with events: `MessagesRequested`, `MessagesPageLoaded`, 174 - `MessageSent`, `MessageDeleted`, `ConvoMarkedRead`. 191 + Viewer state fields on profiles: 175 192 176 - ## Account Switching 193 + - `viewer.blocking` — non-null AT-URI if the current user blocks this profile 194 + - `viewer.blockedBy` — boolean, true if this profile blocks the current user 195 + 196 + When a user is blocked, their posts should be hidden from feeds and threads. 197 + Display a "You have blocked this user" placeholder in their profile view. 198 + 199 + ### Report 200 + 201 + Reports use `com.atproto.moderation.createReport` with two subject types: 177 202 178 - Support multiple authenticated accounts with full data isolation. The 179 - `accounts` table (from Phase 1) already supports multiple rows keyed by DID. 203 + | Subject Type | Usage | Fields | 204 + | --------------- | ---------------------- | ------------- | 205 + | `RepoStrongRef` | Report a specific post | `uri` + `cid` | 206 + | `RepoRef` | Report an account | `did` | 180 207 181 - ### Active Account 208 + Report reasons (from `com.atproto.moderation.defs`): 182 209 183 - Store the active account DID in the Drift `settings` table under key 184 - `active_account_did`. On launch, read this value and restore the session for 185 - that account. 210 + | Reason | Description | 211 + | ------------------ | --------------------------------- | 212 + | `reasonSpam` | Spam or unsolicited content | 213 + | `reasonViolation` | Violates community guidelines | 214 + | `reasonMisleading` | Misleading or deceptive content | 215 + | `reasonSexual` | Unwanted sexual content | 216 + | `reasonRude` | Harassment or rude behaviour | 217 + | `reasonOther` | Other (requires text explanation) | 186 218 187 - ### Data Isolation 219 + The report dialog should present the reason picker and an optional free-text 220 + description field. Submitting returns a report ID for confirmation. 188 221 189 - All user-scoped tables must include an `account_did` FK column. Queries always 190 - filter by the active account's DID. Tables requiring this constraint: 222 + ### Optimistic Updates 191 223 192 - - `drafts` 193 - - `saved_posts` 194 - - `search_history` (Phase 2) 195 - - `cached_posts` (add `account_did` if not present) 224 + All toggle actions (like, repost, follow, mute, block) should use optimistic 225 + UI updates: 196 226 197 - ### Switching Flow 227 + 1. Immediately update the local state (icon, count, button label). 228 + 2. Fire the API call in the background. 229 + 3. On success, reconcile with the server response (update the viewer URI). 230 + 4. On failure, roll back the local state and show a snackbar error. 198 231 199 - 1. User opens account switcher (bottom sheet or settings). 200 - 2. Selects a different account. 201 - 3. App updates `active_account_did` in settings. 202 - 4. All Blocs receive a `AccountSwitched` event and reload their state for the 203 - new account. 204 - 5. If the selected account's tokens are expired, attempt silent refresh. If 205 - refresh fails, navigate to login. 232 + Build a `PostActionCubit` that manages per-post action state (like, repost, 233 + save). It accepts the initial `ViewerState` from the post and exposes 234 + toggleable methods. 206 235 207 - ### Adding Accounts 236 + Build a `ProfileActionCubit` that manages per-profile action state (follow, 237 + mute, block). It accepts the initial `ViewerState` from the profile and 238 + exposes toggleable methods. 208 239 209 - "Add Account" triggers the same OAuth flow from Phase 1. On success, a new row 210 - is inserted into `accounts`. The new account becomes the active account. 240 + ### Post Action Bar 211 241 212 - Build an `AccountSwitcherCubit` that exposes the list of accounts and the 213 - active DID. 242 + The post action bar appears below every post and contains four buttons: 214 243 215 - ## Offline Reading 244 + | Button | Icon | Tap action | Long-press | 245 + | ------ | ------ | ------------- | ----------------- | 246 + | Reply | chat | Open compose | — | 247 + | Repost | repeat | Toggle repost | Quote post option | 248 + | Like | heart | Toggle like | — | 249 + | Share | share | Share sheet | — | 216 250 217 - The app should render cached data when the network is unavailable. This builds 218 - on the `cached_posts` and `cached_profiles` tables from Phase 1. 251 + The bookmark (save) icon is placed in the post overflow menu alongside 252 + "Report" and "Copy link". 219 253 220 - ### Cache Strategy 254 + Like and repost counts are displayed next to their respective icons. Counts 255 + update optimistically. The repost long-press opens a bottom sheet with 256 + "Repost" and "Quote Post" options. 221 257 222 - Cache the last-fetched page of each feed (timeline, pinned generators) in Drift 223 - as serialised JSON. On launch or feed switch, display cached data immediately, 224 - then fetch fresh data in the background. If the fetch fails, keep showing the 225 - cache with a "You're offline" banner. 258 + ### Profile Action Buttons 226 259 227 - ### Offline Indicators 260 + The profile header shows a primary action button based on the relationship: 228 261 229 - - A persistent banner at the top of the screen when connectivity is lost. 230 - - Disable actions that require network (compose, like, repost, follow) and show 231 - a tooltip explaining why. 232 - - Notifications and DM screens show an empty state with "No connection" when 233 - offline and no cached data exists. 262 + | State | Button label | Tap action | 263 + | ---------------- | ------------ | -------------- | 264 + | Not following | "Follow" | Create follow | 265 + | Following | "Following" | Unfollow sheet | 266 + | Blocked by them | — | No button | 267 + | You blocked them | "Unblock" | Delete block | 234 268 235 - ### Network Detection 269 + The profile overflow menu (three-dot icon) contains: Mute / Unmute, Block / 270 + Unblock, Report, Copy DID, Share profile. 236 271 237 - Use the **connectivity_plus** package to monitor network state changes. Expose 238 - connectivity as a stream via a `ConnectivityCubit` that all screens observe. 272 + Mute and block actions should show a confirmation dialog before proceeding. 239 273 240 274 ## Saved Posts 241 275 ··· 264 298 Build a `SavedPostsCubit` that reads/writes the `saved_posts` table and 265 299 exposes a stream of saved post URIs for quick lookup (to show filled vs 266 300 outlined bookmark icons in the feed). 267 - 268 - ## Jump to Profile 269 - 270 - Add a floating action button on the search screen. Tapping it opens a dialog 271 - with a text field for entering a handle. Use 272 - `app.bsky.actor.searchActorsTypeahead` to provide autocomplete suggestions as 273 - the user types. Selecting a result or pressing enter navigates to that user's 274 - profile screen. 275 - 276 - | Endpoint | Purpose | 277 - | -------------------------------------- | ------------------- | 278 - | `app.bsky.actor.searchActorsTypeahead` | Handle autocomplete | 279 - | `app.bsky.actor.getProfile` | Full profile fetch |
+329
docs/specs/phase-4.md
··· 1 + # Phase 4 2 + 3 + ## Direct Messages 4 + 5 + DMs use the `chat.bsky.*` lexicon namespace. The DM feature has two views: a 6 + conversation list and a message thread. 7 + 8 + ### API 9 + 10 + | Endpoint | Purpose | 11 + | -------------------------------------- | ----------------------------- | 12 + | `chat.bsky.convo.listConvos` | Paginated conversation list | 13 + | `chat.bsky.convo.getConvo` | Single conversation metadata | 14 + | `chat.bsky.convo.getMessages` | Paginated messages in a convo | 15 + | `chat.bsky.convo.sendMessage` | Send a message | 16 + | `chat.bsky.convo.deleteMessageForSelf` | Delete a message locally | 17 + | `chat.bsky.convo.muteConvo` | Mute a conversation | 18 + | `chat.bsky.convo.unmuteConvo` | Unmute a conversation | 19 + | `chat.bsky.convo.updateRead` | Mark conversation as read | 20 + | `chat.bsky.convo.getLog` | Polling for new events | 21 + 22 + ### Conversation List 23 + 24 + `listConvos` returns conversations sorted by last message time. Each convo 25 + includes `id`, `members` (array of `profileViewBasic`), `lastMessage`, 26 + `unreadCount`, `muted`. 27 + 28 + Filter conversations into two tabs: **Primary** (accepted) and **Requests** 29 + (conversations the user has not yet responded to). A conversation is a 30 + "request" if the user has never sent a message in it. 31 + 32 + ### Message Thread 33 + 34 + `getMessages` returns paginated `messageView` objects. Each message has `id`, 35 + `text`, `sender` (DID), `sentAt`. Messages are displayed in a standard chat 36 + bubble layout — the current user's messages right-aligned, others left-aligned. 37 + 38 + Support long-press to copy individual messages. Provide a "Copy All" option in 39 + the conversation overflow menu to copy the full thread. 40 + 41 + ### Sending Messages 42 + 43 + `sendMessage` takes `convoId` and `message` (object with `text`). To start a 44 + new conversation, the app calls `chat.bsky.convo.getConvoForMembers` with the 45 + target DID(s) — this returns an existing convo or creates a new one. 46 + 47 + | Endpoint | Purpose | 48 + | ------------------------------------ | --------------------- | 49 + | `chat.bsky.convo.getConvoForMembers` | Get or create a convo | 50 + 51 + Build a `ConvoListBloc` with events: `ConvosRequested`, `ConvosRefreshed`, 52 + `ConvoMuted`, `ConvoUnmuted`. 53 + 54 + Build a `MessageBloc` with events: `MessagesRequested`, `MessagesPageLoaded`, 55 + `MessageSent`, `MessageDeleted`, `ConvoMarkedRead`. 56 + 57 + ## Account Switching 58 + 59 + Support multiple authenticated accounts with full data isolation. The 60 + `accounts` table (from Phase 1) already supports multiple rows keyed by DID. 61 + 62 + ### Active Account 63 + 64 + Store the active account DID in the Drift `settings` table under key 65 + `active_account_did`. On launch, read this value and restore the session for 66 + that account. 67 + 68 + ### Data Isolation 69 + 70 + All user-scoped tables must include an `account_did` FK column. Queries always 71 + filter by the active account's DID. Tables requiring this constraint: 72 + 73 + - `drafts` 74 + - `saved_posts` 75 + - `search_history` (Phase 2) 76 + - `cached_posts` (add `account_did` if not present) 77 + 78 + ### Switching Flow 79 + 80 + 1. User opens account switcher (bottom sheet or settings). 81 + 2. Selects a different account. 82 + 3. App updates `active_account_did` in settings. 83 + 4. All Blocs receive a `AccountSwitched` event and reload their state for the 84 + new account. 85 + 5. If the selected account's tokens are expired, attempt silent refresh. If 86 + refresh fails, navigate to login. 87 + 88 + ### Adding Accounts 89 + 90 + "Add Account" triggers the same OAuth flow from Phase 1. On success, a new row 91 + is inserted into `accounts`. The new account becomes the active account. 92 + 93 + Build an `AccountSwitcherCubit` that exposes the list of accounts and the 94 + active DID. 95 + 96 + ## Offline Reading 97 + 98 + The app should render cached data when the network is unavailable. This builds 99 + on the `cached_posts` and `cached_profiles` tables from Phase 1. 100 + 101 + ### Cache Strategy 102 + 103 + Cache the last-fetched page of each feed (timeline, pinned generators) in Drift 104 + as serialised JSON. On launch or feed switch, display cached data immediately, 105 + then fetch fresh data in the background. If the fetch fails, keep showing the 106 + cache with a "You're offline" banner. 107 + 108 + ### Offline Indicators 109 + 110 + - A persistent banner at the top of the screen when connectivity is lost. 111 + - Disable actions that require network (compose, like, repost, follow) and show 112 + a tooltip explaining why. 113 + - Notifications and DM screens show an empty state with "No connection" when 114 + offline and no cached data exists. 115 + 116 + ### Network Detection 117 + 118 + Use the **connectivity_plus** package to monitor network state changes. Expose 119 + connectivity as a stream via a `ConnectivityCubit` that all screens observe. 120 + 121 + ## Jump to Profile 122 + 123 + Add a floating action button on the search screen. Tapping it opens a dialog 124 + with a text field for entering a handle. Use 125 + `app.bsky.actor.searchActorsTypeahead` to provide autocomplete suggestions as 126 + the user types. Selecting a result or pressing enter navigates to that user's 127 + profile screen. 128 + 129 + | Endpoint | Purpose | 130 + | -------------------------------------- | ------------------- | 131 + | `app.bsky.actor.searchActorsTypeahead` | Handle autocomplete | 132 + | `app.bsky.actor.getProfile` | Full profile fetch | 133 + 134 + ## Labelers & Content Moderation 135 + 136 + Labelers are independent services that produce metadata labels about content 137 + and accounts. Users subscribe to labelers and configure how each label type 138 + affects their experience. The `bluesky` Dart package includes a built-in 139 + moderation decision engine that handles label interpretation. 140 + 141 + ### Architecture Overview 142 + 143 + ```text 144 + User Preferences ──► ModerationOpts ──► moderatePost() ──► ModerationDecision 145 + Label Definitions ─┘ moderateProfile() └─► getUI(context) 146 + moderateNotification() └─► ModerationUI 147 + ├─ filters 148 + ├─ blurs 149 + ├─ alerts 150 + └─ informs 151 + ``` 152 + 153 + ### API 154 + 155 + | Endpoint | Purpose | 156 + | ------------------------------- | ----------------------------------- | 157 + | `app.bsky.labeler.getServices` | Fetch labeler details by DID | 158 + | `app.bsky.actor.getPreferences` | Read labeler subscriptions + prefs | 159 + | `app.bsky.actor.putPreferences` | Write labeler subscriptions + prefs | 160 + 161 + ### Labeler Subscriptions 162 + 163 + Users subscribe to up to 20 labelers. Subscriptions are stored as a 164 + `labelersPref` entry in the user's preferences. Each entry contains a labeler 165 + DID. The official Bluesky moderation labeler is always active and does not 166 + count against the 20-labeler limit. 167 + 168 + On every XRPC request that returns content (feed, thread, profile, search, 169 + notifications), include the `atproto-accept-labelers` HTTP header with a 170 + comma-separated list of subscribed labeler DIDs. The AppView fetches labels 171 + from those labelers and attaches them to the response. 172 + 173 + ### Label Data Model 174 + 175 + A label is a lightweight annotation attached to content or an account: 176 + 177 + | Field | Type | Description | 178 + | ----- | --------- | --------------------------------------------------- | 179 + | `src` | string | DID of the labeler that created the label | 180 + | `uri` | string | AT-URI of the target resource (or DID for accounts) | 181 + | `cid` | string? | Optional CID for a specific version of the target | 182 + | `val` | string | Label value identifier (e.g. "porn", "spam") | 183 + | `neg` | bool? | If true, negates (retracts) a previous label | 184 + | `cts` | datetime | Creation timestamp | 185 + | `exp` | datetime? | Expiration timestamp | 186 + 187 + ### Label Behaviour Definitions 188 + 189 + Each label value is defined by three axes that determine its UI effect: 190 + 191 + | Axis | Values | Description | 192 + | ---------------- | -------------------------- | -------------------------------- | 193 + | `blurs` | `content`, `media`, `none` | What gets blurred | 194 + | `severity` | `inform`, `alert`, `none` | Badge type (neutral vs warning) | 195 + | `defaultSetting` | `ignore`, `warn`, `hide` | Default user-configurable action | 196 + 197 + Global (protocol-defined) label values include: 198 + 199 + | Label | Blurs | Default | Notes | 200 + | ---------------- | ------- | ------- | ------------------------------- | 201 + | `!hide` | content | hide | Non-configurable, no override | 202 + | `!warn` | content | warn | Non-configurable, click-through | 203 + | `porn` | media | hide | Adult, 18+ required | 204 + | `sexual` | media | warn | Adult, 18+ required | 205 + | `graphic-media` | media | warn | Adult, 18+ required | 206 + | `nudity` | media | ignore | Not 18+ restricted | 207 + | `dmca-violation` | content | hide | Non-configurable | 208 + | `doxxing` | content | hide | Non-configurable | 209 + 210 + Labelers may also define custom label values with localised names and 211 + descriptions via `LabelValueDefinition`. 212 + 213 + ### Self-Labels 214 + 215 + Authors can embed `selfLabels` directly in their posts and profiles. Only 216 + global label values are valid as self-labels. Self-labels are treated as if 217 + the author is the label source and follow the same behaviour rules. 218 + 219 + ### Moderation Decision Pipeline 220 + 221 + Use the `bluesky` package's moderation engine for all content display: 222 + 223 + 1. **Build `ModerationOpts`** from user preferences: 224 + - `adultContentEnabled` — boolean from preferences 225 + - `labels` — map of label value → preference (`ignore` / `warn` / `hide`) 226 + - `labelers` — list of subscribed labeler DIDs 227 + - `labelDefs` — map of labeler DID → list of custom `InterpretedLabelValueDefinition` 228 + 229 + 2. **Run moderation** on every piece of content before display: 230 + - `moderatePost(subject, opts)` for posts 231 + - `moderateProfile(subject, opts)` for profiles 232 + - `moderateNotification(subject, opts)` for notifications 233 + 234 + 3. **Apply `ModerationUI`** via `decision.getUI(context)` for each display 235 + context: 236 + - `contentList` — post in a feed or search results 237 + - `contentView` — post in a thread view 238 + - `contentMedia` — images/media within a post 239 + - `avatar` — profile avatar 240 + - `profileList` — profile in a list 241 + - `profileView` — full profile screen 242 + 243 + The `ModerationUI` object contains: 244 + 245 + - `filters` — content should be removed from the list entirely 246 + - `blurs` — content should be placed behind a click-through overlay 247 + - `alerts` — show a warning badge (negative connotation) 248 + - `informs` — show an informational badge (neutral) 249 + - `noOverride` — blur cannot be dismissed by the user 250 + 251 + ### Content Label Preferences 252 + 253 + Users configure per-label visibility via `contentLabelPref` entries in 254 + preferences. Each entry specifies: 255 + 256 + | Field | Description | 257 + | ------------ | ------------------------------------------- | 258 + | `labelerDid` | Scope to a specific labeler (null = global) | 259 + | `label` | The label value string | 260 + | `visibility` | `ignore`, `warn`, `hide`, or `show` | 261 + 262 + Labels with `adultOnly: true` in their definition require the user to have 263 + `adultContentEnabled` set to true. If adult content is disabled, these labels 264 + always apply as `hide` regardless of user preference. 265 + 266 + ### Rendering Rules 267 + 268 + **Blur overlay**: When `blurs` is non-empty, render a semi-transparent overlay 269 + with the label name and a "Show" button. If `noOverride` is true, omit the 270 + "Show" button — the content cannot be revealed. 271 + 272 + **Alert badge**: When `alerts` is non-empty, show a warning icon with the 273 + label name below the content or next to the profile name. 274 + 275 + **Inform badge**: When `informs` is non-empty, show an info icon with the 276 + label name. Use a neutral colour (not red/warning). 277 + 278 + **Filtering**: When `filters` is non-empty, remove the content from the 279 + current list view entirely. Do not render a placeholder. 280 + 281 + **Media blur**: When the `contentMedia` context has blurs, blur only images 282 + and embedded media while leaving the post text visible. 283 + 284 + **Avatar blur**: When the `avatar` context has blurs, show a generic 285 + placeholder avatar. 286 + 287 + ### ModerationService 288 + 289 + Build a `ModerationService` that: 290 + 291 + 1. Loads labeler subscriptions and label preferences from user preferences on 292 + login and account switch. 293 + 2. Fetches labeler details (`getServices` with `detailed: true`) for all 294 + subscribed labelers to obtain custom label definitions. 295 + 3. Caches label definitions in a Drift `labeler_cache` table for offline use. 296 + 4. Constructs `ModerationOpts` and exposes it as a stream that updates when 297 + preferences change. 298 + 5. Provides convenience methods: `moderatePost()`, `moderateProfile()`, 299 + `moderateNotification()`. 300 + 301 + ### Labeler Cache Table 302 + 303 + | Column | Type | Notes | 304 + | --------------- | -------- | -------------------------------- | 305 + | `labeler_did` | text | PK, DID of the labeler | 306 + | `policies_json` | text | Serialised `LabelerViewDetailed` | 307 + | `fetched_at` | datetime | When the data was last refreshed | 308 + 309 + Refresh the cache when the user opens the labeler management screen or when a 310 + subscribed labeler's data is older than 24 hours. 311 + 312 + ### Labeler Management UI 313 + 314 + The labeler management screen (accessible from Settings) shows: 315 + 316 + - A list of subscribed labelers, each showing: creator avatar, display name, 317 + description, and number of label definitions. 318 + - Tapping a labeler opens a detail screen showing all label values the labeler 319 + publishes, with the user's current preference (ignore/warn/hide) for each. 320 + - A toggle for subscribing/unsubscribing to the labeler. 321 + - An "Adult content" toggle at the top of the settings screen that gates all 322 + 18+ label preferences. 323 + 324 + ### Header Integration 325 + 326 + The XRPC client must be modified to include the `atproto-accept-labelers` 327 + header on all outgoing requests. The header value is a comma-separated list of 328 + labeler DIDs from the user's `labelersPref`. This should be set once on login 329 + and updated whenever preferences change.
+17 -40
docs/tasks/phase-3.md
··· 25 25 - [ ] Mark as read via `updateSeen` when notifications screen opens 26 26 - [ ] Tap notification to navigate to relevant post or profile 27 27 28 - ## M10 — Direct Messages 28 + ## M10 — Post & Profile Actions 29 29 30 - - [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination 31 - - [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted` 32 - - [ ] Primary / Requests tab filtering on conversation list 33 - - [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination 34 - - [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead` 35 - - [ ] Chat bubble layout — current user right-aligned, others left-aligned 36 - - [ ] Send messages via `chat.bsky.convo.sendMessage` 37 - - [ ] New conversation via `chat.bsky.convo.getConvoForMembers` 38 - - [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread 39 - - [ ] Mute / unmute conversations 40 - - [ ] Mark conversation as read via `chat.bsky.convo.updateRead` 30 + - [ ] `PostActionRepository` — like, repost, delete via `com.atproto.repo.createRecord` / `deleteRecord` 31 + - [ ] `PostActionCubit` — optimistic state updates for like / repost toggle with rollback on failure 32 + - [ ] Like toggle: create `app.bsky.feed.like` record or delete by rkey; update `viewer.like` and `likeCount` 33 + - [ ] Repost toggle: create `app.bsky.feed.repost` record or delete by rkey; update `viewer.repost` and `repostCount` 34 + - [ ] Post action bar UI — like, repost, reply, share buttons with animated state transitions 35 + - [ ] `ProfileActionRepository` — follow, mute, block, report 36 + - [ ] `ProfileActionCubit` — optimistic follow/mute/block state with rollback 37 + - [ ] Follow toggle: create `app.bsky.graph.follow` record or delete by rkey; update `viewer.following` 38 + - [ ] Mute toggle via `app.bsky.graph.muteActor` / `unmuteActor`; update `viewer.muted` 39 + - [ ] Block toggle: create `app.bsky.graph.block` record or delete by rkey; update `viewer.blocking` 40 + - [ ] Profile action buttons: Follow / Following / Mute / Block in profile header and overflow menu 41 + - [ ] Report dialog: reason picker + optional description, submit via `com.atproto.moderation.createReport` 42 + - [ ] Report for both posts (RepoStrongRef subject) and accounts (RepoRef subject) 43 + - [ ] Confirmation dialog before mute / block actions 44 + - [ ] Thread muting via `app.bsky.feed.threadgate` awareness (show muted-thread indicator) 41 45 42 - ## M11 — Account Switching 43 - 44 - - [ ] `AccountSwitcherCubit` exposing account list and active DID 45 - - [ ] Account switcher bottom sheet UI — list accounts with avatars and handles 46 - - [ ] Store `active_account_did` in Drift `settings` table 47 - - [ ] Drift migration: add `account_did` column to `cached_posts` if not present 48 - - [ ] All user-scoped queries filter by active account DID 49 - - [ ] Broadcast `AccountSwitched` event to all Blocs on switch 50 - - [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row 51 - - [ ] Silent token refresh on account switch; navigate to login on failure 52 - 53 - ## M12 — Offline Reading & Network Resilience 54 - 55 - - [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream 56 - - [ ] Cache last-fetched feed page as serialised JSON in Drift 57 - - [ ] Display cached data immediately on launch, refresh in background 58 - - [ ] "You're offline" banner when connectivity is lost 59 - - [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip 60 - - [ ] Notifications and DM screens show "No connection" empty state when offline with no cache 61 - 62 - ## M13 — Saved Posts 46 + ## M11 — Saved Posts 63 47 64 48 - [ ] Drift migration: add `saved_posts` table (id, account_did, post_uri, post_json, saved_at) with unique constraint on (account_did, post_uri) 65 49 - [ ] `SavedPostsCubit` — read/write saved posts, expose stream of saved URIs for icon state 66 50 - [ ] Bookmark icon on post action bar — toggle saved state 67 51 - [ ] Saved posts list screen accessible from profile or settings 68 - 69 - ## M14 — Jump to Profile 70 - 71 - - [ ] Floating action button on search screen 72 - - [ ] Handle input dialog with autocomplete via `searchActorsTypeahead` 73 - - [ ] Navigate to profile screen on selection or enter 74 - - [ ] Update bottom navigation to include Notifications and Messages tabs (5-tab layout)
+61
docs/tasks/phase-4.md
··· 1 + # Phase 4 Milestones 2 + 3 + ## M12 — Direct Messages 4 + 5 + - [ ] Conversation list screen via `chat.bsky.convo.listConvos` with pagination 6 + - [ ] `ConvoListBloc` — events: `ConvosRequested`, `ConvosRefreshed`, `ConvoMuted`, `ConvoUnmuted` 7 + - [ ] Primary / Requests tab filtering on conversation list 8 + - [ ] Message thread screen via `chat.bsky.convo.getMessages` with pagination 9 + - [ ] `MessageBloc` — events: `MessagesRequested`, `MessagesPageLoaded`, `MessageSent`, `MessageDeleted`, `ConvoMarkedRead` 10 + - [ ] Chat bubble layout — current user right-aligned, others left-aligned 11 + - [ ] Send messages via `chat.bsky.convo.sendMessage` 12 + - [ ] New conversation via `chat.bsky.convo.getConvoForMembers` 13 + - [ ] Long-press to copy individual messages, overflow menu "Copy All" for full thread 14 + - [ ] Mute / unmute conversations 15 + - [ ] Mark conversation as read via `chat.bsky.convo.updateRead` 16 + 17 + ## M13 — Account Switching 18 + 19 + - [ ] `AccountSwitcherCubit` exposing account list and active DID 20 + - [ ] Account switcher bottom sheet UI — list accounts with avatars and handles 21 + - [ ] Store `active_account_did` in Drift `settings` table 22 + - [ ] Drift migration: add `account_did` column to `cached_posts` if not present 23 + - [ ] All user-scoped queries filter by active account DID 24 + - [ ] Broadcast `AccountSwitched` event to all Blocs on switch 25 + - [ ] "Add Account" button triggers OAuth flow, inserts new `accounts` row 26 + - [ ] Silent token refresh on account switch; navigate to login on failure 27 + 28 + ## M14 — Offline Reading & Network Resilience 29 + 30 + - [ ] `ConnectivityCubit` via **connectivity_plus** — expose network state stream 31 + - [ ] Cache last-fetched feed page as serialised JSON in Drift 32 + - [ ] Display cached data immediately on launch, refresh in background 33 + - [ ] "You're offline" banner when connectivity is lost 34 + - [ ] Disable network-dependent actions (compose, like, repost, follow) when offline with tooltip 35 + - [ ] Notifications and DM screens show "No connection" empty state when offline with no cache 36 + 37 + ## M15 — Jump to Profile 38 + 39 + - [ ] Floating action button on search screen 40 + - [ ] Handle input dialog with autocomplete via `searchActorsTypeahead` 41 + - [ ] Navigate to profile screen on selection or enter 42 + - [ ] Update bottom navigation to include Notifications and Messages tabs (5-tab layout) 43 + 44 + ## M16 — Labelers & Content Moderation 45 + 46 + - [ ] Fetch user's labeler subscriptions from preferences via `app.bsky.actor.getPreferences` (`labelersPref`) 47 + - [ ] Include subscribed labeler DIDs in `atproto-accept-labelers` header on all XRPC requests 48 + - [ ] `ModerationService` — wraps the `bluesky` package's `moderatePost`, `moderateProfile`, `moderateNotification` functions 49 + - [ ] Run moderation decisions on all displayed posts and profiles 50 + - [ ] Apply `ModerationUI` results: filter, blur, alert, inform per display context (contentList, contentView, contentMedia, avatar, profileList, profileView) 51 + - [ ] Blur overlay on posts/media with click-through "Show content" button 52 + - [ ] Warning badges on profiles and posts for alert/inform labels 53 + - [ ] Content filtering — remove posts with `filter` decisions from feed and notification lists 54 + - [ ] Labeler management screen: list subscribed labelers via `app.bsky.labeler.getServices` 55 + - [ ] Subscribe / unsubscribe to labelers by updating `labelersPref` via `putPreferences` 56 + - [ ] Per-label preference configuration: ignore / warn / hide per label value per labeler 57 + - [ ] Store label preferences as `contentLabelPref` entries via `putPreferences` 58 + - [ ] Adult content toggle (requires `adultContentEnabled` preference) 59 + - [ ] Self-label support — render self-labels embedded in posts and profiles 60 + - [ ] Labeler detail screen: show labeler creator, policies, and custom label definitions with localised names 61 + - [ ] Drift table: `labeler_cache` (labeler_did, policies_json, fetched_at) for offline label definition lookup
+14
www/client-metadata.json
··· 1 + { 2 + "client_id": "https://lazurite.stormlightlabs.org/client-metadata.json", 3 + "client_name": "Lazurite", 4 + "client_uri": "https://lazurite.stormlightlabs.org", 5 + "redirect_uris": ["http://127.0.0.1/callback"], 6 + "scope": "atproto transition:generic", 7 + "grant_types": ["authorization_code", "refresh_token"], 8 + "response_types": ["code"], 9 + "token_endpoint_auth_method": "none", 10 + "application_type": "native", 11 + "dpop_bound_access_tokens": true, 12 + "software_id": "org.stormlightlabs.lazurite", 13 + "software_version": "1.0.0" 14 + }
+384
www/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <meta name="description" content="Lazurite - A beautiful Material You Bluesky client for mobile and desktop" /> 7 + <meta name="author" content="Stormlight Labs" /> 8 + <title>Lazurite - Coming Soon</title> 9 + <link rel="icon" type="image/svg+xml" href="static/favicon.svg" /> 10 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 11 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 12 + <link 13 + href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&family=DM+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" 14 + rel="stylesheet" /> 15 + 16 + <style> 17 + :root { 18 + --bg-dark: #161616; 19 + --surface-dark: #262626; 20 + --surface-variant: #393939; 21 + --outline: #525252; 22 + --text-primary: #f2f4f8; 23 + --text-secondary: #dde1e6; 24 + --text-tertiary: #697689; 25 + 26 + --primary: #0085ff; 27 + --secondary: #78a9ff; 28 + --tertiary: #33b1ff; 29 + --cyan: #08bdba; 30 + --purple: #be95ff; 31 + 32 + --font-display: "Lora", serif; 33 + --font-body: "DM Sans", sans-serif; 34 + --font-mono: "JetBrains Mono", monospace; 35 + } 36 + 37 + * { 38 + margin: 0; 39 + padding: 0; 40 + box-sizing: border-box; 41 + } 42 + 43 + body { 44 + font-family: var(--font-body); 45 + background-color: var(--bg-dark); 46 + color: var(--text-primary); 47 + line-height: 1.6; 48 + min-height: 100vh; 49 + display: flex; 50 + flex-direction: column; 51 + } 52 + 53 + .container { 54 + max-width: 1200px; 55 + margin: 0 auto; 56 + padding: 2rem; 57 + flex: 1; 58 + display: flex; 59 + flex-direction: column; 60 + justify-content: center; 61 + } 62 + 63 + header { 64 + text-align: center; 65 + margin-bottom: 4rem; 66 + } 67 + 68 + .logo { 69 + font-family: var(--font-display); 70 + font-size: 3.5rem; 71 + font-weight: 700; 72 + background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 50%, var(--tertiary) 100%); 73 + -webkit-background-clip: text; 74 + -webkit-text-fill-color: transparent; 75 + background-clip: text; 76 + margin-bottom: 1rem; 77 + letter-spacing: -0.02em; 78 + } 79 + 80 + .tagline { 81 + font-size: 1.25rem; 82 + color: var(--text-secondary); 83 + font-weight: 400; 84 + letter-spacing: 0.01em; 85 + } 86 + 87 + main { 88 + max-width: 800px; 89 + margin: 0 auto; 90 + } 91 + 92 + .hero { 93 + text-align: center; 94 + margin-bottom: 4rem; 95 + } 96 + 97 + .hero h1 { 98 + font-family: var(--font-display); 99 + font-size: 2.5rem; 100 + font-weight: 600; 101 + margin-bottom: 1.5rem; 102 + color: var(--text-primary); 103 + line-height: 1.2; 104 + } 105 + 106 + .hero p { 107 + font-size: 1.125rem; 108 + color: var(--text-secondary); 109 + margin-bottom: 2rem; 110 + line-height: 1.8; 111 + } 112 + 113 + .status-badge { 114 + display: inline-flex; 115 + align-items: center; 116 + gap: 0.5rem; 117 + background: var(--surface-dark); 118 + padding: 0.75rem 1.5rem; 119 + border-radius: 2rem; 120 + border: 1px solid var(--outline); 121 + font-size: 0.875rem; 122 + font-weight: 500; 123 + letter-spacing: 0.02em; 124 + margin-bottom: 3rem; 125 + } 126 + 127 + .status-dot { 128 + width: 8px; 129 + height: 8px; 130 + border-radius: 50%; 131 + background: var(--primary); 132 + animation: pulse 2s ease-in-out infinite; 133 + } 134 + 135 + @keyframes pulse { 136 + 0%, 137 + 100% { 138 + opacity: 1; 139 + } 140 + 141 + 50% { 142 + opacity: 0.5; 143 + } 144 + } 145 + 146 + .features { 147 + display: grid; 148 + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 149 + gap: 1.5rem; 150 + margin-bottom: 4rem; 151 + } 152 + 153 + .feature { 154 + background: var(--surface-dark); 155 + padding: 2rem; 156 + border-radius: 1rem; 157 + border: 1px solid var(--outline); 158 + transition: 159 + transform 0.2s ease, 160 + border-color 0.2s ease; 161 + } 162 + 163 + .feature:hover { 164 + transform: translateY(-2px); 165 + border-color: var(--primary); 166 + } 167 + 168 + .feature h3 { 169 + font-family: var(--font-display); 170 + font-size: 1.25rem; 171 + margin-bottom: 0.75rem; 172 + color: var(--text-primary); 173 + } 174 + 175 + .feature p { 176 + color: var(--text-secondary); 177 + font-size: 0.9375rem; 178 + line-height: 1.6; 179 + } 180 + 181 + .feature-icon { 182 + width: 48px; 183 + height: 48px; 184 + background: linear-gradient(135deg, var(--primary), var(--secondary)); 185 + border-radius: 12px; 186 + display: flex; 187 + align-items: center; 188 + justify-content: center; 189 + margin-bottom: 1rem; 190 + padding: 12px; 191 + } 192 + 193 + .feature-icon img { 194 + width: 100%; 195 + height: 100%; 196 + color: var(--bg-dark); 197 + } 198 + 199 + .tech-stack { 200 + background: var(--surface-dark); 201 + padding: 2rem; 202 + border-radius: 1rem; 203 + border: 1px solid var(--outline); 204 + margin-bottom: 4rem; 205 + } 206 + 207 + .tech-stack h2 { 208 + font-family: var(--font-display); 209 + font-size: 1.5rem; 210 + margin-bottom: 1.5rem; 211 + color: var(--text-primary); 212 + } 213 + 214 + .tech-list { 215 + display: flex; 216 + flex-wrap: wrap; 217 + gap: 0.75rem; 218 + } 219 + 220 + .tech-tag { 221 + background: var(--surface-variant); 222 + padding: 0.5rem 1rem; 223 + border-radius: 0.5rem; 224 + font-size: 0.875rem; 225 + font-family: var(--font-mono); 226 + color: var(--text-secondary); 227 + border: 1px solid transparent; 228 + transition: border-color 0.2s ease; 229 + } 230 + 231 + .tech-tag:hover { 232 + border-color: var(--primary); 233 + } 234 + 235 + footer { 236 + text-align: center; 237 + padding: 2rem; 238 + color: var(--text-tertiary); 239 + font-size: 0.875rem; 240 + border-top: 1px solid var(--outline); 241 + margin-top: 4rem; 242 + } 243 + 244 + footer a { 245 + color: var(--primary); 246 + text-decoration: none; 247 + transition: color 0.2s ease; 248 + } 249 + 250 + footer a:hover { 251 + color: var(--secondary); 252 + } 253 + 254 + .oauth-info { 255 + background: var(--surface-variant); 256 + padding: 1rem 1.5rem; 257 + border-radius: 0.75rem; 258 + border-left: 3px solid var(--primary); 259 + margin-top: 2rem; 260 + font-size: 0.875rem; 261 + color: var(--text-secondary); 262 + } 263 + 264 + .oauth-info a { 265 + color: var(--primary); 266 + text-decoration: none; 267 + font-weight: 500; 268 + transition: color 0.2s ease; 269 + } 270 + 271 + .oauth-info a:hover { 272 + color: var(--secondary); 273 + text-decoration: underline; 274 + } 275 + 276 + @media (max-width: 768px) { 277 + .logo { 278 + font-size: 2.5rem; 279 + } 280 + 281 + .hero h1 { 282 + font-size: 2rem; 283 + } 284 + 285 + .hero p { 286 + font-size: 1rem; 287 + } 288 + 289 + .features { 290 + grid-template-columns: 1fr; 291 + } 292 + 293 + .container { 294 + padding: 1.5rem; 295 + } 296 + } 297 + </style> 298 + </head> 299 + 300 + <body> 301 + <div class="container"> 302 + <header> 303 + <div class="logo">Lazurite</div> 304 + <p class="tagline">Coming Soon</p> 305 + </header> 306 + 307 + <main> 308 + <div class="hero"> 309 + <div class="status-badge"> 310 + <span class="status-dot"></span> 311 + <span>In Active Development</span> 312 + </div> 313 + 314 + <h1>A better BlueSky client</h1> 315 + <p> 316 + Lazurite is a cross-platform mobile and desktop client for Bluesky, with a mobile-first design for Android 317 + and iOS, with desktop support for a seamless experience. 318 + </p> 319 + </div> 320 + 321 + <div class="features"> 322 + <div class="feature"> 323 + <div class="feature-icon"> 324 + <img src="static/palette.svg" alt="Palette icon" /> 325 + </div> 326 + <h3>Material You Design</h3> 327 + <p>Beautiful IBM Oxocarbon-inspired theme with dynamic color adaptation and smooth animations.</p> 328 + </div> 329 + 330 + <div class="feature"> 331 + <div class="feature-icon"> 332 + <img src="static/message.svg" alt="Message icon" /> 333 + </div> 334 + <h3>Full-Featured</h3> 335 + <p>Timeline, threads, profiles, notifications, search, direct messages, and smart folders.</p> 336 + </div> 337 + 338 + <div class="feature"> 339 + <div class="feature-icon"> 340 + <img src="static/offline.svg" alt="Offline icon" /> 341 + </div> 342 + <h3>Offline-First</h3> 343 + <p>Smart caching with Drift database for lightning-fast performance and offline compose.</p> 344 + </div> 345 + 346 + <div class="feature"> 347 + <div class="feature-icon"> 348 + <img src="static/folder.svg" alt="Folder icon" /> 349 + </div> 350 + <h3>Smart Folders</h3> 351 + <p>Create custom local-only feeds with powerful filtering and organization tools.</p> 352 + </div> 353 + </div> 354 + 355 + <div class="tech-stack"> 356 + <h2>Built with...</h2> 357 + <div class="tech-list"> 358 + <span class="tech-tag">Flutter</span> 359 + <span class="tech-tag">Dart</span> 360 + <span class="tech-tag">Material 3</span> 361 + <span class="tech-tag">AT Protocol</span> 362 + </div> 363 + </div> 364 + 365 + <div class="oauth-info"> 366 + Follow development updates on Bluesky: 367 + <a href="https://bsky.app/profile/desertthunder.dev" target="_blank" rel="noopener noreferrer" 368 + >@desertthunder.dev</a 369 + > 370 + </div> 371 + </main> 372 + </div> 373 + 374 + <footer> 375 + <p> 376 + Built with ⚡️ by 377 + <a href="https://desertthunder.dev" target="_blank">Owais</a> 378 + at 379 + <a href="https://stormlightlabs.org" target="_blank">Stormlight Labs</a> 380 + &middot; Powered by the <a href="https://atproto.com" target="_blank">AT Protocol</a> 381 + </p> 382 + </footer> 383 + </body> 384 + </html>
+3
www/static/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 512 512"> 2 + <path fill="#0085FF" d="M128 16v99.3l119 118.9V120.1zm256 0L265 120.1v114.1l119-119zM16 128l104 119h114.2L115.3 128zm380.8 0l-119 119h114.1l104-119zM120 265L16 384h99.2l119-119zm157.8 0l119 119h99.1l-104-119zM247 277.8l-119 119V496l119-104.1zm18 0v114.1L384 496v-99.2z" /> 3 + </svg>
+6
www/static/folder.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"> 2 + <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"> 3 + <path stroke-miterlimit="10" d="M11.993 10.307v6.874m-3.43-3.437h6.874" /> 4 + <path stroke-linejoin="round" d="M21.25 9.883v7.698a3.083 3.083 0 0 1-3.083 3.083H5.833a3.083 3.083 0 0 1-3.083-3.083V6.419a3.083 3.083 0 0 1 3.083-3.083h3.084a3.08 3.08 0 0 1 2.57 1.377l.873 1.326a1.75 1.75 0 0 0 1.449.77h4.358a3.084 3.084 0 0 1 3.083 3.074" /> 5 + </g> 6 + </svg>
+4
www/static/lock.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2 + <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/> 3 + <path d="M7 11V7a5 5 0 0 1 10 0v4"/> 4 + </svg>
+3
www/static/message.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2 + <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> 3 + </svg>
+3
www/static/offline.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 24 24"> 2 + <path fill="currentColor" d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m-5-5h10v2H7zm3.3-3.8L8.4 9.3L7 10.7l3.3 3.3L17 7.3l-1.4-1.4z" /> 3 + </svg>
+7
www/static/palette.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 2 + <circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/> 3 + <circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/> 4 + <circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/> 5 + <circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/> 6 + <path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/> 7 + </svg>
+6
www/static/smartphone.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 48 48"> 2 + <g fill="currentColor"> 3 + <path fill-rule="evenodd" d="M12.967 7.005A3 3 0 0 1 15.96 4l15.973-.026a3 3 0 0 1 3.005 2.995l.055 33.987A3 3 0 0 1 32 43.96l-15.972.026a3 3 0 0 1-3.005-2.995zm1.999.997a2 2 0 0 1 1.996-2.004l13.978-.022a2 2 0 0 1 2.003 1.996l.044 26.99a2 2 0 0 1-1.997 2.003l-13.977.023a2 2 0 0 1-2.003-1.996zm9.044 33.973a1.998 1.998 0 1 0-.008-3.997a1.998 1.998 0 0 0 .008 3.996" clip-rule="evenodd" /> 4 + <path d="M23.192 27.54c-1.589-1.316-2.895-2.398-3.806-3.416c-.91-1.015-1.41-1.95-1.41-2.967c-.002-1.656 1.418-2.957 3.228-2.959c1.024 0 2.01.436 2.653 1.125l.117.126l.117-.126c.642-.69 1.627-1.128 2.652-1.129c1.81-.002 3.231 1.297 3.233 2.953c0 1.018-.497 1.953-1.405 2.97c-.907 1.017-2.206 2.098-3.785 3.413l-.016.014l-.788.652z" /> 5 + </g> 6 + </svg>